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

1250 wiersze
36 KiB
TypeScript

import { computed } from '@tldraw/state'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import { assertExists, dedupe } from '@tldraw/utils'
import {
Box,
SelectionCorner,
SelectionEdge,
flipSelectionHandleX,
flipSelectionHandleY,
isSelectionCorner,
} from '../../../primitives/Box'
import { Mat } from '../../../primitives/Mat'
import { Vec } from '../../../primitives/Vec'
import { rangeIntersection, rangesOverlap } from '../../../primitives/utils'
import { uniqueId } from '../../../utils/uniqueId'
import { Editor } from '../../Editor'
import {
GapsSnapIndicator,
PointsSnapIndicator,
SnapData,
SnapIndicator,
SnapManager,
} from './SnapManager'
/** @public */
export interface BoundsSnapPoint {
id: string
x: number
y: number
handle?: SelectionCorner
}
type SnapPair = { thisPoint: BoundsSnapPoint; otherPoint: BoundsSnapPoint }
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: Box
}
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: [Vec, Vec]
endEdge: [Vec, Vec]
length: number
breadthIntersection: [number, number]
}
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<SnapIndicator, { 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
}
}
}
}
export class BoundsSnaps {
readonly editor: Editor
constructor(readonly manager: SnapManager) {
this.editor = manager.editor
}
@computed private getSnapPointsCache() {
const { editor } = this
return editor.store.createComputedCache<BoundsSnapPoint[], 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 } = Mat.applyToPoint(pageTransfrorm, point)
return { x, y, id: `${shape.id}:${i}` }
})
})
}
getSnapPoints(shapeId: TLShapeId) {
return this.getSnapPointsCache().get(shapeId) ?? []
}
// Points which belong to snappable shapes
@computed private getSnappablePoints() {
const snapPointsCache = this.getSnapPointsCache()
const snappableShapes = this.manager.getSnappableShapes()
const result: BoundsSnapPoint[] = []
snappableShapes.forEach((shapeId) => {
const snapPoints = snapPointsCache.get(shapeId)
if (snapPoints) {
result.push(...snapPoints)
}
})
return result
}
@computed private getSnappableGapNodes(): Array<GapNode> {
return Array.from(this.manager.getSnappableShapes(), (shapeId) => ({
id: shapeId,
pageBounds: assertExists(this.editor.getShapePageBounds(shapeId)),
}))
}
@computed private getVisibleGaps(): { horizontal: Gap[]; vertical: Gap[] } {
const horizontal: Gap[] = []
const vertical: Gap[] = []
let startNode: GapNode, endNode: GapNode
const sortedShapesOnCurrentPageHorizontal = this.getSnappableGapNodes().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 Vec(startNode.pageBounds.maxX, startNode.pageBounds.minY),
new Vec(startNode.pageBounds.maxX, startNode.pageBounds.maxY),
],
endEdge: [
new Vec(endNode.pageBounds.minX, endNode.pageBounds.minY),
new Vec(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 Vec(startNode.pageBounds.minX, startNode.pageBounds.maxY),
new Vec(startNode.pageBounds.maxX, startNode.pageBounds.maxY),
],
endEdge: [
new Vec(endNode.pageBounds.minX, endNode.pageBounds.minY),
new Vec(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 }
}
snapTranslateShapes({
lockedAxis,
initialSelectionPageBounds,
initialSelectionSnapPoints,
dragDelta,
}: {
lockedAxis: 'x' | 'y' | null
initialSelectionSnapPoints: BoundsSnapPoint[]
initialSelectionPageBounds: Box
dragDelta: Vec
}): SnapData {
const snapThreshold = this.manager.getSnapThreshold()
const visibleSnapPointsNotInSelection = this.getSnappablePoints()
const selectionPageBounds = initialSelectionPageBounds.clone().translate(dragDelta)
const selectionSnapPoints: BoundsSnapPoint[] = 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 Vec(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 Vec(
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.manager.setIndicators([...gapSnapLines, ...pointSnapsLines])
return { nudge }
}
snapResizeShapes({
initialSelectionPageBounds,
dragDelta,
handle: originalHandle,
isAspectRatioLocked,
isResizingFromCenter,
}: {
// the page bounds when the pointer went down, before any dragging
initialSelectionPageBounds: Box
// how far the pointer has been dragged
dragDelta: Vec
handle: SelectionCorner | SelectionEdge
isAspectRatioLocked: boolean
isResizingFromCenter: boolean
}): SnapData {
const snapThreshold = this.manager.getSnapThreshold()
// first figure out the new bounds of the selection
const {
box: unsnappedResizedPageBounds,
scaleX,
scaleY,
} = Box.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 Vec(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 Vec(
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 = Vec.Add(dragDelta, nudge)
// first figure out the new bounds of the selection
const { box: snappedResizedPageBounds } = Box.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.manager.setIndicators([...pointSnaps])
return { nudge }
}
private collectPointSnaps({
selectionSnapPoints,
otherNodeSnapPoints,
minOffset,
nearestSnapsX,
nearestSnapsY,
}: {
selectionSnapPoints: BoundsSnapPoint[]
otherNodeSnapPoints: BoundsSnapPoint[]
minOffset: Vec
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 = Vec.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: Box
minOffset: Vec
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[]
}): PointsSnapIndicator[] {
// 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) => Vec.From(snap.otherPoint))
// be sure to nudge over the selection snap points
.concat(snapGroup.map((snap) => Vec.From(snap.thisPoint))),
(a: Vec, b: Vec) => a.equals(b)
),
}))
}
private getGapSnapLines({
selectionPageBounds,
nearestSnapsX,
nearestSnapsY,
}: {
selectionPageBounds: Box
nearestSnapsX: NearestSnap[]
nearestSnapsY: NearestSnap[]
}): GapsSnapIndicator[] {
const { vertical, horizontal } = this.getVisibleGaps()
const selectionSides: Record<SelectionEdge, [Vec, Vec]> = {
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: GapsSnapIndicator[] = []
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 [Vec, Vec],
},
{ 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 [Vec, Vec],
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 [Vec, Vec],
},
{ 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 [Vec, Vec],
endEdge: selectionSides.top,
},
],
})
}
break
}
}
}
dedupeGapSnaps(result)
return result
}
}
function getResizeSnapPointsForHandle(
handle: SelectionCorner | SelectionEdge | 'any',
selectionPageBounds: Box
): BoundsSnapPoint[] {
const { minX, maxX, minY, maxY } = selectionPageBounds
const result: BoundsSnapPoint[] = []
// 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
}