UNPKG

tldraw

Version:

A tiny little drawing editor.

399 lines (341 loc) • 10.7 kB
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() ) }