fix(snaps): stabilize gap snapline rendering
This commit is contained in:
+209
-98
@@ -39,6 +39,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
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
|
||||
// distance. If their visual distances have a large break, prefer the nearest
|
||||
// cluster before comparing offsets.
|
||||
@@ -67,34 +71,34 @@ type Vector2D = {
|
||||
|
||||
type PointPair = [GlobalPoint, GlobalPoint];
|
||||
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.
|
||||
// For example, "selection->rectA" may collapse to its outermost snaplines,
|
||||
// while "selection->rectB" still keeps its own independent snaplines.
|
||||
type SnapPoint = {
|
||||
point: GlobalPoint;
|
||||
type: SnapPointType;
|
||||
sourceId: SnapPointSourceId;
|
||||
snapSourceId: SnapSourceId;
|
||||
};
|
||||
|
||||
const outerSnapPoint = (
|
||||
point: GlobalPoint,
|
||||
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
|
||||
snapSourceId = SELECTION_SNAP_SOURCE_ID,
|
||||
): SnapPoint => ({
|
||||
point,
|
||||
type: "outer",
|
||||
sourceId,
|
||||
snapSourceId,
|
||||
});
|
||||
|
||||
export type PointSnap = {
|
||||
type: "point";
|
||||
points: PointPair;
|
||||
pointTypes: [SnapPointType, SnapPointType];
|
||||
sourceIds: [SnapPointSourceId, SnapPointSourceId];
|
||||
snapSourceIds: [SnapSourceId, SnapSourceId];
|
||||
// Distance along the rendered snapline, used to group visually nearby
|
||||
// references before choosing which point snaps should win.
|
||||
visualDistance: number;
|
||||
@@ -234,6 +238,17 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
|
||||
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 = (
|
||||
elements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
@@ -355,7 +370,7 @@ const getElementsSnapPoints = (
|
||||
elements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
options: Parameters<typeof getElementsCorners>[2] = {},
|
||||
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
|
||||
snapSourceId = SELECTION_SNAP_SOURCE_ID,
|
||||
): SnapPoint[] => {
|
||||
const points = getElementsCorners(elements, elementsMap, options);
|
||||
// getElementsCorners() appends the center point last unless omitCenter is set.
|
||||
@@ -364,17 +379,25 @@ const getElementsSnapPoints = (
|
||||
return points.map((point, index) => ({
|
||||
point,
|
||||
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
|
||||
.map((element) => element.id)
|
||||
.sort()
|
||||
.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 = (
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
@@ -389,6 +412,18 @@ const getSelectedFrameIdsForSnapping = (
|
||||
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 = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
@@ -403,7 +438,7 @@ const getReferenceElements = (
|
||||
appState,
|
||||
elementsMap,
|
||||
).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 gapIsLargerThanSelection = gap.length > maxX - minX;
|
||||
|
||||
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
|
||||
if (
|
||||
gapIsLargerThanSelection &&
|
||||
isWithinSnapOffset(centerOffset, minOffset.x)
|
||||
) {
|
||||
if (Math.abs(centerOffset) < minOffset.x) {
|
||||
nearestSnapsX.length = 0;
|
||||
}
|
||||
@@ -586,7 +624,7 @@ const getGapSnaps = (
|
||||
const distanceToEndElementX = minX - endMaxX;
|
||||
const sideOffsetRight = round(gap.length - distanceToEndElementX);
|
||||
|
||||
if (Math.abs(sideOffsetRight) <= minOffset.x) {
|
||||
if (isWithinSnapOffset(sideOffsetRight, minOffset.x)) {
|
||||
if (Math.abs(sideOffsetRight) < minOffset.x) {
|
||||
nearestSnapsX.length = 0;
|
||||
}
|
||||
@@ -607,7 +645,7 @@ const getGapSnaps = (
|
||||
const distanceToStartElementX = startMinX - maxX;
|
||||
const sideOffsetLeft = round(distanceToStartElementX - gap.length);
|
||||
|
||||
if (Math.abs(sideOffsetLeft) <= minOffset.x) {
|
||||
if (isWithinSnapOffset(sideOffsetLeft, minOffset.x)) {
|
||||
if (Math.abs(sideOffsetLeft) < minOffset.x) {
|
||||
nearestSnapsX.length = 0;
|
||||
}
|
||||
@@ -633,7 +671,10 @@ const getGapSnaps = (
|
||||
const centerOffset = round(gapMidY - centerY);
|
||||
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) {
|
||||
nearestSnapsY.length = 0;
|
||||
}
|
||||
@@ -655,7 +696,7 @@ const getGapSnaps = (
|
||||
const distanceToStartElementY = startMinY - maxY;
|
||||
const sideOffsetTop = round(distanceToStartElementY - gap.length);
|
||||
|
||||
if (Math.abs(sideOffsetTop) <= minOffset.y) {
|
||||
if (isWithinSnapOffset(sideOffsetTop, minOffset.y)) {
|
||||
if (Math.abs(sideOffsetTop) < minOffset.y) {
|
||||
nearestSnapsY.length = 0;
|
||||
}
|
||||
@@ -676,7 +717,7 @@ const getGapSnaps = (
|
||||
const distanceToEndElementY = round(minY - endMaxY);
|
||||
const sideOffsetBottom = gap.length - distanceToEndElementY;
|
||||
|
||||
if (Math.abs(sideOffsetBottom) <= minOffset.y) {
|
||||
if (isWithinSnapOffset(sideOffsetBottom, minOffset.y)) {
|
||||
if (Math.abs(sideOffsetBottom) < minOffset.y) {
|
||||
nearestSnapsY.length = 0;
|
||||
}
|
||||
@@ -757,14 +798,22 @@ const filterPointSnapsToNearestCluster = (
|
||||
const minOffset = Math.min(
|
||||
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
|
||||
);
|
||||
const selectedOffsets = new Set(
|
||||
nearestOffsetGroups
|
||||
.filter(({ offset }) => Math.abs(offset) === minOffset)
|
||||
.map(({ offset }) => offset),
|
||||
);
|
||||
const selectedOffsets = nearestOffsetGroups
|
||||
.filter(({ offset }) =>
|
||||
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE),
|
||||
)
|
||||
.map(({ offset }) => offset);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -830,7 +879,7 @@ export const getReferenceSnapPoints = (
|
||||
elementGroup,
|
||||
elementsMap,
|
||||
{},
|
||||
getSnapPointSourceId(elementGroup),
|
||||
getSnapSourceId(elementGroup),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -864,12 +913,15 @@ const getPointSnaps = (
|
||||
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
|
||||
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
|
||||
|
||||
if (Math.abs(offsetX) <= minOffset.x) {
|
||||
if (isWithinSnapOffset(offsetX, minOffset.x)) {
|
||||
nearestSnapsX.push({
|
||||
type: "point",
|
||||
points: [thisSnapPoint.point, otherSnapPoint.point],
|
||||
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
||||
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
|
||||
snapSourceIds: [
|
||||
thisSnapPoint.snapSourceId,
|
||||
otherSnapPoint.snapSourceId,
|
||||
],
|
||||
visualDistance: Math.abs(
|
||||
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({
|
||||
type: "point",
|
||||
points: [thisSnapPoint.point, otherSnapPoint.point],
|
||||
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
|
||||
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
|
||||
snapSourceIds: [
|
||||
thisSnapPoint.snapSourceId,
|
||||
otherSnapPoint.snapSourceId,
|
||||
],
|
||||
visualDistance: Math.abs(
|
||||
otherSnapPoint.point[0] - thisSnapPoint.point[0],
|
||||
),
|
||||
@@ -1049,130 +1104,183 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
type PointSnapLineMatch = {
|
||||
sourceKey: string;
|
||||
// Point snaplines are collected from every winning point snap first. Multiple
|
||||
// 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;
|
||||
};
|
||||
|
||||
type PointSnapLineCandidate = PointSnapLine & {
|
||||
matches: PointSnapLineMatch[];
|
||||
sourcePairs: PointSnapLineSourcePair[];
|
||||
};
|
||||
|
||||
type PointSnapLineBucket = {
|
||||
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.
|
||||
*/
|
||||
const getPointSnapLineMatch = (snap: PointSnap): PointSnapLineMatch => {
|
||||
const getPointSnapLineSourcePair = (
|
||||
snap: PointSnap,
|
||||
): PointSnapLineSourcePair => {
|
||||
return {
|
||||
sourceKey: snap.sourceIds.join("->"),
|
||||
pointType: snap.pointTypes.every((pointType) => pointType === "center")
|
||||
? "center"
|
||||
: "outer",
|
||||
snapSourcePairKey: snap.snapSourceIds.join("->"),
|
||||
pointType: isCenterToCenterSnap(snap) ? "center" : "outer",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the outer snapline coordinates by source pair. Center snaplines use
|
||||
* this map to decide whether the surrounding outer lines already explain them.
|
||||
* Builds each snap source pair's outer coordinate range. Center snaplines use
|
||||
* 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[],
|
||||
getCoordinate: (snapLine: PointSnapLine) => number,
|
||||
) => {
|
||||
const outerCoordinates = new Map<string, number[]>();
|
||||
const ranges = new Map<string, OuterSnapLineCoordinateRange>();
|
||||
|
||||
for (const snapLine of snapLines) {
|
||||
const coordinate = getCoordinate(snapLine);
|
||||
|
||||
for (const match of snapLine.matches) {
|
||||
if (match.pointType === "center") {
|
||||
for (const sourcePair of snapLine.sourcePairs) {
|
||||
if (sourcePair.pointType === "center") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceCoordinates = outerCoordinates.get(match.sourceKey) ?? [];
|
||||
sourceCoordinates.push(coordinate);
|
||||
outerCoordinates.set(match.sourceKey, sourceCoordinates);
|
||||
const range = ranges.get(sourcePair.snapSourcePairKey);
|
||||
|
||||
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 = (
|
||||
coordinate: number,
|
||||
sourceOuterCoordinates: number[],
|
||||
outerCoordinateRange?: OuterSnapLineCoordinateRange,
|
||||
) => {
|
||||
const hasOuterBefore = sourceOuterCoordinates.some(
|
||||
(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) {
|
||||
if (!outerCoordinateRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const minCoordinate = Math.min(...sourceOuterCoordinates);
|
||||
const maxCoordinate = Math.max(...sourceOuterCoordinates);
|
||||
return (
|
||||
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
|
||||
* 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 = (
|
||||
snapLines: PointSnapLineCandidate[],
|
||||
getCoordinate: (snapLine: PointSnapLine) => number,
|
||||
): PointSnapLine[] => {
|
||||
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
|
||||
// be produced by multiple source pairs, so a line is removed only when every
|
||||
// pair represented by that line considers it redundant.
|
||||
const outerCoordinatesBySourceKey = getOuterCoordinatesBySourceKey(
|
||||
snapLines,
|
||||
getCoordinate,
|
||||
);
|
||||
// Track outer-line ranges per snap source pair. The same visual snapline can
|
||||
// be produced by multiple snap source pairs, so a line is removed only when
|
||||
// every pair represented by that line considers it redundant.
|
||||
const outerCoordinateRangesBySnapSourcePairKey =
|
||||
getOuterCoordinateRangesBySnapSourcePairKey(snapLines, getCoordinate);
|
||||
|
||||
return snapLines
|
||||
.filter((snapLine) => {
|
||||
const coordinate = getCoordinate(snapLine);
|
||||
|
||||
return snapLine.matches.some((match) => {
|
||||
const sourceOuterCoordinates =
|
||||
outerCoordinatesBySourceKey.get(match.sourceKey) ?? [];
|
||||
|
||||
if (match.pointType === "center") {
|
||||
return !isRedundantCenterSnapLine(coordinate, sourceOuterCoordinates);
|
||||
}
|
||||
|
||||
return !isRedundantOuterSnapLine(coordinate, sourceOuterCoordinates);
|
||||
});
|
||||
return snapLine.sourcePairs.some((sourcePair) =>
|
||||
isPointSnapLineNeededBySourcePair(
|
||||
sourcePair,
|
||||
coordinate,
|
||||
outerCoordinateRangesBySnapSourcePairKey,
|
||||
),
|
||||
);
|
||||
})
|
||||
.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 = (
|
||||
nearestSnapsX: Snaps,
|
||||
nearestSnapsY: Snaps,
|
||||
@@ -1186,14 +1294,14 @@ const createPointSnapLines = (
|
||||
// key = thisPoint.x
|
||||
const key = round(snap.points[0][0]);
|
||||
if (!snapsX[key]) {
|
||||
snapsX[key] = { points: [], matches: [] };
|
||||
snapsX[key] = { points: [], sourcePairs: [] };
|
||||
}
|
||||
snapsX[key].points.push(
|
||||
...snap.points.map((p) =>
|
||||
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
|
||||
const key = round(snap.points[0][1]);
|
||||
if (!snapsY[key]) {
|
||||
snapsY[key] = { points: [], matches: [] };
|
||||
snapsY[key] = { points: [], sourcePairs: [] };
|
||||
}
|
||||
snapsY[key].points.push(
|
||||
...snap.points.map((p) =>
|
||||
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]),
|
||||
),
|
||||
matches: snap.matches,
|
||||
sourcePairs: snap.sourcePairs,
|
||||
} as PointSnapLineCandidate;
|
||||
});
|
||||
|
||||
@@ -1240,7 +1348,7 @@ const createPointSnapLines = (
|
||||
})
|
||||
.sort((a, b) => a[0] - b[0]),
|
||||
),
|
||||
matches: snap.matches,
|
||||
sourcePairs: snap.sourcePairs,
|
||||
} as PointSnapLineCandidate;
|
||||
});
|
||||
|
||||
@@ -1277,10 +1385,13 @@ const createGapSnapLines = (
|
||||
dragOffset: Vector2D,
|
||||
gapSnaps: GapSnap[],
|
||||
): 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(
|
||||
selectedElements,
|
||||
dragOffset,
|
||||
);
|
||||
).map((bound) => round(bound));
|
||||
|
||||
const gapSnapLines: GapSnapLine[] = [];
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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 { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
getElementsCorners,
|
||||
getVisibleGaps,
|
||||
getReferenceSnapPoints,
|
||||
SnapCache,
|
||||
snapDraggedElements,
|
||||
@@ -56,6 +62,22 @@ const getHorizontalPointSnapLineCoordinates = (
|
||||
.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 = (
|
||||
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
|
||||
) => {
|
||||
@@ -74,6 +96,23 @@ const getHorizontalPointSnapLineMaxX = (
|
||||
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>) => {
|
||||
return points.map((point) => point.join(","));
|
||||
};
|
||||
@@ -111,7 +150,7 @@ const primeReferenceSnapPoints = (
|
||||
return corners.map((point, index) => ({
|
||||
point,
|
||||
type: index === corners.length - 1 ? "center" : "outer",
|
||||
sourceId: element.id,
|
||||
snapSourceId: element.id,
|
||||
}));
|
||||
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
|
||||
);
|
||||
@@ -254,6 +293,57 @@ describe("snapping", () => {
|
||||
).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", () => {
|
||||
const angle = 0.68 as Radians;
|
||||
const reference = API.createElement({
|
||||
@@ -319,7 +409,7 @@ describe("snapping", () => {
|
||||
...outerSnapPoints.map((point) => ({
|
||||
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
|
||||
type: "outer" as const,
|
||||
sourceId: "referenceA",
|
||||
snapSourceId: "referenceA",
|
||||
})),
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(
|
||||
@@ -327,7 +417,7 @@ describe("snapping", () => {
|
||||
centerSnapPoint[1],
|
||||
),
|
||||
type: "center" as const,
|
||||
sourceId: "referenceA",
|
||||
snapSourceId: "referenceA",
|
||||
},
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(
|
||||
@@ -335,7 +425,7 @@ describe("snapping", () => {
|
||||
innerOuterSnapPoint[1],
|
||||
),
|
||||
type: "outer" as const,
|
||||
sourceId: "referenceB",
|
||||
snapSourceId: "referenceB",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -387,6 +477,90 @@ describe("snapping", () => {
|
||||
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", () => {
|
||||
const selected = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -405,12 +579,12 @@ describe("snapping", () => {
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(220, 50),
|
||||
type: "center",
|
||||
sourceId: "near",
|
||||
snapSourceId: "near",
|
||||
},
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(900, 50),
|
||||
type: "center",
|
||||
sourceId: "far",
|
||||
snapSourceId: "far",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -443,12 +617,12 @@ describe("snapping", () => {
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(220, 54),
|
||||
type: "center",
|
||||
sourceId: "near",
|
||||
snapSourceId: "near",
|
||||
},
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(900, 50),
|
||||
type: "center",
|
||||
sourceId: "far",
|
||||
snapSourceId: "far",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -482,17 +656,17 @@ describe("snapping", () => {
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(200, 50),
|
||||
type: "center",
|
||||
sourceId: "referenceA",
|
||||
snapSourceId: "referenceA",
|
||||
},
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(350, 50),
|
||||
type: "center",
|
||||
sourceId: "referenceB",
|
||||
snapSourceId: "referenceB",
|
||||
},
|
||||
{
|
||||
point: pointFrom<GlobalPoint>(500, 50),
|
||||
type: "center",
|
||||
sourceId: "referenceC",
|
||||
snapSourceId: "referenceC",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -506,4 +680,89 @@ describe("snapping", () => {
|
||||
|
||||
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