Globs

import { useState } from 'react'
import {
	DefaultToolbar,
	DefaultToolbarContent,
	StateNode,
	TLComponents,
	Tldraw,
	TldrawUiButtonIcon,
	TldrawUiMenuItem,
	TldrawUiPopover,
	TldrawUiPopoverContent,
	TldrawUiPopoverTrigger,
	TldrawUiToolbar,
	TldrawUiToolbarButton,
	TLKeyboardEventInfo,
	tlmenus,
	TLPointerEventInfo,
	TLShape,
	TLShapeId,
	TLUiAssetUrlOverrides,
	TLUiOverrides,
	track,
	useEditor,
	useTools,
	useValue,
} from 'tldraw'
import { CustomHandles } from './CustomHandles'
import { GlobBinding, GlobBindingUtil } from './GlobBindingUtil'
import { GlobShape, GlobShapeUtil } from './GlobShapeUtil'
import { GlobTool } from './GlobTool/GlobTool'
import { NodeShape, NodeShapeUtil } from './NodeShapeUtil'

const customAssetUrls: TLUiAssetUrlOverrides = {
	icons: {
		'glob-icon': '/glob-icon.svg',
		'node-icon': '/node-icon.svg',
		'connect-node-icon': '/connect-node.svg',
	},
}

const uiOverrides: TLUiOverrides = {
	tools(editor, tools) {
		tools['glob.node'] = {
			id: 'glob.node',
			icon: 'node-icon',
			label: 'Node',
			kbd: 'n',
			meta: { variant: 'node' },
			onSelect: () => {
				editor.setCurrentTool('glob.node')
			},
		}

		tools['glob.connect'] = {
			id: 'glob.connect',
			icon: 'connect-node-icon',
			label: 'Connect Nodes',
			kbd: 'c',
			meta: { variant: 'connect' },
			onSelect: () => {
				// Only allow connecting if nodes are selected
				const selectedShapes = editor.getSelectedShapes()
				const hasNodesSelected =
					selectedShapes.length > 0 &&
					selectedShapes.every((shape) => editor.isShapeOfType<NodeShape>(shape, 'node'))
				if (hasNodesSelected) {
					editor.setCurrentTool('glob.connect')
				}
			},
		}

		return tools
	},
}

const GlobToolWithPopover = track(() => {
	const tools = useTools()

	const editor = useEditor()
	const [isOpen, setIsOpen] = useState(false)

	const currentGlobTool = useValue(
		'current glob tool',
		() => {
			const tool = editor.getPath()
			if (tool === 'glob.connect') return 'glob.connect'
			return 'glob.node'
		},
		[editor]
	)

	// Check if any nodes are selected
	const hasNodesSelected = useValue(
		'has nodes selected',
		() => {
			const selectedShapes = editor.getSelectedShapes()
			return (
				selectedShapes.length > 0 &&
				selectedShapes.every((shape) => editor.isShapeOfType<NodeShape>(shape, 'node'))
			)
		},
		[editor]
	)

	const isSelected = editor.getPath() === currentGlobTool
	const popoverId = 'glob-tool-popover'

	const handleToolSelect = (id: string) => {
		if (id === 'glob.connect' && !hasNodesSelected) return
		editor.setCurrentTool(id)
		tlmenus.deleteOpenMenu(popoverId, editor.contextId)
		setIsOpen(false)
	}

	return (
		<>
			<TldrawUiPopover id={popoverId} open={isOpen} onOpenChange={setIsOpen}>
				<TldrawUiPopoverTrigger>
					<TldrawUiToolbarButton title="Glob" type="tool">
						<TldrawUiButtonIcon icon="glob-icon" />
					</TldrawUiToolbarButton>
				</TldrawUiPopoverTrigger>
				<TldrawUiPopoverContent side="top" align="center">
					<TldrawUiToolbar label="Glob">
						<TldrawUiToolbarButton
							title="Add Node"
							type="tool"
							onClick={() => handleToolSelect('glob.node')}
						>
							<TldrawUiButtonIcon icon="node-icon" />
						</TldrawUiToolbarButton>
						<TldrawUiToolbarButton
							title="Connect Nodes"
							type="tool"
							onClick={() => handleToolSelect('glob.connect')}
							disabled={!hasNodesSelected}
						>
							<TldrawUiButtonIcon icon="connect-node-icon" />
						</TldrawUiToolbarButton>
					</TldrawUiToolbar>
				</TldrawUiPopoverContent>
				<TldrawUiMenuItem {...tools[currentGlobTool]} isSelected={isSelected} />
			</TldrawUiPopover>
		</>
	)
})

