diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 6ea47e5bef..c5da70dff2 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -55,10 +55,35 @@ type Vector2D = { }; 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 = { type: "point"; points: PointPair; + pointTypes: [SnapPointType, SnapPointType]; + sourceIds: [SnapPointSourceId, SnapPointSourceId]; offset: number; }; @@ -120,14 +145,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine; // ----------------------------------------------------------------------------- export class SnapCache { - private static referenceSnapPoints: GlobalPoint[] | null = null; + private static referenceSnapPoints: SnapPoint[] | null = null; private static visibleGaps: { verticalGaps: Gap[]; horizontalGaps: Gap[]; } | null = null; - public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => { + public static setReferenceSnapPoints = (snapPoints: SnapPoint[] | null) => { SnapCache.referenceSnapPoints = snapPoints; }; @@ -312,6 +337,30 @@ export const getElementsCorners = ( return result.map((p) => pointFrom(round(p[0]), round(p[1]))); }; +const getElementsSnapPoints = ( + elements: ExcalidrawElement[], + elementsMap: ElementsMap, + options: Parameters[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 = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[], @@ -630,12 +679,19 @@ export const getReferenceSnapPoints = ( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), ) - .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); + .flatMap((elementGroup) => + getElementsSnapPoints( + elementGroup, + elementsMap, + {}, + getSnapPointSourceId(elementGroup), + ), + ); }; const getPointSnaps = ( selectedElements: ExcalidrawElement[], - selectionSnapPoints: GlobalPoint[], + selectionSnapPoints: SnapPoint[], app: AppClassProperties, event: KeyboardModifiersObject, nearestSnapsX: Snaps, @@ -654,8 +710,8 @@ const getPointSnaps = ( if (referenceSnapPoints) { for (const thisSnapPoint of selectionSnapPoints) { for (const otherSnapPoint of referenceSnapPoints) { - const offsetX = otherSnapPoint[0] - thisSnapPoint[0]; - const offsetY = otherSnapPoint[1] - thisSnapPoint[1]; + const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0]; + const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1]; if (Math.abs(offsetX) <= minOffset.x) { if (Math.abs(offsetX) < minOffset.x) { @@ -664,7 +720,9 @@ const getPointSnaps = ( nearestSnapsX.push({ type: "point", - points: [thisSnapPoint, otherSnapPoint], + points: [thisSnapPoint.point, otherSnapPoint.point], + pointTypes: [thisSnapPoint.type, otherSnapPoint.type], + sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId], offset: offsetX, }); @@ -678,7 +736,9 @@ const getPointSnaps = ( nearestSnapsY.push({ type: "point", - points: [thisSnapPoint, otherSnapPoint], + points: [thisSnapPoint.point, otherSnapPoint.point], + pointTypes: [thisSnapPoint.type, otherSnapPoint.type], + sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId], offset: offsetY, }); @@ -720,7 +780,7 @@ export const snapDraggedElements = ( y: snapDistance, }; - const selectionPoints = getElementsCorners(selectedElements, elementsMap, { + const selectionPoints = getElementsSnapPoints(selectedElements, elementsMap, { dragOffset, }); @@ -770,7 +830,7 @@ export const snapDraggedElements = ( getPointSnaps( selectedElements, - getElementsCorners(selectedElements, elementsMap, { + getElementsSnapPoints(selectedElements, elementsMap, { dragOffset: newDragOffset, }), app, @@ -825,12 +885,124 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => { 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(); + + 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 = ( nearestSnapsX: Snaps, nearestSnapsY: Snaps, ): PointSnapLine[] => { - const snapsX = {} as { [key: string]: GlobalPoint[] }; - const snapsY = {} as { [key: string]: GlobalPoint[] }; + const snapsX = {} as { [key: string]: PointSnapLineBucket }; + const snapsY = {} as { [key: string]: PointSnapLineBucket }; if (nearestSnapsX.length > 0) { for (const snap of nearestSnapsX) { @@ -838,13 +1010,14 @@ const createPointSnapLines = ( // key = thisPoint.x const key = round(snap.points[0][0]); if (!snapsX[key]) { - snapsX[key] = []; + snapsX[key] = { points: [], matches: [] }; } - snapsX[key].push( + snapsX[key].points.push( ...snap.points.map((p) => pointFrom(round(p[0]), round(p[1])), ), ); + snapsX[key].matches.push(getPointSnapLineMatch(snap)); } } } @@ -855,44 +1028,55 @@ const createPointSnapLines = ( // key = thisPoint.y const key = round(snap.points[0][1]); if (!snapsY[key]) { - snapsY[key] = []; + snapsY[key] = { points: [], matches: [] }; } - snapsY[key].push( + snapsY[key].points.push( ...snap.points.map((p) => pointFrom(round(p[0]), round(p[1])), ), ); + snapsY[key].matches.push(getPointSnapLineMatch(snap)); } } } - return Object.entries(snapsX) - .map(([key, points]) => { - return { - type: "points", - points: dedupePoints( - points - .map((p) => { - return pointFrom(Number(key), p[1]); - }) - .sort((a, b) => a[1] - b[1]), - ), - } as PointSnapLine; - }) - .concat( - Object.entries(snapsY).map(([key, points]) => { - return { - type: "points", - points: dedupePoints( - points - .map((p) => { - return pointFrom(p[0], Number(key)); - }) - .sort((a, b) => a[0] - b[0]), - ), - } as PointSnapLine; - }), - ); + const snapLinesX = Object.entries(snapsX).map(([key, snap]) => { + return { + type: "points", + points: dedupePoints( + snap.points + .map((p) => { + return pointFrom(Number(key), p[1]); + }) + .sort((a, b) => a[1] - b[1]), + ), + matches: snap.matches, + } as PointSnapLineCandidate; + }); + + const snapLinesY = Object.entries(snapsY).map(([key, snap]) => { + return { + type: "points", + points: dedupePoints( + snap.points + .map((p) => { + return pointFrom(p[0], Number(key)); + }) + .sort((a, b) => a[0] - b[0]), + ), + 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[]) => { @@ -1143,40 +1327,52 @@ export const snapResizingElements = ( } } - const selectionSnapPoints: GlobalPoint[] = []; + const selectionSnapPoints: SnapPoint[] = []; if (transformHandle) { switch (transformHandle) { case "e": { - selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY)); + selectionSnapPoints.push( + outerSnapPoint(pointFrom(maxX, minY)), + outerSnapPoint(pointFrom(maxX, maxY)), + ); break; } case "w": { - selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY)); + selectionSnapPoints.push( + outerSnapPoint(pointFrom(minX, minY)), + outerSnapPoint(pointFrom(minX, maxY)), + ); break; } case "n": { - selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY)); + selectionSnapPoints.push( + outerSnapPoint(pointFrom(minX, minY)), + outerSnapPoint(pointFrom(maxX, minY)), + ); break; } case "s": { - selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY)); + selectionSnapPoints.push( + outerSnapPoint(pointFrom(minX, maxY)), + outerSnapPoint(pointFrom(maxX, maxY)), + ); break; } case "ne": { - selectionSnapPoints.push(pointFrom(maxX, minY)); + selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, minY))); break; } case "nw": { - selectionSnapPoints.push(pointFrom(minX, minY)); + selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, minY))); break; } case "se": { - selectionSnapPoints.push(pointFrom(maxX, maxY)); + selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, maxY))); break; } case "sw": { - selectionSnapPoints.push(pointFrom(minX, maxY)); + selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, maxY))); break; } } @@ -1218,11 +1414,11 @@ export const snapResizingElements = ( round(bound), ); - const corners: GlobalPoint[] = [ - pointFrom(x1, y1), - pointFrom(x1, y2), - pointFrom(x2, y1), - pointFrom(x2, y2), + const corners: SnapPoint[] = [ + outerSnapPoint(pointFrom(x1, y1)), + outerSnapPoint(pointFrom(x1, y2)), + outerSnapPoint(pointFrom(x2, y1)), + outerSnapPoint(pointFrom(x2, y2)), ]; getPointSnaps( @@ -1258,8 +1454,8 @@ export const snapNewElement = ( }; } - const selectionSnapPoints: GlobalPoint[] = [ - pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y), + const selectionSnapPoints: SnapPoint[] = [ + outerSnapPoint(pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y)), ]; const snapDistance = getSnapDistance(app.state.zoom.value); @@ -1292,7 +1488,7 @@ export const snapNewElement = ( nearestSnapsX.length = 0; nearestSnapsY.length = 0; - const corners = getElementsCorners([newElement], elementsMap, { + const corners = getElementsSnapPoints([newElement], elementsMap, { boundingBoxCorners: true, omitCenter: true, });