tldraw
Version:
A tiny little drawing editor.
399 lines (341 loc) • 10.7 kB
text/typescript
import {
ArrowShapeKindStyle,
atom,
Atom,
Box,
clamp,
Editor,
ElbowArrowSnap,
Geometry2dFilters,
invLerp,
mapObjectMapValues,
objectMapEntries,
objectMapKeys,
TLArrowBinding,
TLArrowShape,
TLArrowShapeKind,
TLShape,
Vec,
VecLike,
WeakCache,
} from '@tldraw/editor'
import { ArrowShapeUtil } from './ArrowShapeUtil'
import {
ElbowArrowAxes,
ElbowArrowSide,
ElbowArrowSideAxes,
ElbowArrowSideDeltas,
} from './elbow/definitions'
/**
* Options passed to {@link updateArrowTargetState}.
*
* @public
*/
export interface UpdateArrowTargetStateOpts {
editor: Editor
pointInPageSpace: VecLike
arrow: TLArrowShape | undefined
isPrecise: boolean
currentBinding: TLArrowBinding | undefined
/** The binding from the opposite end of the arrow, if one exists. */
oppositeBinding: TLArrowBinding | undefined
}
/**
* State representing what we're pointing to when drawing or updating an arrow. You can get this
* state using {@link getArrowTargetState}, and update it as part of an arrow interaction with
* {@link updateArrowTargetState} or {@link clearArrowTargetState}.
*
* @public
*/
export interface ArrowTargetState {
target: TLShape
arrowKind: TLArrowShapeKind
handlesInPageSpace: {
top: { point: VecLike; isEnabled: boolean }
bottom: { point: VecLike; isEnabled: boolean }
left: { point: VecLike; isEnabled: boolean }
right: { point: VecLike; isEnabled: boolean }
}
isExact: boolean
isPrecise: boolean
centerInPageSpace: VecLike
anchorInPageSpace: VecLike
snap: ElbowArrowSnap
normalizedAnchor: VecLike
}
const arrowTargetStore = new WeakCache<Editor, Atom<ArrowTargetState | null>>()
function getArrowTargetAtom(editor: Editor) {
return arrowTargetStore.get(editor, () => atom('arrowTarget', null))
}
/**
* Get the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function getArrowTargetState(editor: Editor) {
return getArrowTargetAtom(editor).get()
}
/**
* Clear the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function clearArrowTargetState(editor: Editor) {
getArrowTargetAtom(editor).set(null)
}
/**
* Update the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function updateArrowTargetState({
editor,
pointInPageSpace,
arrow,
isPrecise,
currentBinding,
oppositeBinding,
}: UpdateArrowTargetStateOpts): ArrowTargetState | null {
const util = editor.getShapeUtil<ArrowShapeUtil>('arrow')
// no target picking when ctrl is held:
if (util.options.shouldIgnoreTargets(editor)) {
getArrowTargetAtom(editor).set(null)
return null
}
const arrowKind = arrow ? arrow.props.kind : editor.getStyleForNextShape(ArrowShapeKindStyle)
const target = editor.getShapeAtPoint(pointInPageSpace, {
hitInside: true,
hitFrameInside: true,
margin: arrowKind === 'elbow' ? 8 : [8, 0],
filter: (targetShape) => {
return (
!targetShape.isLocked &&
editor.canBindShapes({
fromShape: arrow ?? targetFilterFallback,
toShape: targetShape,
binding: 'arrow',
})
)
},
})
if (!target) {
getArrowTargetAtom(editor).set(null)
return null
}
const targetGeometryInTargetSpace = editor.getShapeGeometry(target)
const targetBoundsInTargetSpace = Box.ZeroFix(targetGeometryInTargetSpace.bounds)
const targetCenterInTargetSpace = targetGeometryInTargetSpace.center
const targetTransform = editor.getShapePageTransform(target)
const pointInTargetSpace = editor.getPointInShapeSpace(target, pointInPageSpace)
const castDistance = Math.max(
targetGeometryInTargetSpace.bounds.width,
targetGeometryInTargetSpace.bounds.height
)
const handlesInPageSpace = mapObjectMapValues(ElbowArrowSideDeltas, (side, delta) => {
const axis = ElbowArrowAxes[ElbowArrowSideAxes[side]]
const farPoint = Vec.Mul(delta, castDistance).add(targetCenterInTargetSpace)
let isEnabled = false
let handlePointInTargetSpace: VecLike = axis.v(
targetBoundsInTargetSpace[side],
targetBoundsInTargetSpace[axis.crossMid]
)
let furthestDistance = 0
const intersections = targetGeometryInTargetSpace.intersectLineSegment(
targetCenterInTargetSpace,
farPoint,
Geometry2dFilters.EXCLUDE_NON_STANDARD
)
for (const intersection of intersections) {
const distance = Vec.Dist2(intersection, targetCenterInTargetSpace)
if (distance > furthestDistance) {
furthestDistance = distance
handlePointInTargetSpace = intersection
isEnabled = targetGeometryInTargetSpace.isClosed
}
}
const handlePointInPageSpace = targetTransform.applyToPoint(handlePointInTargetSpace)
return { point: handlePointInPageSpace, isEnabled, far: targetTransform.applyToPoint(farPoint) }
})
const zoomLevel = editor.getZoomLevel()
const minDistScaled = util.options.minElbowHandleDistance / zoomLevel
const targetCenterInPageSpace = targetTransform.applyToPoint(targetCenterInTargetSpace)
for (const side of objectMapKeys(handlesInPageSpace)) {
const handle = handlesInPageSpace[side]
if (Vec.DistMin(handle.point, targetCenterInPageSpace, minDistScaled)) {
handle.isEnabled = false
}
}
let precise = isPrecise
if (!precise) {
// If we're switching to a new bound shape, then precise only if moving slowly
if (!currentBinding || (currentBinding && target.id !== currentBinding.toId)) {
precise = editor.inputs.getPointerVelocity().len() < 0.5
}
}
if (!isPrecise) {
if (!targetGeometryInTargetSpace.isClosed) {
precise = true
}
// Double check that we're not going to be doing an imprecise snap on
// the same shape twice, as this would result in a zero length line
if (oppositeBinding && target.id === oppositeBinding.toId && oppositeBinding.props.isPrecise) {
precise = true
}
}
const isExact = util.options.shouldBeExact(editor, precise)
if (isExact) precise = true
const shouldSnapCenter = !isExact && precise && targetGeometryInTargetSpace.isClosed
// const shouldSnapEdges = !isExact && (precise || !targetGeometryInTargetSpace.isClosed)
const shouldSnapEdges =
!isExact && ((precise && arrowKind === 'elbow') || !targetGeometryInTargetSpace.isClosed)
const shouldSnapEdgePoints =
!isExact && precise && arrowKind === 'elbow' && targetGeometryInTargetSpace.isClosed
const shouldSnapNone = precise && (targetGeometryInTargetSpace.isClosed || isExact)
const shouldSnapCenterAxis =
!isExact && precise && arrowKind === 'elbow' && targetGeometryInTargetSpace.isClosed
// we run through all the snapping options from least to most specific:
let snap: ElbowArrowSnap = 'none'
let anchorInPageSpace: VecLike = pointInPageSpace
if (!shouldSnapNone) {
snap = 'center'
anchorInPageSpace = targetCenterInPageSpace
}
if (shouldSnapEdges) {
const snapDistance = shouldSnapNone
? calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowEdgeSnapDistance
)
: Infinity
const nearestPointOnEdgeInTargetSpace = targetGeometryInTargetSpace.nearestPoint(
pointInTargetSpace,
{
includeLabels: false,
includeInternal: false,
}
)
const nearestPointOnEdgeInPageSpace = targetTransform.applyToPoint(
nearestPointOnEdgeInTargetSpace
)
const distance = Vec.Dist(nearestPointOnEdgeInPageSpace, pointInPageSpace)
if (distance < snapDistance) {
snap = 'edge'
anchorInPageSpace = nearestPointOnEdgeInPageSpace
}
}
if (shouldSnapCenterAxis) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowAxisSnapDistance
)
const distanceFromXAxis = Vec.DistanceToLineSegment(
handlesInPageSpace.left.far,
handlesInPageSpace.right.far,
pointInPageSpace
)
const distanceFromYAxis = Vec.DistanceToLineSegment(
handlesInPageSpace.top.far,
handlesInPageSpace.bottom.far,
pointInPageSpace
)
const snapAxis =
distanceFromXAxis < distanceFromYAxis && distanceFromXAxis < snapDistance
? 'x'
: distanceFromYAxis < snapDistance
? 'y'
: null
if (snapAxis) {
const axis = ElbowArrowAxes[snapAxis]
const loDist2 = Vec.Dist2(handlesInPageSpace[axis.loEdge].far, pointInPageSpace)
const hiDist2 = Vec.Dist2(handlesInPageSpace[axis.hiEdge].far, pointInPageSpace)
const side = loDist2 < hiDist2 ? axis.loEdge : axis.hiEdge
if (handlesInPageSpace[side].isEnabled) {
snap = 'edge-point'
anchorInPageSpace = handlesInPageSpace[side].point
}
}
}
if (shouldSnapEdgePoints) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowPointSnapDistance
)
let closestSide: ElbowArrowSide | null = null
let closestDistance = Infinity
for (const [side, handle] of objectMapEntries(handlesInPageSpace)) {
if (!handle.isEnabled) continue
const distance = Vec.Dist(handle.point, pointInPageSpace)
if (distance < snapDistance && distance < closestDistance) {
closestDistance = distance
closestSide = side
}
}
if (closestSide) {
snap = 'edge-point'
anchorInPageSpace = handlesInPageSpace[closestSide].point
}
}
if (shouldSnapCenter) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
arrowKind === 'elbow'
? util.options.elbowArrowCenterSnapDistance
: util.options.arcArrowCenterSnapDistance
)
if (Vec.Dist(pointInTargetSpace, targetBoundsInTargetSpace.center) < snapDistance) {
snap = 'center'
anchorInPageSpace = targetCenterInPageSpace
}
}
const snapPointInTargetSpace = editor.getPointInShapeSpace(target, anchorInPageSpace)
const normalizedAnchor = {
x: invLerp(
targetBoundsInTargetSpace.minX,
targetBoundsInTargetSpace.maxX,
snapPointInTargetSpace.x
),
y: invLerp(
targetBoundsInTargetSpace.minY,
targetBoundsInTargetSpace.maxY,
snapPointInTargetSpace.y
),
}
const result: ArrowTargetState = {
target,
arrowKind,
handlesInPageSpace,
centerInPageSpace: targetCenterInPageSpace,
anchorInPageSpace,
isExact,
isPrecise: precise,
snap,
normalizedAnchor,
}
getArrowTargetAtom(editor).set(result)
return result
}
const targetFilterFallback = { type: 'arrow' as const }
/**
* Funky math but we want the snap distance to be 4 at the minimum and either 16 or 15% of the
* smaller dimension of the target shape, whichever is smaller
*/
function calculateSnapDistance(
editor: Editor,
targetBoundsInTargetSpace: Box,
idealSnapDistance: number
) {
return (
clamp(
Math.min(targetBoundsInTargetSpace.width, targetBoundsInTargetSpace.height) * 0.15,
4,
idealSnapDistance
) / editor.getZoomLevel()
)
}