UNPKG

tldraw

Version:

A tiny little drawing editor.

285 lines (256 loc) • 7.9 kB
import { Editor, Geometry2d, Mat, TLArrowBinding, TLArrowBindingProps, TLArrowShape, TLShape, TLShapeId, Vec, } from '@tldraw/editor' import { createComputedCache } from '@tldraw/store' import { TLArrowInfo } from './arrow-types' import { getCurvedArrowInfo } from './curved-arrow' import { getElbowArrowInfo } from './elbow/getElbowArrowInfo' import { getStraightArrowInfo } from './straight-arrow' const MIN_ARROW_BEND = 8 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<TLArrowBinding>(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 const shapePoint = Vec.Add( point, Vec.MulV( // if the parent is the bound shape, then it's ALWAYS precise binding.props.isPrecise || forceImprecise ? binding.props.normalizedAnchor : { x: 0.5, y: 0.5 }, 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 } /** @public */ export function getArrowBindings(editor: Editor, shape: TLArrowShape): TLArrowBindings { const bindings = editor.getBindingsFromShape<TLArrowBinding>(shape, 'arrow') return { start: bindings.find((b) => b.props.terminal === 'start'), end: bindings.find((b) => b.props.terminal === 'end'), } } const arrowInfoCache = createComputedCache( 'arrow info', (editor: Editor, shape: TLArrowShape): TLArrowInfo => { const bindings = getArrowBindings(editor, shape) if (shape.props.kind === 'elbow') { const elbowInfo = getElbowArrowInfo(editor, shape, bindings) if (!elbowInfo?.route) return getStraightArrowInfo(editor, shape, bindings) const start = elbowInfo.swapOrder ? elbowInfo.B : elbowInfo.A const end = elbowInfo.swapOrder ? elbowInfo.A : elbowInfo.B return { type: 'elbow', bindings, start: { handle: start.target, point: elbowInfo.route.points[0], arrowhead: shape.props.arrowheadStart, }, end: { handle: end.target, point: elbowInfo.route.points[elbowInfo.route.points.length - 1], arrowhead: shape.props.arrowheadEnd, }, elbow: elbowInfo, route: elbowInfo.route, isValid: true, } } return getIsArrowStraight(shape) ? getStraightArrowInfo(editor, shape, bindings) : getCurvedArrowInfo(editor, shape, bindings) } ) /** @public */ export function getArrowInfo(editor: Editor, shape: TLArrowShape | TLShapeId) { const id = typeof shape === 'string' ? shape : shape.id return arrowInfoCache.get(editor, 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<TLArrowBinding>(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<TLArrowBinding>(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' }