Pin (bindings)

A pin, using bindings to pin together networks of shapes.

import {
	BindingOnShapeChangeOptions,
	BindingOnShapeDeleteOptions,
	BindingUtil,
	Box,
	DefaultFillStyle,
	DefaultToolbar,
	DefaultToolbarContent,
	RecordProps,
	Rectangle2d,
	ShapeUtil,
	StateNode,
	TLBinding,
	TLEditorComponents,
	TLPointerEventInfo,
	TLShape,
	TLShapeId,
	TLShapePartial,
	TLShapeUtilCanBindOpts,
	TLUiComponents,
	TLUiOverrides,
	Tldraw,
	TldrawUiMenuItem,
	Vec,
	VecModel,
	createShapeId,
	invLerp,
	lerp,
	useIsToolSelected,
	useTools,
} from 'tldraw'
import 'tldraw/tldraw.css'

const PIN_TYPE = 'pin'

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[PIN_TYPE]: Record<string, never>
	}
}

type PinShape = TLShape<typeof PIN_TYPE>

const offsetX = -16
const offsetY = -26
class PinShapeUtil extends ShapeUtil<PinShape> {
	static override type = PIN_TYPE
	static override props: RecordProps<PinShape> = {}

	override getDefaultProps() {
		return {}
	}

	override canBind({ toShapeType, bindingType }: TLShapeUtilCanBindOpts<PinShape>) {
		if (bindingType === PIN_TYPE) {
			// pins cannot bind to other pins!
			return toShapeType !== PIN_TYPE
		}
		// Allow pins to participate in other bindings, e.g. arrows
		return true
	}
	override canEdit() {
		return false
	}
	override canResize() {
		return false
	}
	override hideRotateHandle() {
		return true
	}
	override isAspectRatioLocked() {
		return true
	}

	override getGeometry() {
		return new Rectangle2d({
			width: 32,
			height: 32,
			x: offsetX,
			y: offsetY,
			isFilled: true,
		})
	}

	override component() {
		return (
			<div
				style={{
					width: '100%',
					height: '100%',
					marginLeft: offsetX,
					marginTop: offsetY,
					fontSize: '26px',
					textAlign: 'center',
				}}
			>
				📍
			</div>
		)
	}

	override indicator() {
		return <rect width={32} height={32} x={offsetX} y={offsetY} />
	}

	override onTranslateStart(shape: PinShape) {
		const bindings = this.editor.getBindingsFromShape(shape, PIN_TYPE)
		this.editor.deleteBindings(bindings)
	}

	override onTranslateEnd(_initial: PinShape, pin: PinShape) {
		const pageAnchor = this.editor.getShapePageTransform(pin).applyToPoint({ x: 0, y: 0 })

		const targets = this.editor
			.getShapesAtPoint(pageAnchor, { hitInside: true })
			.filter(
				(shape) =>
					this.editor.canBindShapes({ fromShape: pin, toShape: shape, binding: PIN_TYPE }) &&
					shape.parentId === pin.parentId &&
					shape.index < pin.index
			)

		for (const target of targets) {
			const targetBounds = Box.ZeroFix(this.editor.getShapeGeometry(target)!.bounds)
			const pointInTargetSpace = this.editor.getPointInShapeSpace(target, pageAnchor)

			const anchor = {
				x: invLerp(targetBounds.minX, targetBounds.maxX, pointInTargetSpace.x),
				y: invLerp(targetBounds.minY, targetBounds.maxY, pointInTargetSpace.y),
			}

			this.editor.createBinding({
				type: PIN_TYPE,
				fromId: pin.id,
				toId: target.id,
				props: {
					anchor,
				},
			})
		}
	}
}

declare module 'tldraw' {
	export interface TLGlobalBindingPropsMap {
		[PIN_TYPE]: {
			anchor: VecModel
		}
	}
}

type PinBinding = TLBinding<typeof PIN_TYPE>

class PinBindingUtil extends BindingUtil<PinBinding> {
	static override type = PIN_TYPE

	override getDefaultProps() {
		return {
			anchor: { x: 0.5, y: 0.5 },
		}
	}

	private changedToShapes = new Set<TLShapeId>()

