fix(snaps): stabilize gap snapline rendering
This commit is contained in:
+209
-98
@@ -39,6 +39,10 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const SNAP_DISTANCE = 8;
|
const SNAP_DISTANCE = 8;
|
||||||
|
// Keep snap candidates with effectively identical offsets together. This needs
|
||||||
|
// to be wider than the 6-decimal rounding quantum to absorb JS floating-point
|
||||||
|
// noise around values like 4 and 4.000001.
|
||||||
|
const SNAP_OFFSET_TOLERANCE = 0.00001;
|
||||||
// Point snaps can come from both nearby and distant references within snap
|
// Point snaps can come from both nearby and distant references within snap
|
||||||
// distance. If their visual distances have a large break, prefer the nearest
|
// distance. If their visual distances have a large break, prefer the nearest
|
||||||
// cluster before comparing offsets.
|
// cluster before comparing offsets.
|
||||||
@@ -67,34 +71,34 @@ type Vector2D = {
|
|||||||
|
|
||||||
type PointPair = [GlobalPoint, GlobalPoint];
|
type PointPair = [GlobalPoint, GlobalPoint];
|
||||||
type SnapPointType = "outer" | "center";
|
type SnapPointType = "outer" | "center";
|
||||||
type SnapPointSourceId = string;
|
type SnapSourceId = string;
|
||||||
|
|
||||||
const SELECTION_SNAP_POINT_SOURCE_ID = "selection";
|
const SELECTION_SNAP_SOURCE_ID = "selection";
|
||||||
|
|
||||||
// Source ids let us filter redundant snaplines only within one
|
// Snap source ids let us filter redundant snaplines only within one
|
||||||
// selection-to-reference relationship, without mixing separate reference groups.
|
// selection-to-reference relationship, without mixing separate reference groups.
|
||||||
// For example, "selection->rectA" may collapse to its outermost snaplines,
|
// For example, "selection->rectA" may collapse to its outermost snaplines,
|
||||||
// while "selection->rectB" still keeps its own independent snaplines.
|
// while "selection->rectB" still keeps its own independent snaplines.
|
||||||
type SnapPoint = {
|
type SnapPoint = {
|
||||||
point: GlobalPoint;
|
point: GlobalPoint;
|
||||||
type: SnapPointType;
|
type: SnapPointType;
|
||||||
sourceId: SnapPointSourceId;
|
snapSourceId: SnapSourceId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const outerSnapPoint = (
|
const outerSnapPoint = (
|
||||||
point: GlobalPoint,
|
point: GlobalPoint,
|
||||||
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
|
snapSourceId = SELECTION_SNAP_SOURCE_ID,
|
||||||
): SnapPoint => ({
|
): SnapPoint => ({
|
||||||
point,
|
point,
|
||||||
type: "outer",
|
type: "outer",
|
||||||
sourceId,
|
snapSourceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PointSnap = {
|
export type PointSnap = {
|
||||||
type: "point";
|
type: "point";
|
||||||
points: PointPair;
|
points: PointPair;
|
||||||
pointTypes: [SnapPointType, SnapPointType];
|
pointTypes: [SnapPointType, SnapPointType];
|
||||||
sourceIds: [SnapPointSourceId, SnapPointSourceId];
|
snapSourceIds: [SnapSourceId, SnapSourceId];
|
||||||
// Distance along the rendered snapline, used to group visually nearby
|
// Distance along the rendered snapline, used to group visually nearby
|
||||||
// references before choosing which point snaps should win.
|
// references before choosing which point snaps should win.
|
||||||
visualDistance: number;
|
visualDistance: number;
|
||||||
@@ -234,6 +238,17 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
|
|||||||
return Math.abs(a - b) <= precision;
|
return Math.abs(a - b) <= precision;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps nearly identical snap offsets in the same winner set. This prevents
|
||||||
|
* snapline flicker when rounded geometry differs by a tiny floating-point tail.
|
||||||
|
*/
|
||||||
|
const isWithinSnapOffset = (offset: number, minOffset: number) => {
|
||||||
|
return (
|
||||||
|
Math.abs(offset) <= minOffset ||
|
||||||
|
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getElementsCorners = (
|
export const getElementsCorners = (
|
||||||
elements: ExcalidrawElement[],
|
elements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@@ -355,7 +370,7 @@ const getElementsSnapPoints = (
|
|||||||
elements: ExcalidrawElement[],
|
elements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
options: Parameters<typeof getElementsCorners>[2] = {},
|
options: Parameters<typeof getElementsCorners>[2] = {},
|
||||||
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
|
snapSourceId = SELECTION_SNAP_SOURCE_ID,
|
||||||
): SnapPoint[] => {
|
): SnapPoint[] => {
|
||||||
const points = getElementsCorners(elements, elementsMap, options);
|
const points = getElementsCorners(elements, elementsMap, options);
|
||||||
// getElementsCorners() appends the center point last unless omitCenter is set.
|
// getElementsCorners() appends the center point last unless omitCenter is set.
|
||||||
@@ -364,17 +379,25 @@ const getElementsSnapPoints = (
|
|||||||
return points.map((point, index) => ({
|
return points.map((point, index) => ({
|
||||||
point,
|
point,
|
||||||
type: hasCenterPoint && index === points.length - 1 ? "center" : "outer",
|
type: hasCenterPoint && index === points.length - 1 ? "center" : "outer",
|
||||||
sourceId,
|
snapSourceId,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSnapPointSourceId = (elements: ExcalidrawElement[]) => {
|
/**
|
||||||
|
* Builds a stable identity for one snap reference group. Sorting keeps grouped
|
||||||
|
* elements represented by the same snap source id regardless of element order.
|
||||||
|
*/
|
||||||
|
const getSnapSourceId = (elements: ExcalidrawElement[]) => {
|
||||||
return elements
|
return elements
|
||||||
.map((element) => element.id)
|
.map((element) => element.id)
|
||||||
.sort()
|
.sort()
|
||||||
.join(",");
|
.join(",");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds frames that the current selection already belongs to. Their children
|
||||||
|
* are still valid snap references; children of unrelated frames are not.
|
||||||
|
*/
|
||||||
const getSelectedFrameIdsForSnapping = (
|
const getSelectedFrameIdsForSnapping = (
|
||||||
selectedElements: readonly ExcalidrawElement[],
|
selectedElements: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
@@ -389,6 +412,18 @@ const getSelectedFrameIdsForSnapping = (
|
|||||||
return selectedFrameIds;
|
return selectedFrameIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame children are only snap references when the dragged selection is inside
|
||||||
|
* the same frame. This avoids snapping external elements to internals of a
|
||||||
|
* framed diagram.
|
||||||
|
*/
|
||||||
|
const canUseElementAsSnapReference = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
selectedFrameIds: Set<ExcalidrawElement["id"]>,
|
||||||
|
) => {
|
||||||
|
return !element.frameId || selectedFrameIds.has(element.frameId);
|
||||||
|
};
|
||||||
|
|
||||||
const getReferenceElements = (
|
const getReferenceElements = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
@@ -403,7 +438,7 @@ const getReferenceElements = (
|
|||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
).filter((element) => {
|
).filter((element) => {
|
||||||
return !element.frameId || selectedFrameIds.has(element.frameId);
|
return canUseElementAsSnapReference(element, selectedFrameIds);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -564,7 +599,10 @@ const getGapSnaps = (
|
|||||||
const centerOffset = round(gapMidX - centerX);
|
const centerOffset = round(gapMidX - centerX);
|
||||||
const gapIsLargerThanSelection = gap.length > maxX - minX;
|
const gapIsLargerThanSelection = gap.length > maxX - minX;
|
||||||
|
|
||||||
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
|
if (
|
||||||
|
gapIsLargerThanSelection &&
|
||||||
|
isWithinSnapOffset(centerOffset, minOffset.x)
|
||||||
|
) {
|
||||||
if (Math.abs(centerOffset) < minOffset.x) {
|
if (Math.abs(centerOffset) < minOffset.x) {
|
||||||
nearestSnapsX.length = 0;
|
nearestSnapsX.length = 0;
|
||||||
}
|
}
|
||||||
@@ -586,7 +624,7 @@ const getGapSnaps = (
|
|||||||
const distanceToEndElementX = minX - endMaxX;
|
const distanceToEndElementX = minX - endMaxX;
|
||||||
const sideOffsetRight = round(gap.length - distanceToEndElementX);
|
const sideOffsetRight = round(gap.length - distanceToEndElementX);
|
||||||
|
|
||||||
if (Math.abs(sideOffsetRight) <= minOffset.x) {
|
if (isWithinSnapOffset(sideOffsetRight, minOffset.x)) {
|
||||||
if (Math.abs(sideOffsetRight) < minOffset.x) {
|
if (Math.abs(sideOffsetRight) < minOffset.x) {
|
||||||
nearestSnapsX.length = 0;
|
nearestSnapsX.length = 0;
|
||||||
}
|
}
|
||||||
@@ -607,7 +645,7 @@ const getGapSnaps = (
|
|||||||
const distanceToStartElementX = startMinX - maxX;
|
const distanceToStartElementX = startMinX - maxX;
|
||||||
const sideOffsetLeft = round(distanceToStartElementX - gap.length);
|
const sideOffsetLeft = round(distanceToStartElementX - gap.length);
|
||||||
|
|
||||||
if (Math.abs(sideOffsetLeft) <= minOffset.x) {
|
if (isWithinSnapOffset(sideOffsetLeft, minOffset.x)) {
|
||||||
if (Math.abs(sideOffsetLeft) < minOffset.x) {
|
if (Math.abs(sideOffsetLeft) < minOffset.x) {
|
||||||
nearestSnapsX.length = 0;
|
nearestSnapsX.length = 0;
|
||||||
}
|
}
|
||||||
@@ -633,7 +671,10 @@ const getGapSnaps = (
|
|||||||
const centerOffset = round(gapMidY - centerY);
|
const centerOffset = round(gapMidY - centerY);
|
||||||
const gapIsLargerThanSelection = gap.length > maxY - minY;
|
const gapIsLargerThanSelection = gap.length > maxY - minY;
|
||||||
|
|
||||||
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) {
|
if (
|
||||||
|
gapIsLargerThanSelection &&
|
||||||
|
isWithinSnapOffset(centerOffset, minOffset.y)
|
||||||
|
) {
|
||||||
if (Math.abs(centerOffset) < minOffset.y) {
|
if (Math.abs(centerOffset) < minOffset.y) {
|
||||||
nearestSnapsY.length = 0;
|
nearestSnapsY.length = 0;
|
||||||
}
|
}
|
||||||
@@ -655,7 +696,7 @@ const getGapSnaps = (
|
|||||||
const distanceToStartElementY = startMinY - maxY;
|
const distanceToStartElementY = startMinY - maxY;
|
||||||
const sideOffsetTop = round(distanceToStartElementY - gap.length);
|
const sideOffsetTop = round(distanceToStartElementY - gap.length);
|
||||||
|
|
||||||
if (Math.abs(sideOffsetTop) <= minOffset.y) {
|
if (isWithinSnapOffset(sideOffsetTop, minOffset.y)) {
|
||||||
if (Math.abs(sideOffsetTop) < minOffset.y) {
|
if (Math.abs(sideOffsetTop) < minOffset.y) {
|
||||||
nearestSnapsY.length = 0;
|
nearestSnapsY.length = 0;
|
||||||
}
|
}
|
||||||
@@ -676,7 +717,7 @@ const getGapSnaps = (
|
|||||||
const distanceToEndElementY = round(minY - endMaxY);
|
const distanceToEndElementY = round(minY - endMaxY);
|
||||||
const sideOffsetBottom = gap.length - distanceToEndElementY;
|
const sideOffsetBottom = gap.length - distanceToEndElementY;
|
||||||
|
|
||||||
if (Math.abs(sideOffsetBottom) <= minOffset.y) {
|
if (isWithinSnapOffset(sideOffsetBottom, minOffset.y)) {
|
||||||
if (Math.abs(sideOffsetBottom) < minOffset.y) {
|
if (Math.abs(sideOffsetBottom) < minOffset.y) {
|
||||||
nearestSnapsY.length = 0;
|
nearestSnapsY.length = 0;
|
||||||
}
|
}
|
||||||
@@ -757,14 +798,22 @@ const filterPointSnapsToNearestCluster = (
|
|||||||
const minOffset = Math.min(
|
const minOffset = Math.min(
|
||||||
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
|
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
|
||||||
);
|
);
|
||||||
const selectedOffsets = new Set(
|
const selectedOffsets = nearestOffsetGroups
|
||||||
nearestOffsetGroups
|
.filter(({ offset }) =>
|
||||||
.filter(({ offset }) => Math.abs(offset) === minOffset)
|
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE),
|
||||||
.map(({ offset }) => offset),
|
)
|
||||||
);
|
.map(({ offset }) => offset);
|
||||||
|
|
||||||
for (const offsetSnaps of snapsByOffset.values()) {
|
for (const offsetSnaps of snapsByOffset.values()) {
|
||||||
if (!selectedOffsets.has(round(offsetSnaps[0].offset))) {
|
if (
|
||||||
|
!selectedOffsets.some((offset) =>
|
||||||
|
areRoughlyEqual(
|
||||||
|
round(offsetSnaps[0].offset),
|
||||||
|
offset,
|
||||||
|
SNAP_OFFSET_TOLERANCE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +879,7 @@ export const getReferenceSnapPoints = (
|
|||||||
elementGroup,
|
elementGroup,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
{},
|
{},
|
||||||
getSnapPointSourceId(elementGroup),
|
getSnapSourceId(elementGroup),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -864,12 +913,15 @@ const getPointSnaps = (
|
|||||||
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
|
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
|
||||||
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
|
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
|
||||||
|
|
||||||
if (Math.abs(offsetX) <= minOffset.x) {
|
if (isWithinSnapOffset(offsetX, minOffset.x)) {
|
||||||
nearestSnapsX.push({
|
nearestSnapsX.push({
|
||||||
type: "point",
|
type: "point",
|
||||||
points: [thisSnapPoint.point, otherSnapPoint.point],
|
points: [thisSnapPoint.point, otherSnapPoint.point],
|
||||||
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
||||||
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
|
snapSourceIds: [
|
||||||
|
thisSnapPoint.snapSourceId,
|
||||||
|
otherSnapPoint.snapSourceId,
|
||||||
|
],
|
||||||
visualDistance: Math.abs(
|
visualDistance: Math.abs(
|
||||||
otherSnapPoint.point[1] - thisSnapPoint.point[1],
|
otherSnapPoint.point[1] - thisSnapPoint.point[1],
|
||||||
),
|
),
|
||||||
@@ -877,12 +929,15 @@ const getPointSnaps = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(offsetY) <= minOffset.y) {
|
if (isWithinSnapOffset(offsetY, minOffset.y)) {
|
||||||
nearestSnapsY.push({
|
nearestSnapsY.push({
|
||||||
type: "point",
|
type: "point",
|
||||||
points: [thisSnapPoint.point, otherSnapPoint.point],
|
points: [thisSnapPoint.point, otherSnapPoint.point],
|
||||||
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
||||||
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
|
snapSourceIds: [
|
||||||
|
thisSnapPoint.snapSourceId,
|
||||||
|
otherSnapPoint.snapSourceId,
|
||||||
|
],
|
||||||
visualDistance: Math.abs(
|
visualDistance: Math.abs(
|
||||||
otherSnapPoint.point[0] - thisSnapPoint.point[0],
|
otherSnapPoint.point[0] - thisSnapPoint.point[0],
|
||||||
),
|
),
|
||||||
@@ -1049,130 +1104,183 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
type PointSnapLineMatch = {
|
// Point snaplines are collected from every winning point snap first. Multiple
|
||||||
sourceKey: string;
|
// snap source pairs can collapse to the same rendered line, so candidates keep
|
||||||
|
// source-pair metadata until redundant center and inner outer lines are removed.
|
||||||
|
type PointSnapLineSourcePair = {
|
||||||
|
snapSourcePairKey: string;
|
||||||
pointType: SnapPointType;
|
pointType: SnapPointType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PointSnapLineCandidate = PointSnapLine & {
|
type PointSnapLineCandidate = PointSnapLine & {
|
||||||
matches: PointSnapLineMatch[];
|
sourcePairs: PointSnapLineSourcePair[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PointSnapLineBucket = {
|
type PointSnapLineBucket = {
|
||||||
points: GlobalPoint[];
|
points: GlobalPoint[];
|
||||||
matches: PointSnapLineMatch[];
|
sourcePairs: PointSnapLineSourcePair[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OuterSnapLineCoordinateRange = {
|
||||||
|
count: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes which source pair produced a rendered point snapline, so later
|
* Only center-to-center snaps count as center snaplines for redundancy. A
|
||||||
|
* center-to-outer snap still explains an outer edge or midpoint alignment.
|
||||||
|
*/
|
||||||
|
const isCenterToCenterSnap = (snap: PointSnap) => {
|
||||||
|
return snap.pointTypes.every((pointType) => pointType === "center");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes which snap source pair produced a rendered point snapline, so later
|
||||||
* filtering can reason about redundancy without mixing unrelated references.
|
* filtering can reason about redundancy without mixing unrelated references.
|
||||||
*/
|
*/
|
||||||
const getPointSnapLineMatch = (snap: PointSnap): PointSnapLineMatch => {
|
const getPointSnapLineSourcePair = (
|
||||||
|
snap: PointSnap,
|
||||||
|
): PointSnapLineSourcePair => {
|
||||||
return {
|
return {
|
||||||
sourceKey: snap.sourceIds.join("->"),
|
snapSourcePairKey: snap.snapSourceIds.join("->"),
|
||||||
pointType: snap.pointTypes.every((pointType) => pointType === "center")
|
pointType: isCenterToCenterSnap(snap) ? "center" : "outer",
|
||||||
? "center"
|
|
||||||
: "outer",
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the outer snapline coordinates by source pair. Center snaplines use
|
* Builds each snap source pair's outer coordinate range. Center snaplines use
|
||||||
* this map to decide whether the surrounding outer lines already explain them.
|
* this range to decide whether the surrounding outer lines already explain
|
||||||
|
* them, and outer snaplines use it to keep only the two extremes.
|
||||||
*/
|
*/
|
||||||
const getOuterCoordinatesBySourceKey = (
|
const getOuterCoordinateRangesBySnapSourcePairKey = (
|
||||||
snapLines: PointSnapLineCandidate[],
|
snapLines: PointSnapLineCandidate[],
|
||||||
getCoordinate: (snapLine: PointSnapLine) => number,
|
getCoordinate: (snapLine: PointSnapLine) => number,
|
||||||
) => {
|
) => {
|
||||||
const outerCoordinates = new Map<string, number[]>();
|
const ranges = new Map<string, OuterSnapLineCoordinateRange>();
|
||||||
|
|
||||||
for (const snapLine of snapLines) {
|
for (const snapLine of snapLines) {
|
||||||
const coordinate = getCoordinate(snapLine);
|
const coordinate = getCoordinate(snapLine);
|
||||||
|
|
||||||
for (const match of snapLine.matches) {
|
for (const sourcePair of snapLine.sourcePairs) {
|
||||||
if (match.pointType === "center") {
|
if (sourcePair.pointType === "center") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceCoordinates = outerCoordinates.get(match.sourceKey) ?? [];
|
const range = ranges.get(sourcePair.snapSourcePairKey);
|
||||||
sourceCoordinates.push(coordinate);
|
|
||||||
outerCoordinates.set(match.sourceKey, sourceCoordinates);
|
ranges.set(sourcePair.snapSourcePairKey, {
|
||||||
|
count: (range?.count ?? 0) + 1,
|
||||||
|
min: range ? Math.min(range.min, coordinate) : coordinate,
|
||||||
|
max: range ? Math.max(range.max, coordinate) : coordinate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outerCoordinates;
|
return ranges;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A center snapline is redundant when outer snaplines from the same source pair
|
/**
|
||||||
// exist on both sides, because the outer lines already imply center alignment.
|
* A center snapline is redundant when outer snaplines from the same snap source
|
||||||
|
* pair exist on both sides, because the outer lines already imply center
|
||||||
|
* alignment.
|
||||||
|
*/
|
||||||
const isRedundantCenterSnapLine = (
|
const isRedundantCenterSnapLine = (
|
||||||
coordinate: number,
|
coordinate: number,
|
||||||
sourceOuterCoordinates: number[],
|
outerCoordinateRange?: OuterSnapLineCoordinateRange,
|
||||||
) => {
|
) => {
|
||||||
const hasOuterBefore = sourceOuterCoordinates.some(
|
if (!outerCoordinateRange) {
|
||||||
(outerCoordinate) => outerCoordinate < coordinate,
|
|
||||||
);
|
|
||||||
const hasOuterAfter = sourceOuterCoordinates.some(
|
|
||||||
(outerCoordinate) => outerCoordinate > coordinate,
|
|
||||||
);
|
|
||||||
|
|
||||||
return hasOuterBefore && hasOuterAfter;
|
|
||||||
};
|
|
||||||
|
|
||||||
// When more than two outer snaplines come from the same source pair, the inner
|
|
||||||
// ones are redundant; the two extremes communicate the same shape alignment.
|
|
||||||
const isRedundantOuterSnapLine = (
|
|
||||||
coordinate: number,
|
|
||||||
sourceOuterCoordinates: number[],
|
|
||||||
) => {
|
|
||||||
if (sourceOuterCoordinates.length <= 2) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minCoordinate = Math.min(...sourceOuterCoordinates);
|
return (
|
||||||
const maxCoordinate = Math.max(...sourceOuterCoordinates);
|
outerCoordinateRange.min < coordinate &&
|
||||||
|
outerCoordinateRange.max > coordinate
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return coordinate !== minCoordinate && coordinate !== maxCoordinate;
|
/**
|
||||||
|
* When more than two outer snaplines come from the same snap source pair, the
|
||||||
|
* inner ones are redundant; the two extremes communicate the same shape
|
||||||
|
* alignment.
|
||||||
|
*/
|
||||||
|
const isRedundantOuterSnapLine = (
|
||||||
|
coordinate: number,
|
||||||
|
outerCoordinateRange?: OuterSnapLineCoordinateRange,
|
||||||
|
) => {
|
||||||
|
if (!outerCoordinateRange || outerCoordinateRange.count <= 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
coordinate !== outerCoordinateRange.min &&
|
||||||
|
coordinate !== outerCoordinateRange.max
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a visual snapline if at least one snap source pair represented by the
|
||||||
|
* line still needs it. This matters when multiple snap source pairs collapse to
|
||||||
|
* the same rendered coordinate.
|
||||||
|
*/
|
||||||
|
const isPointSnapLineNeededBySourcePair = (
|
||||||
|
sourcePair: PointSnapLineSourcePair,
|
||||||
|
coordinate: number,
|
||||||
|
outerCoordinateRangesBySnapSourcePairKey: Map<
|
||||||
|
string,
|
||||||
|
OuterSnapLineCoordinateRange
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
const outerCoordinateRange = outerCoordinateRangesBySnapSourcePairKey.get(
|
||||||
|
sourcePair.snapSourcePairKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourcePair.pointType === "center") {
|
||||||
|
return !isRedundantCenterSnapLine(coordinate, outerCoordinateRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isRedundantOuterSnapLine(coordinate, outerCoordinateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes point snaplines that are visually redundant, while preserving any line
|
* Removes point snaplines that are visually redundant, while preserving any line
|
||||||
* that is still needed by at least one source pair represented on that line.
|
* that is still needed by at least one snap source pair represented on that
|
||||||
|
* line.
|
||||||
*/
|
*/
|
||||||
const filterRedundantPointSnapLines = (
|
const filterRedundantPointSnapLines = (
|
||||||
snapLines: PointSnapLineCandidate[],
|
snapLines: PointSnapLineCandidate[],
|
||||||
getCoordinate: (snapLine: PointSnapLine) => number,
|
getCoordinate: (snapLine: PointSnapLine) => number,
|
||||||
): PointSnapLine[] => {
|
): PointSnapLine[] => {
|
||||||
if (snapLines.length < 3) {
|
if (snapLines.length < 3) {
|
||||||
return snapLines.map(({ matches, ...snapLine }) => snapLine);
|
return snapLines.map(({ sourcePairs, ...snapLine }) => snapLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track outer-line coordinates per source pair. The same visual snapline can
|
// Track outer-line ranges per snap source pair. The same visual snapline can
|
||||||
// be produced by multiple source pairs, so a line is removed only when every
|
// be produced by multiple snap source pairs, so a line is removed only when
|
||||||
// pair represented by that line considers it redundant.
|
// every pair represented by that line considers it redundant.
|
||||||
const outerCoordinatesBySourceKey = getOuterCoordinatesBySourceKey(
|
const outerCoordinateRangesBySnapSourcePairKey =
|
||||||
snapLines,
|
getOuterCoordinateRangesBySnapSourcePairKey(snapLines, getCoordinate);
|
||||||
getCoordinate,
|
|
||||||
);
|
|
||||||
|
|
||||||
return snapLines
|
return snapLines
|
||||||
.filter((snapLine) => {
|
.filter((snapLine) => {
|
||||||
const coordinate = getCoordinate(snapLine);
|
const coordinate = getCoordinate(snapLine);
|
||||||
|
|
||||||
return snapLine.matches.some((match) => {
|
return snapLine.sourcePairs.some((sourcePair) =>
|
||||||
const sourceOuterCoordinates =
|
isPointSnapLineNeededBySourcePair(
|
||||||
outerCoordinatesBySourceKey.get(match.sourceKey) ?? [];
|
sourcePair,
|
||||||
|
coordinate,
|
||||||
if (match.pointType === "center") {
|
outerCoordinateRangesBySnapSourcePairKey,
|
||||||
return !isRedundantCenterSnapLine(coordinate, sourceOuterCoordinates);
|
),
|
||||||
}
|
);
|
||||||
|
|
||||||
return !isRedundantOuterSnapLine(coordinate, sourceOuterCoordinates);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.map(({ matches, ...snapLine }) => snapLine);
|
.map(({ sourcePairs, ...snapLine }) => snapLine);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges point snaps that render on the same x/y coordinate, attaches their
|
||||||
|
* snap source pair metadata, then removes redundant center and inner outer
|
||||||
|
* lines.
|
||||||
|
*/
|
||||||
const createPointSnapLines = (
|
const createPointSnapLines = (
|
||||||
nearestSnapsX: Snaps,
|
nearestSnapsX: Snaps,
|
||||||
nearestSnapsY: Snaps,
|
nearestSnapsY: Snaps,
|
||||||
@@ -1186,14 +1294,14 @@ const createPointSnapLines = (
|
|||||||
// key = thisPoint.x
|
// key = thisPoint.x
|
||||||
const key = round(snap.points[0][0]);
|
const key = round(snap.points[0][0]);
|
||||||
if (!snapsX[key]) {
|
if (!snapsX[key]) {
|
||||||
snapsX[key] = { points: [], matches: [] };
|
snapsX[key] = { points: [], sourcePairs: [] };
|
||||||
}
|
}
|
||||||
snapsX[key].points.push(
|
snapsX[key].points.push(
|
||||||
...snap.points.map((p) =>
|
...snap.points.map((p) =>
|
||||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
snapsX[key].matches.push(getPointSnapLineMatch(snap));
|
snapsX[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1204,14 +1312,14 @@ const createPointSnapLines = (
|
|||||||
// key = thisPoint.y
|
// key = thisPoint.y
|
||||||
const key = round(snap.points[0][1]);
|
const key = round(snap.points[0][1]);
|
||||||
if (!snapsY[key]) {
|
if (!snapsY[key]) {
|
||||||
snapsY[key] = { points: [], matches: [] };
|
snapsY[key] = { points: [], sourcePairs: [] };
|
||||||
}
|
}
|
||||||
snapsY[key].points.push(
|
snapsY[key].points.push(
|
||||||
...snap.points.map((p) =>
|
...snap.points.map((p) =>
|
||||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
snapsY[key].matches.push(getPointSnapLineMatch(snap));
|
snapsY[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1226,7 +1334,7 @@ const createPointSnapLines = (
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a[1] - b[1]),
|
.sort((a, b) => a[1] - b[1]),
|
||||||
),
|
),
|
||||||
matches: snap.matches,
|
sourcePairs: snap.sourcePairs,
|
||||||
} as PointSnapLineCandidate;
|
} as PointSnapLineCandidate;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1240,7 +1348,7 @@ const createPointSnapLines = (
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a[0] - b[0]),
|
.sort((a, b) => a[0] - b[0]),
|
||||||
),
|
),
|
||||||
matches: snap.matches,
|
sourcePairs: snap.sourcePairs,
|
||||||
} as PointSnapLineCandidate;
|
} as PointSnapLineCandidate;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1277,10 +1385,13 @@ const createGapSnapLines = (
|
|||||||
dragOffset: Vector2D,
|
dragOffset: Vector2D,
|
||||||
gapSnaps: GapSnap[],
|
gapSnaps: GapSnap[],
|
||||||
): GapSnapLine[] => {
|
): GapSnapLine[] => {
|
||||||
|
// Use the same rounded bounds as getGapSnaps(). Otherwise a gap snap can be
|
||||||
|
// accepted during snapping but skipped here because the raw bounds miss the
|
||||||
|
// reference gap overlap by a tiny floating-point delta.
|
||||||
const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
|
const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
dragOffset,
|
dragOffset,
|
||||||
);
|
).map((bound) => round(bound));
|
||||||
|
|
||||||
const gapSnapLines: GapSnapLine[] = [];
|
const gapSnapLines: GapSnapLine[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
import { pointFrom, type GlobalPoint, type Radians } from "@excalidraw/math";
|
import {
|
||||||
|
pointFrom,
|
||||||
|
rangeInclusive,
|
||||||
|
type GlobalPoint,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
getElementsCorners,
|
getElementsCorners,
|
||||||
|
getVisibleGaps,
|
||||||
getReferenceSnapPoints,
|
getReferenceSnapPoints,
|
||||||
SnapCache,
|
SnapCache,
|
||||||
snapDraggedElements,
|
snapDraggedElements,
|
||||||
@@ -56,6 +62,22 @@ const getHorizontalPointSnapLineCoordinates = (
|
|||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getVerticalPointSnapLineCoordinates = (
|
||||||
|
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
||||||
|
) => {
|
||||||
|
return snapLines
|
||||||
|
.filter((snapLine) => snapLine.type === "points")
|
||||||
|
.filter((snapLine) => {
|
||||||
|
const [firstPoint, lastPoint] = snapLine.points;
|
||||||
|
|
||||||
|
return firstPoint[0] === lastPoint[0];
|
||||||
|
})
|
||||||
|
.map((snapLine) => {
|
||||||
|
return snapLine.points[0][0];
|
||||||
|
})
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
const getHorizontalPointSnapLineMaxX = (
|
const getHorizontalPointSnapLineMaxX = (
|
||||||
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
||||||
) => {
|
) => {
|
||||||
@@ -74,6 +96,23 @@ const getHorizontalPointSnapLineMaxX = (
|
|||||||
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
|
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHorizontalGapSnapLines = (
|
||||||
|
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
||||||
|
) => {
|
||||||
|
return snapLines.filter(
|
||||||
|
(snapLine) =>
|
||||||
|
snapLine.type === "gap" && snapLine.direction === "horizontal",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVerticalGapSnapLines = (
|
||||||
|
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
||||||
|
) => {
|
||||||
|
return snapLines.filter(
|
||||||
|
(snapLine) => snapLine.type === "gap" && snapLine.direction === "vertical",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => {
|
const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => {
|
||||||
return points.map((point) => point.join(","));
|
return points.map((point) => point.join(","));
|
||||||
};
|
};
|
||||||
@@ -111,7 +150,7 @@ const primeReferenceSnapPoints = (
|
|||||||
return corners.map((point, index) => ({
|
return corners.map((point, index) => ({
|
||||||
point,
|
point,
|
||||||
type: index === corners.length - 1 ? "center" : "outer",
|
type: index === corners.length - 1 ? "center" : "outer",
|
||||||
sourceId: element.id,
|
snapSourceId: element.id,
|
||||||
}));
|
}));
|
||||||
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
|
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
|
||||||
);
|
);
|
||||||
@@ -254,6 +293,57 @@ describe("snapping", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not use frame children as visible gap references when snapping outside elements", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
id: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 500,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
const frameChildA = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "frameChildA",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const frameChildB = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "frameChildB",
|
||||||
|
x: 250,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const selected = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "selected",
|
||||||
|
x: 700,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
const elements = [frame, frameChildA, frameChildB, selected];
|
||||||
|
const app = createSnappingApp({
|
||||||
|
selectedElementIds: { [selected.id]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleGaps = getVisibleGaps(
|
||||||
|
elements,
|
||||||
|
[selected],
|
||||||
|
app.state,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(visibleGaps.horizontalGaps).toHaveLength(0);
|
||||||
|
expect(visibleGaps.verticalGaps).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters center and inner outer point snaplines for the same reference", () => {
|
it("filters center and inner outer point snaplines for the same reference", () => {
|
||||||
const angle = 0.68 as Radians;
|
const angle = 0.68 as Radians;
|
||||||
const reference = API.createElement({
|
const reference = API.createElement({
|
||||||
@@ -319,7 +409,7 @@ describe("snapping", () => {
|
|||||||
...outerSnapPoints.map((point) => ({
|
...outerSnapPoints.map((point) => ({
|
||||||
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
|
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
|
||||||
type: "outer" as const,
|
type: "outer" as const,
|
||||||
sourceId: "referenceA",
|
snapSourceId: "referenceA",
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(
|
point: pointFrom<GlobalPoint>(
|
||||||
@@ -327,7 +417,7 @@ describe("snapping", () => {
|
|||||||
centerSnapPoint[1],
|
centerSnapPoint[1],
|
||||||
),
|
),
|
||||||
type: "center" as const,
|
type: "center" as const,
|
||||||
sourceId: "referenceA",
|
snapSourceId: "referenceA",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(
|
point: pointFrom<GlobalPoint>(
|
||||||
@@ -335,7 +425,7 @@ describe("snapping", () => {
|
|||||||
innerOuterSnapPoint[1],
|
innerOuterSnapPoint[1],
|
||||||
),
|
),
|
||||||
type: "outer" as const,
|
type: "outer" as const,
|
||||||
sourceId: "referenceB",
|
snapSourceId: "referenceB",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -387,6 +477,90 @@ describe("snapping", () => {
|
|||||||
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]);
|
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters center snaplines when matching outer offsets differ by rounding precision", () => {
|
||||||
|
const selected = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "selected",
|
||||||
|
x: 2532.227563984471,
|
||||||
|
y: -1553.9657067952232,
|
||||||
|
width: 140.1015625,
|
||||||
|
height: 140.1015625,
|
||||||
|
});
|
||||||
|
const reference = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "reference",
|
||||||
|
x: 2532.2275640966914,
|
||||||
|
y: -1299.4323092037737,
|
||||||
|
width: 140.1015625,
|
||||||
|
height: 140.1015625,
|
||||||
|
});
|
||||||
|
const elements = [reference, selected];
|
||||||
|
const app = createSnappingApp({
|
||||||
|
selectedElementIds: { [selected.id]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
primeReferenceSnapPoints(elements, [selected]);
|
||||||
|
|
||||||
|
const { snapLines } = snapDraggedElements(
|
||||||
|
elements,
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
app,
|
||||||
|
NO_MODIFIER_KEYS,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getVerticalPointSnapLineCoordinates(snapLines)).toEqual([
|
||||||
|
2532.227564, 2672.329126,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps outer snaplines stable while dragging a snapped element through rounding-equivalent offsets", () => {
|
||||||
|
const referenceMiddle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "referenceMiddle",
|
||||||
|
x: 2532.22756398447,
|
||||||
|
y: -1553.9657067952237,
|
||||||
|
width: 140.1015625,
|
||||||
|
height: 140.1015625,
|
||||||
|
});
|
||||||
|
const referenceAbove = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "referenceAbove",
|
||||||
|
x: 2532.2275637826165,
|
||||||
|
y: -1779.7363232531268,
|
||||||
|
width: 140.1015625,
|
||||||
|
height: 140.1015625,
|
||||||
|
});
|
||||||
|
const selected = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "selected",
|
||||||
|
x: 2532.227563096691,
|
||||||
|
y: -1328.1950902037736,
|
||||||
|
width: 140.1015625,
|
||||||
|
height: 140.1015625,
|
||||||
|
});
|
||||||
|
const elements = [referenceAbove, referenceMiddle, selected];
|
||||||
|
const app = createSnappingApp({
|
||||||
|
selectedElementIds: { [selected.id]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
primeReferenceSnapPoints(elements, [selected]);
|
||||||
|
|
||||||
|
for (const dragOffsetX of [-4, -1, -0.1, 0, 0.1, 1, 4]) {
|
||||||
|
const { snapLines } = snapDraggedElements(
|
||||||
|
elements,
|
||||||
|
{ x: dragOffsetX, y: 0 },
|
||||||
|
app,
|
||||||
|
NO_MODIFIER_KEYS,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
const coordinates = getVerticalPointSnapLineCoordinates(snapLines);
|
||||||
|
|
||||||
|
expect(coordinates).toHaveLength(2);
|
||||||
|
expect(coordinates[1] - coordinates[0]).toBeCloseTo(selected.width, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the nearest visual cluster for same-offset point snaps", () => {
|
it("prefers the nearest visual cluster for same-offset point snaps", () => {
|
||||||
const selected = API.createElement({
|
const selected = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
@@ -405,12 +579,12 @@ describe("snapping", () => {
|
|||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(220, 50),
|
point: pointFrom<GlobalPoint>(220, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "near",
|
snapSourceId: "near",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(900, 50),
|
point: pointFrom<GlobalPoint>(900, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "far",
|
snapSourceId: "far",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -443,12 +617,12 @@ describe("snapping", () => {
|
|||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(220, 54),
|
point: pointFrom<GlobalPoint>(220, 54),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "near",
|
snapSourceId: "near",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(900, 50),
|
point: pointFrom<GlobalPoint>(900, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "far",
|
snapSourceId: "far",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -482,17 +656,17 @@ describe("snapping", () => {
|
|||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(200, 50),
|
point: pointFrom<GlobalPoint>(200, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "referenceA",
|
snapSourceId: "referenceA",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(350, 50),
|
point: pointFrom<GlobalPoint>(350, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "referenceB",
|
snapSourceId: "referenceB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
point: pointFrom<GlobalPoint>(500, 50),
|
point: pointFrom<GlobalPoint>(500, 50),
|
||||||
type: "center",
|
type: "center",
|
||||||
sourceId: "referenceC",
|
snapSourceId: "referenceC",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -506,4 +680,89 @@ describe("snapping", () => {
|
|||||||
|
|
||||||
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
|
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
|
||||||
|
const selected = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "selected",
|
||||||
|
x: 0,
|
||||||
|
y: 0.0000004,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
const elements = [selected];
|
||||||
|
const app = createSnappingApp({
|
||||||
|
selectedElementIds: { [selected.id]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
SnapCache.setVisibleGaps({
|
||||||
|
horizontalGaps: [
|
||||||
|
{
|
||||||
|
startBounds: [200, -100, 300, 0],
|
||||||
|
endBounds: [400, -100, 500, 0],
|
||||||
|
startSide: [pointFrom(300, -100), pointFrom(300, 0)],
|
||||||
|
endSide: [pointFrom(400, -100), pointFrom(400, 0)],
|
||||||
|
overlap: rangeInclusive(-100, 0),
|
||||||
|
length: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
verticalGaps: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { snapLines } = snapDraggedElements(
|
||||||
|
elements,
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
app,
|
||||||
|
NO_MODIFIER_KEYS,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getHorizontalGapSnapLines(snapLines)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders gap snaplines when the winning gap offset only differs by rounding precision", () => {
|
||||||
|
const selected = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "selected",
|
||||||
|
x: 0,
|
||||||
|
y: 399.999999,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
const elements = [selected];
|
||||||
|
const app = createSnappingApp({
|
||||||
|
selectedElementIds: { [selected.id]: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
SnapCache.setReferenceSnapPoints([
|
||||||
|
{
|
||||||
|
point: pointFrom<GlobalPoint>(0, 399.999999),
|
||||||
|
type: "outer",
|
||||||
|
snapSourceId: "reference",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
SnapCache.setVisibleGaps({
|
||||||
|
horizontalGaps: [],
|
||||||
|
verticalGaps: [
|
||||||
|
{
|
||||||
|
startBounds: [0, 100, 100, 200],
|
||||||
|
endBounds: [0, 250, 100, 350],
|
||||||
|
startSide: [pointFrom(0, 200), pointFrom(100, 200)],
|
||||||
|
endSide: [pointFrom(0, 250), pointFrom(100, 250)],
|
||||||
|
overlap: rangeInclusive(0, 100),
|
||||||
|
length: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { snapLines } = snapDraggedElements(
|
||||||
|
elements,
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
app,
|
||||||
|
NO_MODIFIER_KEYS,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getVerticalGapSnapLines(snapLines)).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user