const components: TLComponents = {
	Toolbar: (props) => {
		return (
			<DefaultToolbar {...props}>
				<GlobToolWithPopover />
				<DefaultToolbarContent />
			</DefaultToolbar>
		)
	},
	Handles: CustomHandles,
}

const shapes = [NodeShapeUtil, GlobShapeUtil]
const tools = [GlobTool]
const bindings = [GlobBindingUtil]

export default function GlobsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				onMount={(editor) => {
					editor.updateInstanceState({ isDebugMode: true })

					// Override dragging_handle state to prevent space from interrupting handle dragging
					const draggingHandleState = editor.getStateDescendant<StateNode>('select.dragging_handle')

					if (draggingHandleState) {
						const originalOnKeyDown = draggingHandleState.onKeyDown?.bind(draggingHandleState)

						draggingHandleState.onKeyDown = (info: TLKeyboardEventInfo) => {
							// If space is pressed while dragging a glob handle, disable panning
							const shape = editor.getShape(editor.getOnlySelectedShapeId()!)
							if (
								shape &&
								editor.isShapeOfType<GlobShape>(shape, 'glob') &&
								info.code === 'Space'
							) {
								// Prevent space from activating panning
								editor.inputs.isPanning = false
								editor.inputs.isSpacebarPanning = false
								return
							}

							originalOnKeyDown?.(info)
						}
					}

					// Override pointing_handle state to allow handle dragging with modifier keys, otherwise
					// it starts brushing instead
					const pointingHandleState = editor.getStateDescendant<StateNode>('select.pointing_handle')

					if (!pointingHandleState) {
						throw new Error('SelectTool pointing_handle state not found')
					}

					// Store original handlers with proper binding
					const originalOnPointerMove = pointingHandleState.onPointerMove?.bind(pointingHandleState)

					// Return to idle state after dragging a handle
					pointingHandleState.onPointerMove = (info: TLPointerEventInfo) => {
						if (!info.shape) return

						if (editor.isShapeOfType<GlobShape>(info.shape, 'glob')) {
							editor.updateInstanceState({ isToolLocked: true })
							editor.setCurrentTool('select.dragging_handle', {
								...info,
							})
							return
						}

						originalOnPointerMove?.(info)
					}

					// if we have a just a glob selected, expand the selection to include the nodes it's connected to
					const originalGetContent = editor.getContentFromCurrentPage.bind(editor)
					editor.getContentFromCurrentPage = (shapes) => {
						// Extract shape IDs
						const ids =
							typeof shapes[0] === 'string'
								? (shapes as TLShapeId[])
								: (shapes as TLShape[]).map((s) => s.id)

						// Expand selection to include bound nodes for any globs
						const expandedIds = new Set(ids)

						for (const id of ids) {
							const shape = editor.getShape(id)
							if (shape && editor.isShapeOfType<GlobShape>(shape, 'glob')) {
								const bindings = editor.getBindingsFromShape<GlobBinding>(id, 'glob')
								for (const binding of bindings) {
									expandedIds.add(binding.toId)
								}
							}
						}

						// Call original with expanded selection
						return originalGetContent(Array.from(expandedIds))
					}
				}}
				shapeUtils={shapes}
				tools={tools}
				bindingUtils={bindings}
				overrides={uiOverrides}
				assetUrls={customAssetUrls}
				components={components}
			/>
		</div>
	)
}
Is this page helpful?
Prev
Custom shape wrapper
Next
Custom shape