	override onOperationComplete(): void {
		if (this.changedToShapes.size === 0) return

		const fixedShapes = this.changedToShapes
		const toCheck = [...this.changedToShapes]

		const initialPositions = new Map<TLShapeId, VecModel>()
		const targetDeltas = new Map<TLShapeId, Map<TLShapeId, VecModel>>()

		const addTargetDelta = (fromId: TLShapeId, toId: TLShapeId, delta: VecModel) => {
			if (!targetDeltas.has(fromId)) targetDeltas.set(fromId, new Map())
			targetDeltas.get(fromId)!.set(toId, delta)

			if (!targetDeltas.has(toId)) targetDeltas.set(toId, new Map())
			targetDeltas.get(toId)!.set(fromId, { x: -delta.x, y: -delta.y })
		}

		const allShapes = new Set<TLShapeId>()
		while (toCheck.length) {
			const shapeId = toCheck.pop()!

			const shape = this.editor.getShape(shapeId)
			if (!shape) continue

			if (allShapes.has(shapeId)) continue
			allShapes.add(shapeId)

			const bindings = this.editor.getBindingsToShape(shape, PIN_TYPE)
			for (const binding of bindings) {
				if (allShapes.has(binding.fromId)) continue
				allShapes.add(binding.fromId)

				const pin = this.editor.getShape<PinShape>(binding.fromId)
				if (!pin) continue

				const pinPosition = this.editor.getShapePageTransform(pin).applyToPoint({ x: 0, y: 0 })
				initialPositions.set(pin.id, pinPosition)

				for (const binding of this.editor.getBindingsFromShape(pin.id, PIN_TYPE)) {
					const shapeBounds = this.editor.getShapeGeometry(binding.toId)!.bounds
					const shapeAnchor = {
						x: lerp(shapeBounds.minX, shapeBounds.maxX, binding.props.anchor.x),
						y: lerp(shapeBounds.minY, shapeBounds.maxY, binding.props.anchor.y),
					}
					const currentPageAnchor = this.editor
						.getShapePageTransform(binding.toId)
						.applyToPoint(shapeAnchor)

					const shapeOrigin = this.editor
						.getShapePageTransform(binding.toId)
						.applyToPoint({ x: 0, y: 0 })
					initialPositions.set(binding.toId, shapeOrigin)

					addTargetDelta(pin.id, binding.toId, {
						x: currentPageAnchor.x - shapeOrigin.x,
						y: currentPageAnchor.y - shapeOrigin.y,
					})

					if (!allShapes.has(binding.toId)) toCheck.push(binding.toId)
				}
			}
		}

		const currentPositions = new Map(initialPositions)

		const iterations = 30
		for (let i = 0; i < iterations; i++) {
			const movements = new Map<TLShapeId, VecModel[]>()
			for (const [aId, deltas] of targetDeltas) {
				if (fixedShapes.has(aId)) continue
				const aPosition = currentPositions.get(aId)!
				for (const [bId, targetDelta] of deltas) {
					const bPosition = currentPositions.get(bId)!

					const adjustmentDelta = {
						x: targetDelta.x - (aPosition.x - bPosition.x),
						y: targetDelta.y - (aPosition.y - bPosition.y),
					}

					if (!movements.has(aId)) movements.set(aId, [])
					movements.get(aId)!.push(adjustmentDelta)
				}
			}

			for (const [shapeId, deltas] of movements) {
				const currentPosition = currentPositions.get(shapeId)!
				currentPositions.set(shapeId, Vec.Average(deltas).add(currentPosition))
			}
		}

		const updates: TLShapePartial[] = []
		for (const [shapeId, position] of currentPositions) {
			const delta = Vec.Sub(position, initialPositions.get(shapeId)!)
			if (delta.len2() <= 0.01) continue

			const newPosition = this.editor.getPointInParentSpace(shapeId, position)
			updates.push({
				...this.editor.getShape(shapeId)!,
				id: shapeId,
				x: newPosition.x,
				y: newPosition.y,
			})
		}

		if (updates.length === 0) {
			this.changedToShapes.clear()
		} else {
			this.editor.updateShapes(updates)
		}
	}

	// when the shape we're stuck to changes, update the pin's position
	override onAfterChangeToShape({
		binding,
		shapeAfter,
	}: BindingOnShapeChangeOptions<PinBinding>): void {
		this.changedToShapes.add(binding.toId)
		const pin = this.editor.getShape(binding.fromId)
		if (!pin) return

		// If the bound shape changed parents, reparent the pin to follow
		if (pin.parentId !== shapeAfter.parentId) {
			this.editor.reparentShapes([pin.id], shapeAfter.parentId)
		}
	}

	// when the thing we're stuck to is deleted, delete the pin too
	override onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions<PinBinding>): void {
		this.editor.deleteShape(binding.fromId)
	}
}

class PinTool extends StateNode {
	static override id = PIN_TYPE

	override onEnter() {
		this.editor.setCursor({ type: 'cross', rotation: 0 })
	}

	override onPointerDown(info: TLPointerEventInfo) {
		const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
		const pinId = createShapeId()
		this.editor.markHistoryStoppingPoint()
		this.editor.createShape({
			id: pinId,
			type: PIN_TYPE,
			x: currentPagePoint.x,
			y: currentPagePoint.y,
		})
		this.editor.setSelectedShapes([pinId])
		this.editor.setCurrentTool('select.translating', {
			...info,
			target: 'shape',
			shape: this.editor.getShape(pinId),
			isCreating: true,
			onInteractionEnd: 'pin',
			onCreate: () => {
				this.editor.setCurrentTool('pin')
			},
		})
	}
}

const overrides: TLUiOverrides = {
	tools(editor, schema) {
		schema['pin'] = {
			id: 'pin',
			label: 'Pin',
			icon: 'heart-icon',
			kbd: 'p',
			onSelect: () => {
				editor.setCurrentTool('pin')
			},
		}
		return schema
	},
}

const components: TLUiComponents & TLEditorComponents = {
	Toolbar: (...props) => {
		const pin = useTools().pin
		const isPinSelected = useIsToolSelected(pin)
		return (
			<DefaultToolbar {...props}>
				<TldrawUiMenuItem {...pin} isSelected={isPinSelected} />
				<DefaultToolbarContent />
			</DefaultToolbar>
		)
	},
}

export default function PinExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				persistenceKey="pin-example"
				onMount={(editor) => {
					;(window as any).editor = editor
					editor.setStyleForNextShapes(DefaultFillStyle, 'semi')
				}}
				shapeUtils={[PinShapeUtil]}
				bindingUtils={[PinBindingUtil]}
				tools={[PinTool]}
				overrides={overrides}
				components={components}
			/>
		</div>
	)
}

This example shows how to use bindings to connect a network of shapes together.

To try it out, select the pin tool, then click over two overlapping shapes to pin them together.

Is this page helpful?
Prev
Layout constraints (bindings)
Next
Rich text with custom extension and toolbar