refactor(snaps): filter redundant point snaplines

This commit is contained in:
Ryan Di
2026-05-11 17:13:11 +10:00
parent 708048ec61
commit 0dab7c3a68
+256 -60
View File
@@ -55,10 +55,35 @@ type Vector2D = {
}; };
type PointPair = [GlobalPoint, GlobalPoint]; type PointPair = [GlobalPoint, GlobalPoint];
type SnapPointType = "outer" | "center";
type SnapPointSourceId = string;
const SELECTION_SNAP_POINT_SOURCE_ID = "selection";
// 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;
};
const outerSnapPoint = (
point: GlobalPoint,
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
): SnapPoint => ({
point,
type: "outer",
sourceId,
});
export type PointSnap = { export type PointSnap = {
type: "point"; type: "point";
points: PointPair; points: PointPair;
pointTypes: [SnapPointType, SnapPointType];
sourceIds: [SnapPointSourceId, SnapPointSourceId];
offset: number; offset: number;
}; };
@@ -120,14 +145,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export class SnapCache { export class SnapCache {
private static referenceSnapPoints: GlobalPoint[] | null = null; private static referenceSnapPoints: SnapPoint[] | null = null;
private static visibleGaps: { private static visibleGaps: {
verticalGaps: Gap[]; verticalGaps: Gap[];
horizontalGaps: Gap[]; horizontalGaps: Gap[];
} | null = null; } | null = null;
public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => { public static setReferenceSnapPoints = (snapPoints: SnapPoint[] | null) => {
SnapCache.referenceSnapPoints = snapPoints; SnapCache.referenceSnapPoints = snapPoints;
}; };
@@ -312,6 +337,30 @@ export const getElementsCorners = (
return result.map((p) => pointFrom(round(p[0]), round(p[1]))); return result.map((p) => pointFrom(round(p[0]), round(p[1])));
}; };
const getElementsSnapPoints = (
elements: ExcalidrawElement[],
elementsMap: ElementsMap,
options: Parameters<typeof getElementsCorners>[2] = {},
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
): SnapPoint[] => {
const points = getElementsCorners(elements, elementsMap, options);
// getElementsCorners() appends the center point last unless omitCenter is set.
const hasCenterPoint = !options.omitCenter && points.length > 0;
return points.map((point, index) => ({
point,
type: hasCenterPoint && index === points.length - 1 ? "center" : "outer",
sourceId,
}));
};
const getSnapPointSourceId = (elements: ExcalidrawElement[]) => {
return elements
.map((element) => element.id)
.sort()
.join(",");
};
const getReferenceElements = ( const getReferenceElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
@@ -630,12 +679,19 @@ export const getReferenceSnapPoints = (
(elementsGroup) => (elementsGroup) =>
!(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
) )
.flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); .flatMap((elementGroup) =>
getElementsSnapPoints(
elementGroup,
elementsMap,
{},
getSnapPointSourceId(elementGroup),
),
);
}; };
const getPointSnaps = ( const getPointSnaps = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
selectionSnapPoints: GlobalPoint[], selectionSnapPoints: SnapPoint[],
app: AppClassProperties, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
nearestSnapsX: Snaps, nearestSnapsX: Snaps,
@@ -654,8 +710,8 @@ const getPointSnaps = (
if (referenceSnapPoints) { if (referenceSnapPoints) {
for (const thisSnapPoint of selectionSnapPoints) { for (const thisSnapPoint of selectionSnapPoints) {
for (const otherSnapPoint of referenceSnapPoints) { for (const otherSnapPoint of referenceSnapPoints) {
const offsetX = otherSnapPoint[0] - thisSnapPoint[0]; const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
const offsetY = otherSnapPoint[1] - thisSnapPoint[1]; const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
if (Math.abs(offsetX) <= minOffset.x) { if (Math.abs(offsetX) <= minOffset.x) {
if (Math.abs(offsetX) < minOffset.x) { if (Math.abs(offsetX) < minOffset.x) {
@@ -664,7 +720,9 @@ const getPointSnaps = (
nearestSnapsX.push({ nearestSnapsX.push({
type: "point", type: "point",
points: [thisSnapPoint, otherSnapPoint], points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
offset: offsetX, offset: offsetX,
}); });
@@ -678,7 +736,9 @@ const getPointSnaps = (
nearestSnapsY.push({ nearestSnapsY.push({
type: "point", type: "point",
points: [thisSnapPoint, otherSnapPoint], points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
offset: offsetY, offset: offsetY,
}); });
@@ -720,7 +780,7 @@ export const snapDraggedElements = (
y: snapDistance, y: snapDistance,
}; };
const selectionPoints = getElementsCorners(selectedElements, elementsMap, { const selectionPoints = getElementsSnapPoints(selectedElements, elementsMap, {
dragOffset, dragOffset,
}); });
@@ -770,7 +830,7 @@ export const snapDraggedElements = (
getPointSnaps( getPointSnaps(
selectedElements, selectedElements,
getElementsCorners(selectedElements, elementsMap, { getElementsSnapPoints(selectedElements, elementsMap, {
dragOffset: newDragOffset, dragOffset: newDragOffset,
}), }),
app, app,
@@ -825,12 +885,124 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
return Array.from(map.values()); return Array.from(map.values());
}; };
type PointSnapLineMatch = {
sourceKey: string;
pointType: SnapPointType;
};
type PointSnapLineCandidate = PointSnapLine & {
matches: PointSnapLineMatch[];
};
type PointSnapLineBucket = {
points: GlobalPoint[];
matches: PointSnapLineMatch[];
};
const getPointSnapLineMatch = (snap: PointSnap): PointSnapLineMatch => {
return {
sourceKey: snap.sourceIds.join("->"),
pointType: snap.pointTypes.every((pointType) => pointType === "center")
? "center"
: "outer",
};
};
const getOuterCoordinatesBySourceKey = (
snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number,
) => {
const outerCoordinates = new Map<string, number[]>();
for (const snapLine of snapLines) {
const coordinate = getCoordinate(snapLine);
for (const match of snapLine.matches) {
if (match.pointType === "center") {
continue;
}
const sourceCoordinates = outerCoordinates.get(match.sourceKey) ?? [];
sourceCoordinates.push(coordinate);
outerCoordinates.set(match.sourceKey, sourceCoordinates);
}
}
return outerCoordinates;
};
// 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.
const isRedundantCenterSnapLine = (
coordinate: number,
sourceOuterCoordinates: number[],
) => {
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) {
return false;
}
const minCoordinate = Math.min(...sourceOuterCoordinates);
const maxCoordinate = Math.max(...sourceOuterCoordinates);
return coordinate !== minCoordinate && coordinate !== maxCoordinate;
};
const filterRedundantPointSnapLines = (
snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number,
): PointSnapLine[] => {
if (snapLines.length < 3) {
return snapLines.map(({ matches, ...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,
);
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);
});
})
.map(({ matches, ...snapLine }) => snapLine);
};
const createPointSnapLines = ( const createPointSnapLines = (
nearestSnapsX: Snaps, nearestSnapsX: Snaps,
nearestSnapsY: Snaps, nearestSnapsY: Snaps,
): PointSnapLine[] => { ): PointSnapLine[] => {
const snapsX = {} as { [key: string]: GlobalPoint[] }; const snapsX = {} as { [key: string]: PointSnapLineBucket };
const snapsY = {} as { [key: string]: GlobalPoint[] }; const snapsY = {} as { [key: string]: PointSnapLineBucket };
if (nearestSnapsX.length > 0) { if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) { for (const snap of nearestSnapsX) {
@@ -838,13 +1010,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] = []; snapsX[key] = { points: [], matches: [] };
} }
snapsX[key].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));
} }
} }
} }
@@ -855,44 +1028,55 @@ 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] = []; snapsY[key] = { points: [], matches: [] };
} }
snapsY[key].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));
} }
} }
} }
return Object.entries(snapsX) const snapLinesX = Object.entries(snapsX).map(([key, snap]) => {
.map(([key, points]) => { return {
return { type: "points",
type: "points", points: dedupePoints(
points: dedupePoints( snap.points
points .map((p) => {
.map((p) => { return pointFrom<GlobalPoint>(Number(key), p[1]);
return pointFrom<GlobalPoint>(Number(key), p[1]); })
}) .sort((a, b) => a[1] - b[1]),
.sort((a, b) => a[1] - b[1]), ),
), matches: snap.matches,
} as PointSnapLine; } as PointSnapLineCandidate;
}) });
.concat(
Object.entries(snapsY).map(([key, points]) => { const snapLinesY = Object.entries(snapsY).map(([key, snap]) => {
return { return {
type: "points", type: "points",
points: dedupePoints( points: dedupePoints(
points snap.points
.map((p) => { .map((p) => {
return pointFrom<GlobalPoint>(p[0], Number(key)); return pointFrom<GlobalPoint>(p[0], Number(key));
}) })
.sort((a, b) => a[0] - b[0]), .sort((a, b) => a[0] - b[0]),
), ),
} as PointSnapLine; matches: snap.matches,
}), } as PointSnapLineCandidate;
); });
return filterRedundantPointSnapLines(
snapLinesX,
(snapLine) => snapLine.points[0][0],
).concat(
filterRedundantPointSnapLines(
snapLinesY,
(snapLine) => snapLine.points[0][1],
),
);
}; };
const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => { const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => {
@@ -1143,40 +1327,52 @@ export const snapResizingElements = (
} }
} }
const selectionSnapPoints: GlobalPoint[] = []; const selectionSnapPoints: SnapPoint[] = [];
if (transformHandle) { if (transformHandle) {
switch (transformHandle) { switch (transformHandle) {
case "e": { case "e": {
selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY)); selectionSnapPoints.push(
outerSnapPoint(pointFrom(maxX, minY)),
outerSnapPoint(pointFrom(maxX, maxY)),
);
break; break;
} }
case "w": { case "w": {
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY)); selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, minY)),
outerSnapPoint(pointFrom(minX, maxY)),
);
break; break;
} }
case "n": { case "n": {
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY)); selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, minY)),
outerSnapPoint(pointFrom(maxX, minY)),
);
break; break;
} }
case "s": { case "s": {
selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY)); selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, maxY)),
outerSnapPoint(pointFrom(maxX, maxY)),
);
break; break;
} }
case "ne": { case "ne": {
selectionSnapPoints.push(pointFrom(maxX, minY)); selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, minY)));
break; break;
} }
case "nw": { case "nw": {
selectionSnapPoints.push(pointFrom(minX, minY)); selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, minY)));
break; break;
} }
case "se": { case "se": {
selectionSnapPoints.push(pointFrom(maxX, maxY)); selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, maxY)));
break; break;
} }
case "sw": { case "sw": {
selectionSnapPoints.push(pointFrom(minX, maxY)); selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, maxY)));
break; break;
} }
} }
@@ -1218,11 +1414,11 @@ export const snapResizingElements = (
round(bound), round(bound),
); );
const corners: GlobalPoint[] = [ const corners: SnapPoint[] = [
pointFrom(x1, y1), outerSnapPoint(pointFrom(x1, y1)),
pointFrom(x1, y2), outerSnapPoint(pointFrom(x1, y2)),
pointFrom(x2, y1), outerSnapPoint(pointFrom(x2, y1)),
pointFrom(x2, y2), outerSnapPoint(pointFrom(x2, y2)),
]; ];
getPointSnaps( getPointSnaps(
@@ -1258,8 +1454,8 @@ export const snapNewElement = (
}; };
} }
const selectionSnapPoints: GlobalPoint[] = [ const selectionSnapPoints: SnapPoint[] = [
pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y), outerSnapPoint(pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y)),
]; ];
const snapDistance = getSnapDistance(app.state.zoom.value); const snapDistance = getSnapDistance(app.state.zoom.value);
@@ -1292,7 +1488,7 @@ export const snapNewElement = (
nearestSnapsX.length = 0; nearestSnapsX.length = 0;
nearestSnapsY.length = 0; nearestSnapsY.length = 0;
const corners = getElementsCorners([newElement], elementsMap, { const corners = getElementsSnapPoints([newElement], elementsMap, {
boundingBoxCorners: true, boundingBoxCorners: true,
omitCenter: true, omitCenter: true,
}); });