UNPKG

tldraw

Version:

A tiny little drawing editor.

311 lines (276 loc) • 9.17 kB
import { Editor, Geometry2d, intersectLineSegmentPolygon, Mat, MatModel, pointInPolygon, TLArrowBinding, TLArrowBindingProps, TLArrowShape, TLShape, TLShapeId, Vec, } from '@tldraw/editor' import { createComputedCache } from '@tldraw/store' const MIN_ARROW_BEND = 8 // Keep anchors off exact edges/corners to avoid degenerate arrow intersections. const NORMALIZED_ANCHOR_EPSILON = 1e-3 function clampNormalizedAnchor(anchor: { x: number; y: number }) { const clamp = (v: number) => Math.max(NORMALIZED_ANCHOR_EPSILON, Math.min(1 - NORMALIZED_ANCHOR_EPSILON, v)) return { x: clamp(anchor.x), y: clamp(anchor.y) } } export function getIsArrowStraight(shape: TLArrowShape) { if (shape.props.kind !== 'arc') return false return Math.abs(shape.props.bend) < MIN_ARROW_BEND * shape.props.scale // snap to +-8px } export interface BoundShapeInfo<T extends TLShape = TLShape> { shape: T didIntersect: boolean isExact: boolean isClosed: boolean transform: Mat geometry: Geometry2d } export function getBoundShapeInfoForTerminal( editor: Editor, arrow: TLArrowShape, terminalName: 'start' | 'end' ): BoundShapeInfo | undefined { const binding = editor .getBindingsFromShape(arrow, 'arrow') .find((b) => b.props.terminal === terminalName) if (!binding) return const boundShape = editor.getShape(binding.toId)! if (!boundShape) return const transform = editor.getShapePageTransform(boundShape)! const hasArrowhead = terminalName === 'start' ? arrow.props.arrowheadStart !== 'none' : arrow.props.arrowheadEnd !== 'none' const geometry = editor.getShapeGeometry( boundShape, hasArrowhead ? undefined : { context: '@tldraw/arrow-without-arrowhead' } ) return { shape: boundShape, transform, isClosed: geometry.isClosed, isExact: binding.props.isExact, didIntersect: false, geometry, } } export function getArrowTerminalInArrowSpace( editor: Editor, arrowPageTransform: Mat, binding: TLArrowBinding, forceImprecise: boolean ) { const boundShape = editor.getShape(binding.toId) if (!boundShape) { // this can happen in multiplayer contexts where the shape is being deleted return new Vec(0, 0) } else { // Find the actual local point of the normalized terminal on // the bound shape and transform it to page space, then transform // it to arrow space const { point, size } = editor.getShapeGeometry(boundShape).bounds // If the parent is the bound shape, then it's always treated as precise. const shouldUsePreciseAnchor = binding.props.isPrecise || forceImprecise const normalizedAnchor = shouldUsePreciseAnchor ? clampNormalizedAnchor(binding.props.normalizedAnchor) : { x: 0.5, y: 0.5 } const shapePoint = Vec.Add(point, Vec.MulV(normalizedAnchor, size)) const pagePoint = Mat.applyToPoint(editor.getShapePageTransform(boundShape)!, shapePoint) const arrowPoint = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), pagePoint) return arrowPoint } } /** @public */ export interface TLArrowBindings { start: TLArrowBinding | undefined end: TLArrowBinding | undefined } const arrowBindingsCache = createComputedCache( 'arrow bindings', (editor: Editor, arrow: TLArrowShape) => { const bindings = editor.getBindingsFromShape(arrow.id, 'arrow') return { start: bindings.find((b) => b.props.terminal === 'start'), end: bindings.find((b) => b.props.terminal === 'end'), } }, { // we only look at the arrow IDs: areRecordsEqual: (a, b) => a.id === b.id, // the records should stay the same: areResultsEqual: (a, b) => a.start === b.start && a.end === b.end, } ) /** @public */ export function getArrowBindings(editor: Editor, shape: TLArrowShape): TLArrowBindings { return arrowBindingsCache.get(editor, shape.id)! } /** @public */ export function getArrowTerminalsInArrowSpace( editor: Editor, shape: TLArrowShape, bindings: TLArrowBindings ) { const arrowPageTransform = editor.getShapePageTransform(shape)! const boundShapeRelationships = getBoundShapeRelationships( editor, bindings.start?.toId, bindings.end?.toId ) const start = bindings.start ? getArrowTerminalInArrowSpace( editor, arrowPageTransform, bindings.start, boundShapeRelationships === 'double-bound' || boundShapeRelationships === 'start-contains-end' ) : Vec.From(shape.props.start) const end = bindings.end ? getArrowTerminalInArrowSpace( editor, arrowPageTransform, bindings.end, boundShapeRelationships === 'double-bound' || boundShapeRelationships === 'end-contains-start' ) : Vec.From(shape.props.end) return { start, end } } /** * Create or update the arrow binding for a particular arrow terminal. Will clear up if needed. * @internal */ export function createOrUpdateArrowBinding( editor: Editor, arrow: TLArrowShape | TLShapeId, target: TLShape | TLShapeId, props: TLArrowBindingProps ) { const arrowId = typeof arrow === 'string' ? arrow : arrow.id const targetId = typeof target === 'string' ? target : target.id const existingMany = editor .getBindingsFromShape(arrowId, 'arrow') .filter((b) => b.props.terminal === props.terminal) // if we've somehow ended up with too many bindings, delete the extras if (existingMany.length > 1) { editor.deleteBindings(existingMany.slice(1)) } const existing = existingMany[0] if (existing) { editor.updateBinding({ ...existing, toId: targetId, props, }) } else { editor.createBinding({ type: 'arrow', fromId: arrowId, toId: targetId, props, }) } } /** * Remove any arrow bindings for a particular terminal. * @internal */ export function removeArrowBinding(editor: Editor, arrow: TLArrowShape, terminal: 'start' | 'end') { const existing = editor .getBindingsFromShape(arrow, 'arrow') .filter((b) => b.props.terminal === terminal) editor.deleteBindings(existing) } /** @internal */ export const MIN_ARROW_LENGTH = 10 /** @internal */ export const BOUND_ARROW_OFFSET = 10 /** @internal */ export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10 /** @public */ export const STROKE_SIZES: Record<string, number> = { s: 2, m: 3.5, l: 5, xl: 10, } /** * Get the relationships for an arrow that has two bound shape terminals. * If the arrow has only one bound shape, then it is always "safe" to apply * standard offsets and precision behavior. If the shape is bound to the same * shape on both ends, then that is an exception. If one of the shape's * terminals is bound to a shape that contains / is contained by the shape that * is bound to the other terminal, then that is also an exception. * * @param editor - the editor instance * @param startShapeId - the bound shape from the arrow's start * @param endShapeId - the bound shape from the arrow's end * * @internal */ export function getBoundShapeRelationships( editor: Editor, startShapeId?: TLShapeId, endShapeId?: TLShapeId ) { if (!startShapeId || !endShapeId) return 'safe' if (startShapeId === endShapeId) return 'double-bound' const startBounds = editor.getShapePageBounds(startShapeId) const endBounds = editor.getShapePageBounds(endShapeId) if (startBounds && endBounds) { if (startBounds.contains(endBounds)) return 'start-contains-end' if (endBounds.contains(startBounds)) return 'end-contains-start' } return 'safe' } /** * If the arrow terminal point falls outside the bound shape's mask (e.g. when a shape * extends beyond a frame boundary and is clipped), clamp the terminal to the mask boundary. * Uses the binding anchor point (inside the shape/frame) as the ray origin, since the * arrow endpoint may be entirely outside the mask. * * @internal */ export function clampArrowTerminalToMask( editor: Editor, point: Vec, terminalHandle: Vec, arrowPageTransform: MatModel, targetShapeInfo?: BoundShapeInfo ) { if (!targetShapeInfo) return const mask = editor.getShapeMask(targetShapeInfo.shape.id) if (!mask) return const pagePoint = Mat.applyToPoint(arrowPageTransform, point) if (pointInPolygon(pagePoint, mask)) return // The point is outside the mask (clipped). Cast a ray from the binding // anchor (which is inside the shape, and typically inside the frame) through // the intersection point on the shape boundary to find where it crosses the mask. // We extend the line slightly past the anchor in case the anchor sits exactly // on the mask boundary. const pageAnchor = Mat.applyToPoint(arrowPageTransform, terminalHandle) const direction = Vec.Sub(pageAnchor, pagePoint).uni() const extendedAnchor = Vec.Add(pageAnchor, Vec.Mul(direction, 1)) const intersections = intersectLineSegmentPolygon(extendedAnchor, pagePoint, mask) if (!intersections || intersections.length === 0) return // Pick the intersection closest to the original point (nearest frame edge to the shape) let closest = intersections[0] let closestDist = Vec.Dist2(closest, pagePoint) for (let i = 1; i < intersections.length; i++) { const dist = Vec.Dist2(intersections[i], pagePoint) if (dist < closestDist) { closest = intersections[i] closestDist = dist } } const arrowPoint = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), closest) point.setTo(arrowPoint) }