Tldraw/packages/editor/src/lib/editor/managers/SnapManager.ts

1459 wiersze
41 KiB
TypeScript

import { atom, computed, EMPTY_ARRAY } from '@tldraw/state'
import { TLGroupShape, TLParentId, TLShape, TLShapeId, Vec2dModel } from '@tldraw/tlschema'
import { dedupe, deepCopy, warnDeprecatedGetter } from '@tldraw/utils'
import {
Box2d,
flipSelectionHandleX,
flipSelectionHandleY,
isSelectionCorner,
SelectionCorner,
SelectionEdge,
} from '../../primitives/Box2d'
import { Matrix2d } from '../../primitives/Matrix2d'
import { rangeIntersection, rangesOverlap } from '../../primitives/utils'
import { Vec2d, VecLike } from '../../primitives/Vec2d'
import { uniqueId } from '../../utils/uniqueId'
import type { Editor } from '../Editor'
/** @public */
export type PointsSnapLine = {
id: string
type: 'points'
points: VecLike[]
}
/** @public */
export type GapsSnapLine = {
id: string
type: 'gaps'
direction: 'horizontal' | 'vertical'
gaps: Array<{
startEdge: [VecLike, VecLike]
endEdge: [VecLike, VecLike]
}>
}
/** @public */
export type SnapLine = PointsSnapLine | GapsSnapLine
export type SnapInteractionType =
| {
type: 'translate'
lockedAxis: 'x' | 'y' | null
initialSelectionSnapPoints: Vec2d[]
}
| {
type: 'resize'
}
/** @public */
export interface SnapPoint {
id: string
x: number
y: number
handle?: SelectionCorner
}
type SnapPair = { thisPoint: SnapPoint; otherPoint: SnapPoint }
type NearestPointsSnap = {
// selection snaps to a nearby snap point
type: 'points'
points: SnapPair
nudge: number
}
type NearestSnap =
| NearestPointsSnap
| {
// selection snaps to the center of a gap
type: 'gap_center'
gap: Gap
nudge: number
}
| {
// selection snaps to create a new gap of equal size to another gap
// on the opposite side of some shape
type: 'gap_duplicate'
gap: Gap
protrusionDirection: 'left' | 'right' | 'top' | 'bottom'
nudge: number
}
type GapNode = {
id: TLShapeId
pageBounds: Box2d
isClosed: boolean
}
type Gap = {
// e.g.
// start
// edge │ breadth
// │ intersection
// ▼ [40,100] end
// │ │ edge
// ┌───────────┐ │ 100,0 │ │
// │ │ │ ▼ ▼
// │ │ │
// │ start │ │ │ 200,40 │ ┌───────────┐
// │ node │ │ │ │ │ │
// │ │ ├────────────┼────────────┤ │ end │
// │ │ │ │ │ │ node │
// └───────────┘ │ 100,100 │ │ │ │
// │ │ │
// 200,120 │ └───────────┘
//
// length 100
// ◄─────────────────────────►
startNode: GapNode
endNode: GapNode
startEdge: [Vec2d, Vec2d]
endEdge: [Vec2d, Vec2d]
length: number
breadthIntersection: [number, number]
}
interface SnapData {
nudge: Vec2d
}
const round = (x: number) => {
// round numbers to avoid glitches for floating point rounding errors
const decimalPlacesTolerance = 8
return Math.round(x * 10 ** decimalPlacesTolerance) / 10 ** decimalPlacesTolerance
}
function findAdjacentGaps(
gaps: Gap[],
shapeId: TLShapeId,
gapLength: number,
direction: 'forward' | 'backward',
intersection: [number, number]
): Gap[] {
// TODO: take advantage of the fact that gaps is sorted by starting position?
const matches = gaps.filter(
(gap) =>
(direction === 'forward' ? gap.startNode.id === shapeId : gap.endNode.id === shapeId) &&
round(gap.length) === round(gapLength) &&
rangeIntersection(
gap.breadthIntersection[0],
gap.breadthIntersection[1],
intersection[0],
intersection[1]
)
)
if (matches.length === 0) return []
const nextNodes = new Set<TLShapeId>()
matches.forEach((match) => {
const node = direction === 'forward' ? match.endNode.id : match.startNode.id
if (!nextNodes.has(node)) {
nextNodes.add(node)
const foundGaps = findAdjacentGaps(
gaps,
node,
gapLength,
direction,
rangeIntersection(
match.breadthIntersection[0],
match.breadthIntersection[1],
intersection[0],
intersection[1]
)!
)
matches.push(...foundGaps)
}
})
return matches
}
function dedupeGapSnaps(snaps: Array<Extract<SnapLine, { type: 'gaps' }>>) {
// sort by descending order of number of gaps
snaps.sort((a, b) => b.gaps.length - a.gaps.length)
// pop off any that are included already
for (let i = snaps.length - 1; i > 0; i--) {
const snap = snaps[i]
for (let j = i - 1; j >= 0; j--) {
const otherSnap = snaps[j]
// if every edge in this snap is included in the other snap somewhere, then it's redundant
if (
otherSnap.direction === snap.direction &&
snap.gaps.every(
(gap) =>
otherSnap.gaps.some(
(otherGap) =>
round(gap.startEdge[0].x) === round(otherGap.startEdge[0].x) &&
round(gap.startEdge[0].y) === round(otherGap.startEdge[0].y) &&
round(gap.startEdge[1].x) === round(otherGap.startEdge[1].x) &&
round(gap.startEdge[1].y) === round(otherGap.startEdge[1].y)
) &&
otherSnap.gaps.some(
(otherGap) =>
round(gap.endEdge[0].x) === round(otherGap.endEdge[0].x) &&
round(gap.endEdge[0].y) === round(otherGap.endEdge[0].y) &&
round(gap.endEdge[1].x) === round(otherGap.endEdge[1].x) &&
round(gap.endEdge[1].y) === round(otherGap.endEdge[1].y)
)
)
) {
snaps.splice(i, 1)
break
}
}
}
}
/** @public */
export class SnapManager {
private _snapLines = atom<SnapLine[] | undefined>('snapLines', undefined)
getLines() {
return this._snapLines.get() ?? (EMPTY_ARRAY as SnapLine[])
}
/**
* @deprecated use `getLines` instead
*/
// eslint-disable-next-line no-restricted-syntax
get lines() {
warnDeprecatedGetter('lines')
return this.getLines()
}
clear() {
if (this.getLines().length) {
this._snapLines.set(undefined)
}
}
setLines(lines: SnapLine[]) {
this._snapLines.set(lines)
}
constructor(public readonly editor: Editor) {}
@computed getSnapPointsCache() {
const { editor } = this
return editor.store.createComputedCache<SnapPoint[], TLShape>('snapPoints', (shape) => {
const pageTransfrorm = editor.getShapePageTransform(shape.id)
if (!pageTransfrorm) return undefined
const snapPoints = this.editor.getShapeGeometry(shape).snapPoints
return snapPoints.map((point, i) => {
const { x, y } = Matrix2d.applyToPoint(pageTransfrorm, point)
return { x, y, id: `${shape.id}:${i}` }
})
})
}
/**
* @deprecated use `getSnapPointsCache` instead
*/
// eslint-disable-next-line no-restricted-syntax
get snapPointsCache() {
warnDeprecatedGetter('snapPointsCache')
return this.getSnapPointsCache()
}
@computed getSnapThreshold() {
return 8 / this.editor.getZoomLevel()
}
/**
* @deprecated use `getSnapThreshold` instead
*/
// eslint-disable-next-line no-restricted-syntax
get snapThreshold() {
warnDeprecatedGetter('snapThreshold')
return this.getSnapThreshold()
}
// TODO: make this an incremental derivation
@computed getSnappableShapes(): GapNode[] {
const { editor } = this
const renderingBounds = editor.getRenderingBounds()
const selectedShapeIds = editor.getSelectedShapeIds()
const snappableShapes: GapNode[] = []
const collectSnappableShapesFromParent = (parentId: TLParentId) => {
const sortedChildIds = editor.getSortedChildIdsForParent(parentId)
for (const childId of sortedChildIds) {
// Skip any selected ids
if (selectedShapeIds.includes(childId)) continue
const childShape = editor.getShape(childId)
if (!childShape) continue
const util = editor.getShapeUtil(childShape)
// Skip any shapes that don't allow snapping
if (!util.canSnap(childShape)) continue
// Only consider shapes if they're inside of the viewport page bounds
const pageBounds = editor.getShapePageBounds(childId)
if (!(pageBounds && renderingBounds.includes(pageBounds))) continue
// Snap to children of groups but not group itself
if (editor.isShapeOfType<TLGroupShape>(childShape, 'group')) {
collectSnappableShapesFromParent(childId)
continue
}
snappableShapes.push({
id: childId,
pageBounds,
isClosed: editor.getShapeGeometry(childShape).isClosed,
})
}
}
collectSnappableShapesFromParent(this.getCurrentCommonAncestor() ?? editor.getCurrentPageId())
return snappableShapes
}
/**
* @deprecated use `getSnappableShapes` instead
*/
// eslint-disable-next-line no-restricted-syntax
get snappableShapes() {
warnDeprecatedGetter('snappableShapes')
return this.getSnappableShapes()
}
// This needs to be external from any expensive work
@computed getCurrentCommonAncestor() {
return this.editor.findCommonAncestor(this.editor.getSelectedShapes())
}
/**
* @deprecated use `getCurrentCommonAncestor` instead
*/
// eslint-disable-next-line no-restricted-syntax
get currentCommonAncestor() {
warnDeprecatedGetter('currentCommonAncestor')
return this.getCurrentCommonAncestor()
}
// Points which belong to snappable shapes
@computed getSnappablePoints() {
const snapPointsCache = this.getSnapPointsCache()
const snappableShapes = this.getSnappableShapes()
const result: SnapPoint[] = []
snappableShapes.forEach((shape) => {
const snapPoints = snapPointsCache.get(shape.id)
if (snapPoints) {
result.push(...snapPoints)
}
})
return result
}
/**
* @deprecated use `getSnappablePoints` instead
*/
// eslint-disable-next-line no-restricted-syntax
get snappablePoints() {
warnDeprecatedGetter('snappablePoints')
return this.getSnappablePoints()
}
@computed getVisibleGaps(): { horizontal: Gap[]; vertical: Gap[] } {
const horizontal: Gap[] = []
const vertical: Gap[] = []
let startNode: GapNode, endNode: GapNode
const sortedShapesOnCurrentPageHorizontal = this.getSnappableShapes().sort((a, b) => {
return a.pageBounds.minX - b.pageBounds.minX
})
// Collect horizontal gaps
for (let i = 0; i < sortedShapesOnCurrentPageHorizontal.length; i++) {
startNode = sortedShapesOnCurrentPageHorizontal[i]
for (let j = i + 1; j < sortedShapesOnCurrentPageHorizontal.length; j++) {
endNode = sortedShapesOnCurrentPageHorizontal[j]
if (
// is there space between the boxes
startNode.pageBounds.maxX < endNode.pageBounds.minX &&
// and they overlap in the y axis
rangesOverlap(
startNode.pageBounds.minY,
startNode.pageBounds.maxY,
endNode.pageBounds.minY,
endNode.pageBounds.maxY
)
) {
horizontal.push({
startNode,
endNode,
startEdge: [
new Vec2d(startNode.pageBounds.maxX, startNode.pageBounds.minY),
new Vec2d(startNode.pageBounds.maxX, startNode.pageBounds.maxY),
],
endEdge: [
new Vec2d(endNode.pageBounds.minX, endNode.pageBounds.minY),
new Vec2d(endNode.pageBounds.minX, endNode.pageBounds.maxY),
],
length: endNode.pageBounds.minX - startNode.pageBounds.maxX,
breadthIntersection: rangeIntersection(
startNode.pageBounds.minY,
startNode.pageBounds.maxY,
endNode.pageBounds.minY,
endNode.pageBounds.maxY
)!,
})
}
}
}
// Collect vertical gaps
const sortedShapesOnCurrentPageVertical = sortedShapesOnCurrentPageHorizontal.sort((a, b) => {
return a.pageBounds.minY - b.pageBounds.minY
})
for (let i = 0; i < sortedShapesOnCurrentPageVertical.length; i++) {
startNode = sortedShapesOnCurrentPageVertical[i]
for (let j = i + 1; j < sortedShapesOnCurrentPageVertical.length; j++) {
endNode = sortedShapesOnCurrentPageVertical[j]
if (
// is there space between the boxes
startNode.pageBounds.maxY < endNode.pageBounds.minY &&
// do they overlap in the x axis
rangesOverlap(
startNode.pageBounds.minX,
startNode.pageBounds.maxX,
endNode.pageBounds.minX,
endNode.pageBounds.maxX
)
) {
vertical.push({
startNode,
endNode,
startEdge: [
new Vec2d(startNode.pageBounds.minX, startNode.pageBounds.maxY),
new Vec2d(startNode.pageBounds.maxX, startNode.pageBounds.maxY),
],
endEdge: [
new Vec2d(endNode.pageBounds.minX, endNode.pageBounds.minY),
new Vec2d(endNode.pageBounds.maxX, endNode.pageBounds.minY),
],
length: endNode.pageBounds.minY - startNode.pageBounds.maxY,
breadthIntersection: rangeIntersection(
startNode.pageBounds.minX,
startNode.pageBounds.maxX,
endNode.pageBounds.minX,
endNode.pageBounds.maxX
)!,
})
}
}
}
return { horizontal, vertical }
}
/**
* @deprecated use `getVisibleGaps` instead
*/
// eslint-disable-next-line no-restricted-syntax
get visibleGaps() {
warnDeprecatedGetter('visibleGaps')
return this.getVisibleGaps()
}
snapTranslate({
lockedAxis,
initialSelectionPageBounds,
initialSelectionSnapPoints,
dragDelta,
}: {
lockedAxis: 'x' | 'y' | null
initialSelectionSnapPoints: SnapPoint[]
initialSelectionPageBounds: Box2d
dragDelta: Vec2d
}): SnapData {
const snapThreshold = this.getSnapThreshold()
const visibleSnapPointsNotInSelection = this.getSnappablePoints()
const selectionPageBounds = initialSelectionPageBounds.clone().translate(dragDelta)
const selectionSnapPoints: SnapPoint[] = initialSelectionSnapPoints.map(({ x, y }, i) => ({
id: 'selection:' + i,
x: x + dragDelta.x,
y: y + dragDelta.y,
}))
const otherNodeSnapPoints = visibleSnapPointsNotInSelection
const nearestSnapsX: NearestSnap[] = []
const nearestSnapsY: NearestSnap[] = []
const minOffset = new Vec2d(snapThreshold, snapThreshold)
this.collectPointSnaps({
minOffset,
nearestSnapsX,
nearestSnapsY,
otherNodeSnapPoints,
selectionSnapPoints,
})
this.collectGapSnaps({
selectionPageBounds,
nearestSnapsX,
nearestSnapsY,
minOffset,
})
// at the same time, calculate how far we need to nudge the shape to 'snap' to the target point(s)
const nudge = new Vec2d(
lockedAxis === 'x' ? 0 : nearestSnapsX[0]?.nudge ?? 0,
lockedAxis === 'y' ? 0 : nearestSnapsY[0]?.nudge ?? 0
)
// ok we've figured out how much the box should be nudged, now let's find all the snap points
// that are exact after making that translation, so we can render all of them.
// first reset everything and adjust the original shapes to conform to the nudge
minOffset.x = 0
minOffset.y = 0
nearestSnapsX.length = 0
nearestSnapsY.length = 0
selectionSnapPoints.forEach((s) => {
s.x += nudge.x
s.y += nudge.y
})
selectionPageBounds.translate(nudge)
this.collectPointSnaps({
minOffset,
nearestSnapsX,
nearestSnapsY,
otherNodeSnapPoints,
selectionSnapPoints,
})
this.collectGapSnaps({
selectionPageBounds,
nearestSnapsX,
nearestSnapsY,
minOffset,
})
const pointSnapsLines = this.getPointSnapLines({
nearestSnapsX,
nearestSnapsY,
})
const gapSnapLines = this.getGapSnapLines({
selectionPageBounds,
nearestSnapsX,
nearestSnapsY,
})
this._snapLines.set([...gapSnapLines, ...pointSnapsLines])
return { nudge }
}
@computed getOutlinesInPageSpace() {
return this.getSnappableShapes().map(({ id, isClosed }) => {
const outline = deepCopy(this.editor.getShapeGeometry(id).vertices)
if (isClosed) outline.push(outline[0])
const pageTransform = this.editor.getShapePageTransform(id)
if (!pageTransform) throw Error('No page transform')
return Matrix2d.applyToPoints(pageTransform, outline)
})
}
/**
* @deprecated use `getOutlinesInPageSpace` instead
*/
// eslint-disable-next-line no-restricted-syntax
get outlinesInPageSpace() {
warnDeprecatedGetter('outlinesInPageSpace')
return this.getOutlinesInPageSpace()
}
getSnappingHandleDelta({
handlePoint,
additionalSegments,
}: {
handlePoint: Vec2d
additionalSegments: Vec2d[][]
}): Vec2d | null {
const snapThreshold = this.getSnapThreshold()
const outlinesInPageSpace = this.getOutlinesInPageSpace()
// Find the nearest point that is within the snap threshold
let minDistance = snapThreshold
let nearestPoint: Vec2d | null = null
let C: Vec2dModel, D: Vec2dModel, nearest: Vec2d, distance: number
const allSegments = [...outlinesInPageSpace, ...additionalSegments]
for (const outline of allSegments) {
for (let i = 0; i < outline.length - 1; i++) {
C = outline[i]
D = outline[i + 1]
nearest = Vec2d.NearestPointOnLineSegment(C, D, handlePoint)
distance = Vec2d.Dist(handlePoint, nearest)
if (isNaN(distance)) continue
if (distance < minDistance) {
minDistance = distance
nearestPoint = nearest
}
}
}
// If we found a point, display snap lines, and return the nudge
if (nearestPoint) {
this._snapLines.set([
{
id: uniqueId(),
type: 'points',
points: [nearestPoint],
},
])
return Vec2d.Sub(nearestPoint, handlePoint)
}
return null
}
snapResize({
initialSelectionPageBounds,
dragDelta,
handle: originalHandle,
isAspectRatioLocked,
isResizingFromCenter,
}: {
// the page bounds when the pointer went down, before any dragging
initialSelectionPageBounds: Box2d
// how far the pointer has been dragged
dragDelta: Vec2d
handle: SelectionCorner | SelectionEdge
isAspectRatioLocked: boolean
isResizingFromCenter: boolean
}): SnapData {
const snapThreshold = this.getSnapThreshold()
// first figure out the new bounds of the selection
const {
box: unsnappedResizedPageBounds,
scaleX,
scaleY,
} = Box2d.Resize(
initialSelectionPageBounds,
originalHandle,
isResizingFromCenter ? dragDelta.x * 2 : dragDelta.x,
isResizingFromCenter ? dragDelta.y * 2 : dragDelta.y,
isAspectRatioLocked
)
let handle = originalHandle
if (scaleX < 0) {
handle = flipSelectionHandleX(handle)
}
if (scaleY < 0) {
handle = flipSelectionHandleY(handle)
}
if (isResizingFromCenter) {
// reposition if resizing from center
unsnappedResizedPageBounds.center = initialSelectionPageBounds.center
}
const isXLocked = handle === 'top' || handle === 'bottom'
const isYLocked = handle === 'left' || handle === 'right'
const selectionSnapPoints = getResizeSnapPointsForHandle(handle, unsnappedResizedPageBounds)
const otherNodeSnapPoints = this.getSnappablePoints()
const nearestSnapsX: NearestPointsSnap[] = []
const nearestSnapsY: NearestPointsSnap[] = []
const minOffset = new Vec2d(snapThreshold, snapThreshold)
this.collectPointSnaps({
minOffset,
nearestSnapsX,
nearestSnapsY,
otherNodeSnapPoints,
selectionSnapPoints,
})
// at the same time, calculate how far we need to nudge the shape to 'snap' to the target point(s)
const nudge = new Vec2d(
isXLocked ? 0 : nearestSnapsX[0]?.nudge ?? 0,
isYLocked ? 0 : nearestSnapsY[0]?.nudge ?? 0
)
if (isAspectRatioLocked && isSelectionCorner(handle) && nudge.len() !== 0) {
// if the aspect ratio is locked we need to make the nudge diagonal rather than independent in each axis
// so we use the aspect ratio along with one axis value to set the other axis value, but which axis we use
// as a source of truth depends what we have snapped to and how far.
// if we found a snap in both axes, pick the closest one and discard the other
const primaryNudgeAxis: 'x' | 'y' =
nearestSnapsX.length && nearestSnapsY.length
? Math.abs(nudge.x) < Math.abs(nudge.y)
? 'x'
: 'y'
: nearestSnapsX.length
? 'x'
: 'y'
const ratio = initialSelectionPageBounds.aspectRatio
if (primaryNudgeAxis === 'x') {
nearestSnapsY.length = 0
nudge.y = nudge.x / ratio
if (handle === 'bottom_left' || handle === 'top_right') {
nudge.y = -nudge.y
}
} else {
nearestSnapsX.length = 0
nudge.x = nudge.y * ratio
if (handle === 'bottom_left' || handle === 'top_right') {
nudge.x = -nudge.x
}
}
}
// now resize the box after nudging, calculate the snaps again, and return the snap lines to match
// the fully resized box
const snappedDelta = Vec2d.Add(dragDelta, nudge)
// first figure out the new bounds of the selection
const { box: snappedResizedPageBounds } = Box2d.Resize(
initialSelectionPageBounds,
originalHandle,
isResizingFromCenter ? snappedDelta.x * 2 : snappedDelta.x,
isResizingFromCenter ? snappedDelta.y * 2 : snappedDelta.y,
isAspectRatioLocked
)
if (isResizingFromCenter) {
// reposition if resizing from center
snappedResizedPageBounds.center = initialSelectionPageBounds.center
}
const snappedSelectionPoints = getResizeSnapPointsForHandle('any', snappedResizedPageBounds)
// calculate snaps again using all points
nearestSnapsX.length = 0
nearestSnapsY.length = 0
minOffset.x = 0
minOffset.y = 0
this.collectPointSnaps({
minOffset,
nearestSnapsX,
nearestSnapsY,
otherNodeSnapPoints,
selectionSnapPoints: snappedSelectionPoints,
})
const pointSnaps = this.getPointSnapLines({
nearestSnapsX,
nearestSnapsY,
})
this._snapLines.set([...pointSnaps])
return { nudge }
}
private collectPointSnaps({
selectionSnapPoints,
otherNodeSnapPoints,
minOffset,
nearestSnapsX,
nearestSnapsY,
}: {
selectionSnapPoints: SnapPoint[]
otherNodeSnapPoints: SnapPoint[]
minOffset: Vec2d
nearestSnapsX: NearestSnap[]
nearestSnapsY: NearestSnap[]
}) {
// for each snap point on the bounding box of the selection, find the set of points
// which are closest to it in each axis
for (const thisSnapPoint of selectionSnapPoints) {
for (const otherSnapPoint of otherNodeSnapPoints) {
const offset = Vec2d.Sub(thisSnapPoint, otherSnapPoint)
const offsetX = Math.abs(offset.x)
const offsetY = Math.abs(offset.y)
if (round(offsetX) <= round(minOffset.x)) {
if (round(offsetX) < round(minOffset.x)) {
// we found a point that is significantly closer than all previous points
// so wipe the slate clean and start over
nearestSnapsX.length = 0
}
nearestSnapsX.push({
type: 'points',
points: { thisPoint: thisSnapPoint, otherPoint: otherSnapPoint },
nudge: otherSnapPoint.x - thisSnapPoint.x,
})
minOffset.x = offsetX
}
if (round(offsetY) <= round(minOffset.y)) {
if (round(offsetY) < round(minOffset.y)) {
// we found a point that is significantly closer than all previous points
// so wipe the slate clean and start over
nearestSnapsY.length = 0
}
nearestSnapsY.push({
type: 'points',
points: { thisPoint: thisSnapPoint, otherPoint: otherSnapPoint },
nudge: otherSnapPoint.y - thisSnapPoint.y,
})
minOffset.y = offsetY
}
}
}
}
private collectGapSnaps({
selectionPageBounds,
minOffset,
nearestSnapsX,
nearestSnapsY,
}: {
selectionPageBounds: Box2d
minOffset: Vec2d
nearestSnapsX: NearestSnap[]
nearestSnapsY: NearestSnap[]
}) {
const { horizontal, vertical } = this.getVisibleGaps()
for (const gap of horizontal) {
// ignore this gap if the selection doesn't overlap with it in the y axis
if (
!rangesOverlap(
gap.breadthIntersection[0],
gap.breadthIntersection[1],
selectionPageBounds.minY,
selectionPageBounds.maxY
)
) {
continue
}
// check for center match
const gapMidX = gap.startEdge[0].x + gap.length / 2
const centerNudge = gapMidX - selectionPageBounds.center.x
const gapIsLargerThanSelection = gap.length > selectionPageBounds.width
if (gapIsLargerThanSelection && round(Math.abs(centerNudge)) <= round(minOffset.x)) {
if (round(Math.abs(centerNudge)) < round(minOffset.x)) {
// reset if we found a closer snap
nearestSnapsX.length = 0
}
minOffset.x = Math.abs(centerNudge)
const snap: NearestSnap = {
type: 'gap_center',
gap,
nudge: centerNudge,
}
// we need to avoid creating visual noise with too many center snaps in situations
// where there are lots of adjacent items with even spacing
// so let's only show other center snaps where the gap's breadth does not overlap with this one
// i.e.
// ┌───────────────┐
// │ │
// └──────┬────┬───┘
// ┼ │
// ┌─────┴┐ │
// │ │ ┼
// └─────┬┘ │
// ┼ │
// ┌───┴────┴───────┐
// │ │ ◄──── i'm dragging this one
// └───┬────┬───────┘
// ─────► ┼ │
// ┌─────┴┐ │ don't show these
// show these │ │ ┼ larger gaps since
// smaller └─────┬┘ │ ◄───────────── the smaller ones
// gaps ┼ │ cover the same
// ─────► ┌┴────┴─────┐ information
// │ │
// └───────────┘
//
// but we want to show all of these ones since the gap breadths don't overlap
// ┌─────────────┐
// │ │
// ┌────┐ └───┬─────────┘
// │ │ │
// └──┬─┘ ┼
// ┼ │
// ┌──┴───────────┴─┐
// │ │ ◄───── i'm dragging this one
// └──┬───────────┬─┘
// ┼ │
// ┌──┴────┐ ┼
// │ │ │
// └───────┘ ┌─┴───────┐
// │ │
// └─────────┘
const otherCenterSnap = nearestSnapsX.find(({ type }) => type === 'gap_center') as
| Extract<NearestSnap, { type: 'gap_center' }>
| undefined
const gapBreadthsOverlap =
otherCenterSnap &&
rangeIntersection(
gap.breadthIntersection[0],
gap.breadthIntersection[1],
otherCenterSnap.gap.breadthIntersection[0],
otherCenterSnap.gap.breadthIntersection[1]
)
// if there is another center snap and it's bigger than this one, and it overlaps with this one, replace it
if (otherCenterSnap && otherCenterSnap.gap.length > gap.length && gapBreadthsOverlap) {
nearestSnapsX[nearestSnapsX.indexOf(otherCenterSnap)] = snap
} else if (!otherCenterSnap || !gapBreadthsOverlap) {
nearestSnapsX.push(snap)
}
}
// check for duplication left match
const duplicationLeftX = gap.startNode.pageBounds.minX - gap.length
const selectionRightX = selectionPageBounds.maxX
const duplicationLeftNudge = duplicationLeftX - selectionRightX
if (round(Math.abs(duplicationLeftNudge)) <= round(minOffset.x)) {
if (round(Math.abs(duplicationLeftNudge)) < round(minOffset.x)) {
// reset if we found a closer snap
nearestSnapsX.length = 0
}
minOffset.x = Math.abs(duplicationLeftNudge)
nearestSnapsX.push({
type: 'gap_duplicate',
gap,
protrusionDirection: 'left',
nudge: duplicationLeftNudge,
})
}
// check for duplication right match
const duplicationRightX = gap.endNode.pageBounds.maxX + gap.length
const selectionLeftX = selectionPageBounds.minX
const duplicationRightNudge = duplicationRightX - selectionLeftX
if (round(Math.abs(duplicationRightNudge)) <= round(minOffset.x)) {
if (round(Math.abs(duplicationRightNudge)) < round(minOffset.x)) {
// reset if we found a closer snap
nearestSnapsX.length = 0
}
minOffset.x = Math.abs(duplicationRightNudge)
nearestSnapsX.push({
type: 'gap_duplicate',
gap,
protrusionDirection: 'right',
nudge: duplicationRightNudge,
})
}
}
for (const gap of vertical) {
// ignore this gap if the selection doesn't overlap with it in the y axis
if (
!rangesOverlap(
gap.breadthIntersection[0],
gap.breadthIntersection[1],
selectionPageBounds.minX,
selectionPageBounds.maxX
)
) {
continue
}
// check for center match
const gapMidY = gap.startEdge[0].y + gap.length / 2
const centerNudge = gapMidY - selectionPageBounds.center.y
const gapIsLargerThanSelection = gap.length > selectionPageBounds.height
if (gapIsLargerThanSelection && round(Math.abs(centerNudge)) <= round(minOffset.y)) {
if (round(Math.abs(centerNudge)) < round(minOffset.y)) {
// reset if we found a closer snap
nearestSnapsY.length = 0
}
minOffset.y = Math.abs(centerNudge)
const snap: NearestSnap = {
type: 'gap_center',
gap,
nudge: centerNudge,
}
// we need to avoid creating visual noise with too many center snaps in situations
// where there are lots of adjacent items with even spacing
// so let's only show other center snaps where the gap's breadth does not overlap with this one
// i.e.
// ┌───────────────┐
// │ │
// └──────┬────┬───┘
// ┼ │
// ┌─────┴┐ │
// │ │ ┼
// └─────┬┘ │
// ┼ │
// ┌───┴────┴───────┐
// │ │ ◄──── i'm dragging this one
// └───┬────┬───────┘
// ─────► ┼ │
// ┌─────┴┐ │ don't show these
// show these │ │ ┼ larger gaps since
// smaller └─────┬┘ │ ◄───────────── the smaller ones
// gaps ┼ │ cover the same
// ─────► ┌┴────┴─────┐ information
// │ │
// └───────────┘
//
// but we want to show all of these ones since the gap breadths don't overlap
// ┌─────────────┐
// │ │
// ┌────┐ └───┬─────────┘
// │ │ │
// └──┬─┘ ┼
// ┼ │
// ┌──┴───────────┴─┐
// │ │ ◄───── i'm dragging this one
// └──┬───────────┬─┘
// ┼ │
// ┌──┴────┐ ┼
// │ │ │
// └───────┘ ┌─┴───────┐
// │ │
// └─────────┘
const otherCenterSnap = nearestSnapsY.find(({ type }) => type === 'gap_center') as
| Extract<NearestSnap, { type: 'gap_center' }>
| undefined
const gapBreadthsOverlap =
otherCenterSnap &&
rangesOverlap(
otherCenterSnap.gap.breadthIntersection[0],
otherCenterSnap.gap.breadthIntersection[1],
gap.breadthIntersection[0],
gap.breadthIntersection[1]
)
// if there is another center snap and it's bigger than this one, and it overlaps with this one, replace it
if (otherCenterSnap && otherCenterSnap.gap.length > gap.length && gapBreadthsOverlap) {
nearestSnapsY[nearestSnapsY.indexOf(otherCenterSnap)] = snap
} else if (!otherCenterSnap || !gapBreadthsOverlap) {
nearestSnapsY.push(snap)
}
continue
}
// check for duplication top match
const duplicationTopY = gap.startNode.pageBounds.minY - gap.length
const selectionBottomY = selectionPageBounds.maxY
const duplicationTopNudge = duplicationTopY - selectionBottomY
if (round(Math.abs(duplicationTopNudge)) <= round(minOffset.y)) {
if (round(Math.abs(duplicationTopNudge)) < round(minOffset.y)) {
// reset if we found a closer snap
nearestSnapsY.length = 0
}
minOffset.y = Math.abs(duplicationTopNudge)
nearestSnapsY.push({
type: 'gap_duplicate',
gap,
protrusionDirection: 'top',
nudge: duplicationTopNudge,
})
}
// check for duplication bottom match
const duplicationBottomY = gap.endNode.pageBounds.maxY + gap.length
const selectionTopY = selectionPageBounds.minY
const duplicationBottomNudge = duplicationBottomY - selectionTopY
if (round(Math.abs(duplicationBottomNudge)) <= round(minOffset.y)) {
if (round(Math.abs(duplicationBottomNudge)) < round(minOffset.y)) {
// reset if we found a closer snap
nearestSnapsY.length = 0
}
minOffset.y = Math.abs(duplicationBottomNudge)
nearestSnapsY.push({
type: 'gap_duplicate',
gap,
protrusionDirection: 'bottom',
nudge: duplicationBottomNudge,
})
}
}
}
private getPointSnapLines({
nearestSnapsX,
nearestSnapsY,
}: {
nearestSnapsX: NearestSnap[]
nearestSnapsY: NearestSnap[]
}): PointsSnapLine[] {
// point snaps may align on multiple parallel lines so we need to split the pairs
// into groups based on where they are in their their snap axes
const snapGroupsX = {} as { [key: string]: SnapPair[] }
const snapGroupsY = {} as { [key: string]: SnapPair[] }
if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) {
if (snap.type === 'points') {
const key = round(snap.points.otherPoint.x)
if (!snapGroupsX[key]) {
snapGroupsX[key] = []
}
snapGroupsX[key].push(snap.points)
}
}
}
if (nearestSnapsY.length > 0) {
for (const snap of nearestSnapsY) {
if (snap.type === 'points') {
const key = round(snap.points.otherPoint.y)
if (!snapGroupsY[key]) {
snapGroupsY[key] = []
}
snapGroupsY[key].push(snap.points)
}
}
}
// and finally create all the snap lines for the UI to render
return Object.values(snapGroupsX)
.concat(Object.values(snapGroupsY))
.map((snapGroup) => ({
id: uniqueId(),
type: 'points',
points: dedupe(
snapGroup
.map((snap) => Vec2d.From(snap.otherPoint))
// be sure to nudge over the selection snap points
.concat(snapGroup.map((snap) => Vec2d.From(snap.thisPoint))),
(a: Vec2d, b: Vec2d) => a.equals(b)
),
}))
}
private getGapSnapLines({
selectionPageBounds,
nearestSnapsX,
nearestSnapsY,
}: {
selectionPageBounds: Box2d
nearestSnapsX: NearestSnap[]
nearestSnapsY: NearestSnap[]
}): GapsSnapLine[] {
const { vertical, horizontal } = this.getVisibleGaps()
const selectionSides: Record<SelectionEdge, [Vec2d, Vec2d]> = {
top: selectionPageBounds.sides[0],
right: selectionPageBounds.sides[1],
// need bottom and left to be sorted asc, which .sides is not.
bottom: [selectionPageBounds.corners[3], selectionPageBounds.corners[2]],
left: [selectionPageBounds.corners[0], selectionPageBounds.corners[3]],
}
const result: GapsSnapLine[] = []
if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) {
if (snap.type === 'points') continue
const {
gap: { breadthIntersection, startEdge, startNode, endNode, length, endEdge },
} = snap
switch (snap.type) {
case 'gap_center': {
// create
const newGapsLength = (length - selectionPageBounds.width) / 2
const gapBreadthIntersection = rangeIntersection(
breadthIntersection[0],
breadthIntersection[1],
selectionPageBounds.minY,
selectionPageBounds.maxY
)!
result.push({
type: 'gaps',
direction: 'horizontal',
id: uniqueId(),
gaps: [
...findAdjacentGaps(
horizontal,
startNode.id,
newGapsLength,
'backward',
gapBreadthIntersection
),
{
startEdge,
endEdge: selectionSides.left,
},
{
startEdge: selectionSides.right,
endEdge,
},
...findAdjacentGaps(
horizontal,
endNode.id,
newGapsLength,
'forward',
gapBreadthIntersection
),
],
})
break
}
case 'gap_duplicate': {
// create
const gapBreadthIntersection = rangeIntersection(
breadthIntersection[0],
breadthIntersection[1],
selectionPageBounds.minY,
selectionPageBounds.maxY
)!
result.push({
type: 'gaps',
direction: 'horizontal',
id: uniqueId(),
gaps:
snap.protrusionDirection === 'left'
? [
{
startEdge: selectionSides.right,
endEdge: startEdge.map((v) =>
v.clone().addXY(-startNode.pageBounds.width, 0)
) as [Vec2d, Vec2d],
},
{ startEdge, endEdge },
...findAdjacentGaps(
horizontal,
endNode.id,
length,
'forward',
gapBreadthIntersection
),
]
: [
...findAdjacentGaps(
horizontal,
startNode.id,
length,
'backward',
gapBreadthIntersection
),
{ startEdge, endEdge },
{
startEdge: endEdge.map((v) =>
v.clone().addXY(snap.gap.endNode.pageBounds.width, 0)
) as [Vec2d, Vec2d],
endEdge: selectionSides.left,
},
],
})
break
}
}
}
}
if (nearestSnapsY.length > 0) {
for (const snap of nearestSnapsY) {
if (snap.type === 'points') continue
const {
gap: { breadthIntersection, startEdge, startNode, endNode, length, endEdge },
} = snap
switch (snap.type) {
case 'gap_center': {
const newGapsLength = (length - selectionPageBounds.height) / 2
const gapBreadthIntersection = rangeIntersection(
breadthIntersection[0],
breadthIntersection[1],
selectionPageBounds.minX,
selectionPageBounds.maxX
)!
result.push({
type: 'gaps',
direction: 'vertical',
id: uniqueId(),
gaps: [
...findAdjacentGaps(
vertical,
startNode.id,
newGapsLength,
'backward',
gapBreadthIntersection
),
{
startEdge,
endEdge: selectionSides.top,
},
{
startEdge: selectionSides.bottom,
endEdge,
},
...findAdjacentGaps(
vertical,
snap.gap.endNode.id,
newGapsLength,
'forward',
gapBreadthIntersection
),
],
})
break
}
case 'gap_duplicate':
{
const gapBreadthIntersection = rangeIntersection(
breadthIntersection[0],
breadthIntersection[1],
selectionPageBounds.minX,
selectionPageBounds.maxX
)!
result.push({
type: 'gaps',
direction: 'vertical',
id: uniqueId(),
gaps:
snap.protrusionDirection === 'top'
? [
{
startEdge: selectionSides.bottom,
endEdge: startEdge.map((v) =>
v.clone().addXY(0, -startNode.pageBounds.height)
) as [Vec2d, Vec2d],
},
{ startEdge, endEdge },
...findAdjacentGaps(
vertical,
endNode.id,
length,
'forward',
gapBreadthIntersection
),
]
: [
...findAdjacentGaps(
vertical,
startNode.id,
length,
'backward',
gapBreadthIntersection
),
{ startEdge, endEdge },
{
startEdge: endEdge.map((v) =>
v.clone().addXY(0, endNode.pageBounds.height)
) as [Vec2d, Vec2d],
endEdge: selectionSides.top,
},
],
})
}
break
}
}
}
dedupeGapSnaps(result)
return result
}
}
function getResizeSnapPointsForHandle(
handle: SelectionCorner | SelectionEdge | 'any',
selectionPageBounds: Box2d
): SnapPoint[] {
const { minX, maxX, minY, maxY } = selectionPageBounds
const result: SnapPoint[] = []
// top left corner
switch (handle) {
case 'top':
case 'left':
case 'top_left':
case 'any':
result.push({
id: 'top_left',
handle: 'top_left',
x: minX,
y: minY,
})
}
// top right corner
switch (handle) {
case 'top':
case 'right':
case 'top_right':
case 'any':
result.push({
id: 'top_right',
handle: 'top_right',
x: maxX,
y: minY,
})
}
// bottom right corner
switch (handle) {
case 'bottom':
case 'right':
case 'bottom_right':
case 'any':
result.push({
id: 'bottom_right',
handle: 'bottom_right',
x: maxX,
y: maxY,
})
}
// bottom left corner
switch (handle) {
case 'bottom':
case 'left':
case 'bottom_left':
case 'any':
result.push({
id: 'bottom_left',
handle: 'bottom_left',
x: minX,
y: maxY,
})
}
return result
}