snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import {
DefaultActionsMenu,
DefaultQuickActions,
DefaultStylePanel,
TLComponents,
Tldraw,
TldrawOptions,
TldrawUiToolbar,
useEditor,
useValue,
} from 'tldraw'
import { overrides, WorkflowToolbar } from './components/WorkflowToolbar.tsx'
import { ConnectionBindingUtil } from './connection/ConnectionBindingUtil.tsx'
import { ConnectionCenterHandleOverlayUtil } from './connection/ConnectionCenterHandleOverlayUtil.tsx'
import { ConnectionShapeUtil } from './connection/ConnectionShapeUtil.tsx'
import { keepConnectionsAtBottom } from './connection/keepConnectionsAtBottom.tsx'
import { disableTransparency } from './disableTransparency.tsx'
import { NodeShapeUtil } from './nodes/NodeShapeUtil.tsx'
import { PointingPort } from './ports/PointingPort.tsx'
// Define custom shape utilities that extend tldraw's shape system
const shapeUtils = [NodeShapeUtil, ConnectionShapeUtil]
// Define binding utilities that handle relationships between shapes
const bindingUtils = [ConnectionBindingUtil]
// Canvas overlays — adds the "+" insert handle at the midpoint of each connection
const overlayUtils = [ConnectionCenterHandleOverlayUtil]
// Customize tldraw's UI components to add workflow-specific functionality
const components: TLComponents = {
Toolbar: () => (
<>
<WorkflowToolbar />
<div className="tlui-main-toolbar tlui-main-toolbar--horizontal">
<TldrawUiToolbar className="tlui-main-toolbar__tools" label="Actions">
<DefaultQuickActions />
<DefaultActionsMenu />
</TldrawUiToolbar>
</div>
</>
),
MenuPanel: () => null,
StylePanel: () => {
const editor = useEditor()
const shouldShowStylePanel = useValue(
'shouldShowStylePanel',
() =>
!editor.isIn('select') ||
editor.getSelectedShapes().some((s) => s.type !== 'node' && s.type !== 'connection'),
[editor]
)
if (!shouldShowStylePanel) return
return <DefaultStylePanel />
},
}
const options: Partial<TldrawOptions> = {
actionShortcutsLocation: 'menu',
maxPages: 1,
}
function App() {
return (
<div className="workflow" style={{ position: 'fixed', inset: 0 }}>
<Tldraw
persistenceKey="workflow"
options={options}
overrides={overrides}
shapeUtils={shapeUtils}
bindingUtils={bindingUtils}
overlayUtils={overlayUtils}
components={components}
onMount={(editor) => {
;(window as any).editor = editor
if (!editor.getCurrentPageShapes().some((s) => s.type === 'node')) {
editor.createShape({ type: 'node', x: 200, y: 200 })
}
editor.user.updateUserPreferences({ isSnapMode: true })
// Add our custom pointing port tool to the select tool's state machine
// This allows users to create connections by pointing at ports
editor.getStateDescendant('select')!.addChild(PointingPort)
// todo: move connections to on the canvas layer
// Ensure connections always stay at the bottom of the shape stack
// This prevents them from covering other shapes
keepConnectionsAtBottom(editor)
// Disable transparency for workflow shapes
disableTransparency(editor, ['node', 'connection'])
}}
/>
</div>
)
}
export default App

View File

@@ -0,0 +1,131 @@
import {
AssetToolbarItem,
CheckBoxToolbarItem,
CloudToolbarItem,
createShapeId,
DefaultToolbar,
DiamondToolbarItem,
DrawToolbarItem,
Editor,
EllipseToolbarItem,
HandToolbarItem,
HeartToolbarItem,
HexagonToolbarItem,
HighlightToolbarItem,
LaserToolbarItem,
NoteToolbarItem,
onDragFromToolbarToCreateShape,
OvalToolbarItem,
RectangleToolbarItem,
RhombusToolbarItem,
SelectToolbarItem,
StarToolbarItem,
TextToolbarItem,
TldrawUiMenuGroup,
TLShapeId,
TLUiOverrides,
ToolbarItem,
TriangleToolbarItem,
Vec,
XBoxToolbarItem,
} from 'tldraw'
import { NodeShape } from '../nodes/NodeShapeUtil'
import { getNodeDefinitions, NodeType } from '../nodes/nodeTypes'
function createNodeShape(editor: Editor, shapeId: TLShapeId, center: Vec, node: NodeType) {
// Mark a history stopping point for undo/redo
editor.markHistoryStoppingPoint('create node')
editor.run(() => {
// Create the shape with the node definition
editor.createShape({
id: shapeId,
type: 'node',
props: { node },
})
// Get the created shape and its bounds
const shape = editor.getShape<NodeShape>(shapeId)!
const shapeBounds = editor.getShapePageBounds(shapeId)!
// Position the shape so its center aligns with the drop point
editor.updateShape({
...shape,
x: center.x - shapeBounds.width / 2,
y: center.y - shapeBounds.height / 2,
})
// Select the newly created shape
editor.select(shapeId)
})
}
export const overrides: TLUiOverrides = {
tools: (editor, tools, _) => {
for (const nodeDef of Object.values(getNodeDefinitions(editor))) {
tools[`node-${nodeDef.type}`] = {
id: `node-${nodeDef.type}`,
label: nodeDef.title,
icon: nodeDef.icon,
onSelect: () => {
createNodeShape(
editor,
createShapeId(),
editor.getViewportPageBounds().center,
nodeDef.getDefault()
)
},
onDragStart: (_, info) => {
onDragFromToolbarToCreateShape(editor, info, {
createShape: (id) => {
editor.createShape({
id,
type: 'node',
props: { node: nodeDef.getDefault() },
})
},
onDragEnd: () => {},
})
},
}
}
return tools
},
}
export function WorkflowToolbar() {
return (
<DefaultToolbar orientation="vertical" maxItems={7}>
<TldrawUiMenuGroup id="selection">
<SelectToolbarItem />
<HandToolbarItem />
<DrawToolbarItem />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="nodes">
<ToolbarItem tool="node-message" />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="shapes">
<RectangleToolbarItem />
<EllipseToolbarItem />
<TriangleToolbarItem />
<NoteToolbarItem />
<DiamondToolbarItem />
<HexagonToolbarItem />
<OvalToolbarItem />
<RhombusToolbarItem />
<StarToolbarItem />
<CloudToolbarItem />
<HeartToolbarItem />
<XBoxToolbarItem />
<CheckBoxToolbarItem />
<TextToolbarItem />
<AssetToolbarItem />
<HighlightToolbarItem />
<LaserToolbarItem />
</TldrawUiMenuGroup>
</DefaultToolbar>
)
}

View File

@@ -0,0 +1,12 @@
export function HandleIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 4.625C6.12132 4.625 6.625 4.12132 6.625 3.5C6.625 2.87868 6.12132 2.375 5.5 2.375C4.87868 2.375 4.375 2.87868 4.375 3.5C4.375 4.12132 4.87868 4.625 5.5 4.625ZM9.5 4.625C10.1213 4.625 10.625 4.12132 10.625 3.5C10.625 2.87868 10.1213 2.375 9.5 2.375C8.87868 2.375 8.375 2.87868 8.375 3.5C8.375 4.12132 8.87868 4.625 9.5 4.625ZM10.625 7.5C10.625 8.12132 10.1213 8.625 9.5 8.625C8.87868 8.625 8.375 8.12132 8.375 7.5C8.375 6.87868 8.87868 6.375 9.5 6.375C10.1213 6.375 10.625 6.87868 10.625 7.5ZM5.5 8.625C6.12132 8.625 6.625 8.12132 6.625 7.5C6.625 6.87868 6.12132 6.375 5.5 6.375C4.87868 6.375 4.375 6.87868 4.375 7.5C4.375 8.12132 4.87868 8.625 5.5 8.625ZM10.625 11.5C10.625 12.1213 10.1213 12.625 9.5 12.625C8.87868 12.625 8.375 12.1213 8.375 11.5C8.375 10.8787 8.87868 10.375 9.5 10.375C10.1213 10.375 10.625 10.8787 10.625 11.5ZM5.5 12.625C6.12132 12.625 6.625 12.1213 6.625 11.5C6.625 10.8787 6.12132 10.375 5.5 10.375C4.87868 10.375 4.375 10.8787 4.375 11.5C4.375 12.1213 4.87868 12.625 5.5 12.625Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
)
}

View File

@@ -0,0 +1,12 @@
export function SendIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.20308 1.04312C1.00481 0.954998 0.772341 1.0048 0.627577 1.16641C0.482813 1.32802 0.458794 1.56455 0.568117 1.75196L3.92115 7.50002L0.568117 13.2481C0.458794 13.4355 0.482813 13.672 0.627577 13.8336C0.772341 13.9952 1.00481 14.045 1.20308 13.9569L14.7031 7.95693C14.8836 7.87668 15 7.69762 15 7.50002C15 7.30243 14.8836 7.12337 14.7031 7.04312L1.20308 1.04312ZM4.84553 7.10002L2.21234 2.586L13.2689 7.50002L2.21234 12.414L4.84552 7.90002H9C9.22092 7.90002 9.4 7.72094 9.4 7.50002C9.4 7.27911 9.22092 7.10002 9 7.10002H4.84553Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
)
}

View File

@@ -0,0 +1,196 @@
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeDeleteOptions,
BindingOnShapeIsolateOptions,
BindingUtil,
createComputedCache,
Editor,
T,
TLBinding,
TLShapeId,
} from 'tldraw'
import { getNodePorts } from '../nodes/nodePorts'
import { NodeShape } from '../nodes/NodeShapeUtil'
import { onNodePortConnect, onNodePortDisconnect } from '../nodes/nodeTypes'
import { PortId } from '../ports/Port'
import { ConnectionShape } from './ConnectionShapeUtil'
const CONNECTION_TYPE = 'connection'
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
[CONNECTION_TYPE]: {
portId: PortId
terminal: 'start' | 'end'
}
}
}
/**
* A connection binding is a binding between a connection shape and a node shape. Usually, each
* connection shape has two bindings: one for each end of the connection.
*/
export type ConnectionBinding = TLBinding<typeof CONNECTION_TYPE>
export class ConnectionBindingUtil extends BindingUtil<ConnectionBinding> {
static override type = CONNECTION_TYPE
static override props = {
portId: T.string,
terminal: T.literalEnum('start', 'end'),
}
override getDefaultProps() {
return {}
}
// A connection can't outlive either of the nodes it's bound to
onBeforeIsolateToShape({ binding }: BindingOnShapeIsolateOptions<ConnectionBinding>): void {
// When we're duplicating a node but not its connection, delete the connection
this.editor.deleteShapes([binding.fromId])
}
onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions<ConnectionBinding>): void {
// When we're deleting a node, delete any connections that are bound to it
this.editor.deleteShapes([binding.fromId])
}
onAfterCreate({ binding }: BindingOnCreateOptions<ConnectionBinding>): void {
// Our ports system has an `onConnect` callback - call it when we create a connection
const node = this.getBoundNode(binding)
if (node) onNodePortConnect(this.editor, node, binding.props.portId)
}
onAfterChange({ bindingBefore, bindingAfter }: BindingOnChangeOptions<ConnectionBinding>): void {
// We also might need to call the connection callbacks if we change the thing this connection is binding to.
if (
bindingBefore.props.portId === bindingAfter.props.portId &&
bindingBefore.toId === bindingAfter.toId
) {
return
}
const nodeBefore = this.getBoundNode(bindingBefore)
const nodeAfter = this.getBoundNode(bindingAfter)
if (!nodeBefore || !nodeAfter) return
onNodePortDisconnect(this.editor, nodeBefore, bindingBefore.props.portId)
onNodePortConnect(this.editor, nodeAfter, bindingAfter.props.portId)
}
onAfterDelete({ binding }: BindingOnDeleteOptions<ConnectionBinding>): void {
// When we're deleting a connection, we need to call the node's port disconnect callback
const node = this.getBoundNode(binding)
if (node) onNodePortDisconnect(this.editor, node, binding.props.portId)
}
private getBoundNode(binding: ConnectionBinding): NodeShape | undefined {
const node = this.editor.getShape(binding.toId)
return node && this.editor.isShapeOfType(node, 'node') ? node : undefined
}
}
/** The bindings associated with a specific connection shape. */
export interface ConnectionBindings {
start?: ConnectionBinding
end?: ConnectionBinding
}
/** Get the bindings associated with a specific connection shape. */
export function getConnectionBindings(
editor: Editor,
shape: ConnectionShape | TLShapeId
): ConnectionBindings {
// we cache the bindings so we don't have to recompute them every time - this function gets called a lot.
return connectionBindingsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const connectionBindingsCache = createComputedCache(
'connection bindings',
(editor: Editor, connection: ConnectionShape) => {
const bindings = editor.getBindingsFromShape(connection.id, CONNECTION_TYPE)
let start, end
for (const binding of bindings) {
if (binding.props.terminal === 'start') {
start = binding
} else if (binding.props.terminal === 'end') {
end = binding
}
}
return { start, 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,
}
)
/** Get the position of a connection binding in page space. */
export function getConnectionBindingPositionInPageSpace(
editor: Editor,
binding: ConnectionBinding
) {
// Find the shape that this binding is bound to
const targetShape = editor.getShape(binding.toId)
if (!targetShape || !editor.isShapeOfType(targetShape, 'node')) return null
// Find the port in the shape that the connection is bound to
const port = getNodePorts(editor, targetShape)[binding.props.portId]
if (!port) return null
// Transform the port position from shape space to page space
return editor.getShapePageTransform(targetShape).applyToPoint(port)
}
/**
* Create or update a connection binding. This utility makes sure that there are only ever two
* connection bindings per connection shape - a start and an end.
*/
export function createOrUpdateConnectionBinding(
editor: Editor,
connection: ConnectionShape | TLShapeId,
target: NodeShape | TLShapeId,
props: ConnectionBinding['props']
) {
const connectionId = typeof connection === 'string' ? connection : connection.id
const targetId = typeof target === 'string' ? target : target.id
const existingMany = editor
.getBindingsFromShape(connectionId, CONNECTION_TYPE)
.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: CONNECTION_TYPE,
fromId: connectionId,
toId: targetId,
props,
})
}
}
/** Remove a connection binding. */
export function removeConnectionBinding(
editor: Editor,
connection: ConnectionShape,
terminal: 'start' | 'end'
) {
const existing = editor
.getBindingsFromShape(connection, CONNECTION_TYPE)
.filter((b) => b.props.terminal === terminal)
editor.deleteBindings(existing)
}

View File

@@ -0,0 +1,124 @@
import {
Circle2d,
Geometry2d,
OverlayUtil,
TLCursorType,
TLOverlay,
TLPointerEventInfo,
TLShapeId,
} from 'tldraw'
import {
CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX,
CONNECTION_CENTER_HANDLE_SIZE_PX,
} from '../constants'
import { ConnectionShape, getConnectionPageCenter } from './ConnectionShapeUtil'
import { insertNodeWithinConnection } from './insertNodeWithinConnection'
interface TLConnectionCenterHandleOverlay extends TLOverlay {
props: {
shapeId: TLShapeId
x: number
y: number
}
}
/**
* Renders a clickable "+" handle at the midpoint of each fully-bound connection
* so users can insert a new node into the middle of a connection. Replaces the
* old SVG indicator-based handle.
*/
export class ConnectionCenterHandleOverlayUtil extends OverlayUtil<TLConnectionCenterHandleOverlay> {
static override type = 'connection_center_handle'
override options = { zIndex: 150 }
override isActive(): boolean {
const editor = this.editor
if (editor.getInstanceState().isReadonly) return false
if (!editor.isIn('select.idle')) return false
return editor.getZoomLevel() > 0.5
}
override getOverlays(): TLConnectionCenterHandleOverlay[] {
const editor = this.editor
const overlays: TLConnectionCenterHandleOverlay[] = []
for (const renderingShape of editor.getRenderingShapes()) {
const shape = renderingShape.shape
if (!editor.isShapeOfType<ConnectionShape>(shape, 'connection')) continue
const center = getConnectionPageCenter(editor, shape)
if (!center) continue
overlays.push({
id: `connection_center_handle:${shape.id}`,
type: 'connection_center_handle',
props: { shapeId: shape.id, x: center.x, y: center.y },
})
}
return overlays
}
override getGeometry(overlay: TLConnectionCenterHandleOverlay): Geometry2d {
const radius = CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX / 2 / this.editor.getZoomLevel()
return new Circle2d({
x: overlay.props.x - radius,
y: overlay.props.y - radius,
radius,
isFilled: true,
})
}
override getCursor(): TLCursorType {
return 'pointer'
}
override onPointerDown(overlay: TLConnectionCenterHandleOverlay, _info: TLPointerEventInfo) {
const connection = this.editor.getShape<ConnectionShape>(overlay.props.shapeId)
if (!connection) return false
insertNodeWithinConnection(this.editor, connection, 'vertical')
}
override render(
ctx: CanvasRenderingContext2D,
overlays: TLConnectionCenterHandleOverlay[]
): void {
if (overlays.length === 0) return
const editor = this.editor
const zoom = editor.getZoomLevel()
const themeColors = editor.getCurrentTheme().colors[editor.getColorMode()]
const ringColor = themeColors.selectionStroke
const iconColor = themeColors.selectedContrast
const hoverFill = themeColors.selectionFill
const hoveredId = editor.overlays.getHoveredOverlayId()
const ringRadius = CONNECTION_CENTER_HANDLE_SIZE_PX / 2 / zoom
const hoverRadius = CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX / 2 / zoom
const plusReachPage = (CONNECTION_CENTER_HANDLE_SIZE_PX / 3 - 1) / zoom
const iconLineWidth = 2 / zoom
for (const overlay of overlays) {
const { x, y } = overlay.props
const isHovered = overlay.id === hoveredId
if (isHovered) {
ctx.fillStyle = hoverFill
ctx.beginPath()
ctx.arc(x, y, hoverRadius, 0, Math.PI * 2)
ctx.fill()
}
ctx.fillStyle = ringColor
ctx.beginPath()
ctx.arc(x, y, ringRadius, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = iconColor
ctx.lineWidth = iconLineWidth
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(x - plusReachPage, y)
ctx.lineTo(x + plusReachPage, y)
ctx.moveTo(x, y - plusReachPage)
ctx.lineTo(x, y + plusReachPage)
ctx.stroke()
}
}
}

View File

@@ -0,0 +1,339 @@
import {
CubicBezier2d,
Editor,
IndexKey,
Mat,
RecordProps,
SVGContainer,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLShape,
TLShapeId,
Vec,
VecLike,
VecModel,
clamp,
createShapeId,
useEditor,
useValue,
vecModelValidator,
} from 'tldraw'
import { getAllConnectedNodes, getNodePorts } from '../nodes/nodePorts'
import { getPortAtPoint } from '../ports/getPortAtPoint'
import { updatePortState } from '../ports/portState'
import {
createOrUpdateConnectionBinding,
getConnectionBindingPositionInPageSpace,
getConnectionBindings,
removeConnectionBinding,
} from './ConnectionBindingUtil'
const CONNECTION_TYPE = 'connection'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[CONNECTION_TYPE]: { start: VecModel; end: VecModel }
}
}
/**
* A connection shape is a directed connection between two node shapes. It has a start point, and an
* end point. These are called "terminals" in the code.
*
* Usually, a connection will also have two ConnectionBindings. These bind each end of the shape to
* the nodes it's connected to. The `start` and `end` properties are the positions of each end of
* the connection, but only when there isn't a binding (ie while dragging the connection). When the
* ends are bound, the position is derived from the connected shape instead.
*/
export type ConnectionShape = TLShape<typeof CONNECTION_TYPE>
export class ConnectionShapeUtil extends ShapeUtil<ConnectionShape> {
static override type = CONNECTION_TYPE
static override props: RecordProps<ConnectionShape> = {
start: vecModelValidator,
end: vecModelValidator,
}
getDefaultProps(): ConnectionShape['props'] {
return {
start: { x: 0, y: 0 },
end: { x: 100, y: 100 },
}
}
override canEdit(_shape: ConnectionShape) {
return false
}
override canResize(_shape: ConnectionShape) {
return false
}
override hideResizeHandles(_shape: ConnectionShape) {
return true
}
override hideRotateHandle(_shape: ConnectionShape) {
return true
}
override hideSelectionBoundsBg(_shape: ConnectionShape) {
return true
}
override hideSelectionBoundsFg(_shape: ConnectionShape) {
return true
}
override canSnap(_shape: ConnectionShape) {
// disable snapping this shape to other shapes
return false
}
override getBoundsSnapGeometry(_shape: ConnectionShape) {
// disable snapping other shape to this shape
return { points: [] }
}
// Define the geometry of our connection shape as a cubic bezier curve
getGeometry(connection: ConnectionShape) {
const { start, end } = getConnectionTerminals(this.editor, connection)
const [cp1, cp2] = getConnectionControlPoints(start, end)
return new CubicBezier2d({
start: Vec.From(start),
cp1: Vec.From(cp1),
cp2: Vec.From(cp2),
end: Vec.From(end),
})
}
getHandles(connection: ConnectionShape): TLHandle[] {
// Handles are draggable points on a shape. In our connection shape, we have a handle at each end.
const { start, end } = getConnectionTerminals(this.editor, connection)
return [
{
id: 'start',
type: 'vertex',
index: 'a0' as IndexKey,
x: start.x,
y: start.y,
},
{
id: 'end',
type: 'vertex',
index: 'a1' as IndexKey,
x: end.x,
y: end.y,
},
]
}
// Handle dragging of connection terminals to connect/disconnect from ports
onHandleDrag(connection: ConnectionShape, { handle }: TLHandleDragInfo<ConnectionShape>) {
// First, get some info about the connection and the terminal we're dragging
const existingBindings = getConnectionBindings(this.editor, connection)
const draggingTerminal = handle.id as 'start' | 'end'
const oppositeTerminal = draggingTerminal === 'start' ? 'end' : 'start'
const oppositeTerminalShapeId = existingBindings[oppositeTerminal]?.toId
// Find the new position of the handle in page space
const handlePagePosition = this.editor.getShapePageTransform(connection).applyToPoint(handle)
// Find the port at the new position
const target = getPortAtPoint(this.editor, handlePagePosition, {
margin: 8,
terminal: draggingTerminal,
})
// only 'start' ports (outputs) can have multiple connections
const allowsMultipleConnections = draggingTerminal === 'start'
// does this port have an existing connection (excluding this one)?
const hasExistingConnection =
target?.existingConnections.some((c) => c.connectionId !== connection.id) ?? false
// find out which nodes would create a cycle based on what the other end of the connection
// is bound to
const nodesWhichWouldCreateACycle = oppositeTerminalShapeId
? getAllConnectedNodes(this.editor, oppositeTerminalShapeId, draggingTerminal)
: null
// update our port UI state to highlight which ports are eligible to connect to
updatePortState(this.editor, {
eligiblePorts: {
terminal: draggingTerminal,
excludeNodes: nodesWhichWouldCreateACycle,
},
})
// if for whatever reason we can't connect to this port...
const wouldCreateACycle = (target && nodesWhichWouldCreateACycle?.has(target.shape.id)) ?? false
if (!target || (hasExistingConnection && !allowsMultipleConnections) || wouldCreateACycle) {
// ... update our port ui state to not highlight any ports...
updatePortState(this.editor, { hintingPort: null })
// ... remove any existing binding for this connection terminal...
removeConnectionBinding(this.editor, connection, draggingTerminal)
// ... and return the connection with the new position.
return {
...connection,
props: {
[handle.id]: { x: handle.x, y: handle.y },
},
}
}
// if we can connect to this port, update our port ui state to highlight the port we're
// connecting to
updatePortState(this.editor, {
hintingPort: { portId: target.port.id, shapeId: target.shape.id },
})
// create or update the connection binding for this connection terminal
createOrUpdateConnectionBinding(this.editor, connection, target.shape, {
portId: target.port.id,
terminal: draggingTerminal,
})
// return the connection unmodified because we only need to update the binding.
return connection
}
// Handle the end of dragging a connection terminal
onHandleDragEnd(
connection: ConnectionShape,
{ handle, isCreatingShape }: TLHandleDragInfo<ConnectionShape>
) {
// clear our port UI state
updatePortState(this.editor, { hintingPort: null, eligiblePorts: null })
const draggingTerminal = handle.id as 'start' | 'end'
// if we successfully connected & now have a binding, we're done!
const bindings = getConnectionBindings(this.editor, connection)
if (bindings[draggingTerminal]) return
if (isCreatingShape && draggingTerminal === 'end') {
// A new connection dropped in empty space gets a fresh node to connect to
createNodeAtConnectionEnd(this.editor, connection, this.editor.inputs.getCurrentPagePoint())
} else if (!bindings.start || !bindings.end) {
// Letting go of an existing connection's terminal with nothing under it disconnects it
this.editor.deleteShapes([connection.id])
}
}
onHandleDragCancel() {
// if we cancel a drag part way through, we need to clear out our port UI state.
updatePortState(this.editor, { hintingPort: null, eligiblePorts: null })
}
component(connection: ConnectionShape) {
return <ConnectionShape connection={connection} />
}
getIndicatorPath(connection: ConnectionShape) {
const { start, end } = getConnectionTerminals(this.editor, connection)
return new Path2D(getConnectionPath(start, end))
}
}
// Main connection component that renders the SVG path
function ConnectionShape({ connection }: { connection: ConnectionShape }) {
const editor = useEditor()
// Get the connection terminals
const { start, end } = useValue('terminals', () => getConnectionTerminals(editor, connection), [
editor,
connection,
])
return (
<SVGContainer className="ConnectionShape">
<path d={getConnectionPath(start, end)} />
</SVGContainer>
)
}
/**
* Page-space midpoint of a connection's bezier curve, for positioning the
* center insert handle. Returns null when the connection isn't fully bound.
*/
export function getConnectionPageCenter(editor: Editor, connection: ConnectionShape): Vec | null {
const bindings = getConnectionBindings(editor, connection)
if (!bindings.start || !bindings.end) return null
const startPage = getConnectionBindingPositionInPageSpace(editor, bindings.start)
const endPage = getConnectionBindingPositionInPageSpace(editor, bindings.end)
if (!startPage || !endPage) return null
const [cp1, cp2] = getConnectionControlPoints(startPage, endPage)
// Cubic bezier midpoint at t=0.5: (P0 + 3·P1 + 3·P2 + P3) / 8
return new Vec(
(startPage.x + 3 * cp1.x + 3 * cp2.x + endPage.x) / 8,
(startPage.y + 3 * cp1.y + 3 * cp2.y + endPage.y) / 8
)
}
// Connections flow top-to-bottom, so control points are offset vertically from each terminal
function getConnectionControlPoints(start: VecLike, end: VecLike): [Vec, Vec] {
const distance = end.y - start.y
const adjustedDistance = Math.max(
30,
distance > 0 ? distance / 3 : clamp(Math.abs(distance) + 30, 0, 100)
)
return [new Vec(start.x, start.y + adjustedDistance), new Vec(end.x, end.y - adjustedDistance)]
}
// Generate SVG path for the connection
function getConnectionPath(start: VecLike, end: VecLike) {
const [cp1, cp2] = getConnectionControlPoints(start, end)
return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${end.x} ${end.y}`
}
// Get the actual start and end points of a connection, considering its bindings
export function getConnectionTerminals(editor: Editor, connection: ConnectionShape) {
// if possible, set the start and end points based on the bindings
const bindings = getConnectionBindings(editor, connection)
const shapeTransform = Mat.Inverse(editor.getShapePageTransform(connection))
const getTerminal = (terminal: 'start' | 'end'): VecLike => {
const binding = bindings[terminal]
const inPageSpace = binding && getConnectionBindingPositionInPageSpace(editor, binding)
// if we couldn't set the start and end points based on the bindings, use the values stored on
// the shape itself
return inPageSpace ? Mat.applyToPoint(shapeTransform, inPageSpace) : connection.props[terminal]
}
return { start: getTerminal('start'), end: getTerminal('end') }
}
/**
* Create a new message node whose input port sits at `point` and bind the connection's end to it.
* Returns the new node's id.
*/
export function createNodeAtConnectionEnd(
editor: Editor,
connection: ConnectionShape | TLShapeId,
point: VecLike
): TLShapeId {
const newNodeId = createShapeId()
editor.createShape({
type: 'node',
id: newNodeId,
x: point.x,
y: point.y,
props: { node: { type: 'message', userMessage: '', assistantMessage: '' } },
})
editor.select(newNodeId)
// Position the node so its input port aligns with the connection end
const inputPort = Object.values(getNodePorts(editor, newNodeId)).find((p) => p.terminal === 'end')
if (inputPort) {
editor.updateShape({
id: newNodeId,
type: 'node',
x: point.x - inputPort.x,
y: point.y - inputPort.y,
})
// bind the connection to the node's first input port
createOrUpdateConnectionBinding(editor, connection, newNodeId, {
portId: inputPort.id,
terminal: 'end',
})
}
return newNodeId
}

View File

@@ -0,0 +1,207 @@
import { Box, createShapeId, Editor, TLShapeId } from 'tldraw'
import { DEFAULT_NODE_SPACING_PX, NODE_HEIGHT_PX, NODE_WIDTH_PX } from '../constants'
import { getNodePortConnections, getNodePorts } from '../nodes/nodePorts'
import { createOrUpdateConnectionBinding, getConnectionBindings } from './ConnectionBindingUtil'
import { ConnectionShape } from './ConnectionShapeUtil'
/**
* Insert a node in the middle of a connection.
*
* This is used when the user clicks the center handle of a connection shape.
*/
export function insertNodeWithinConnection(
editor: Editor,
connection: ConnectionShape,
direction: 'horizontal' | 'vertical' = 'horizontal'
) {
// mark the history so we can undo this operation
const mark = editor.markHistoryStoppingPoint()
// get the original bindings of the connection
const originalBindings = getConnectionBindings(editor, connection)
// if the connection doesn't have bindings, we can't insert a node in the middle of it
if (!originalBindings.start || !originalBindings.end) return
// find the ideal position for the new node based on direction:
const startBounds = editor.getShapePageBounds(originalBindings.start.toId)!
const endBounds = editor.getShapePageBounds(originalBindings.end.toId)!
// Aim for the midpoint between the two nodes, but never overlap the upstream one
let newNodeX: number, newNodeY: number
if (direction === 'horizontal') {
// horizontal layout: nodes flow left to right
newNodeY = (startBounds.top + endBounds.top) / 2
const newNodeIdealX = (startBounds.right + endBounds.left - NODE_WIDTH_PX) / 2
const newNodeMin = startBounds.right + DEFAULT_NODE_SPACING_PX
newNodeX = Math.max(newNodeIdealX, newNodeMin)
} else {
// vertical layout: nodes flow top to bottom
newNodeX = (startBounds.left + endBounds.left) / 2
if (startBounds.top > endBounds.bottom) {
const newNodeIdealY = (startBounds.top + endBounds.bottom - NODE_HEIGHT_PX) / 2
const newNodeMin = endBounds.bottom + DEFAULT_NODE_SPACING_PX
newNodeY = Math.max(newNodeIdealY, newNodeMin)
} else {
const newNodeIdealY = (startBounds.bottom + endBounds.top - NODE_HEIGHT_PX) / 2
const newNodeMin = startBounds.bottom + DEFAULT_NODE_SPACING_PX
newNodeY = Math.max(newNodeIdealY, newNodeMin)
}
}
// create the new node
const newNodeId = createShapeId()
editor.createShape({
type: 'node',
id: newNodeId,
x: newNodeX,
y: newNodeY,
props: { node: { type: 'message', userMessage: '', assistantMessage: '' } },
})
// now, we need to connect up the new node.
// first, lets find the first input and output port on the new node.
const ports = getNodePorts(editor, newNodeId)
const firstInputPort = Object.values(ports).find((p) => p.terminal === 'end')
const firstOutputPort = Object.values(ports).find((p) => p.terminal === 'start')
if (!firstInputPort || !firstOutputPort) {
editor.bailToMark(mark)
return
}
// update the existing connection to connect to the input of our new node
createOrUpdateConnectionBinding(editor, connection, newNodeId, {
portId: firstInputPort.id,
terminal: 'end',
})
// create a new connection between the new node and the end of the original connection
const newConnectionId = createShapeId()
editor.createShape({
type: 'connection',
id: newConnectionId,
})
createOrUpdateConnectionBinding(editor, newConnectionId, newNodeId, {
portId: firstOutputPort.id,
terminal: 'start',
})
createOrUpdateConnectionBinding(editor, newConnectionId, originalBindings.end.toId, {
portId: originalBindings.end.props.portId,
terminal: 'end',
})
// move around any connected nodes to make room for the new one
moveNodesIfNeeded(editor, newNodeId, originalBindings.end.toId, direction)
// select the new node
editor.select(newNodeId)
// update the pointer so that e.g. the editor's internal hovered shape id is correct
editor.updatePointer()
}
/**
* Move around any connected nodes to make room for the new node.
*
* This is used when the user inserts a node in the middle of a connection.
*/
function moveNodesIfNeeded(
editor: Editor,
newNodeId: TLShapeId,
rootNodeId: TLShapeId,
direction: 'horizontal' | 'vertical'
) {
const rootNode = editor.getShape(rootNodeId)
const newNode = editor.getShape(newNodeId)
if (
!rootNode ||
!newNode ||
!editor.isShapeOfType(rootNode, 'node') ||
!editor.isShapeOfType(newNode, 'node')
) {
return
}
// first, we need to nudge all the downstream nodes to make room for the new node.
// toNudge tracks all the nodes we need to nudge.
const toNudge = new Map<
TLShapeId,
{ initialX: number; initialY: number; amountX: number; amountY: number }
>()
// we start with the expanded bounds of the newly added node:
const newNodeBounds = editor.getShapePageBounds(newNodeId)!.clone()
visit(rootNodeId, newNodeBounds.expandBy(DEFAULT_NODE_SPACING_PX))
function visit(nodeId: TLShapeId, parentExpandedBounds: Box) {
const node = editor.getShape(nodeId)
if (!node || !editor.isShapeOfType(node, 'node')) return
// if this node has already been visited, we need to continue on from the nudge we
// calculated last time:
const currentNudge = toNudge.get(nodeId) ?? {
initialX: node.x,
initialY: node.y,
amountX: 0,
amountY: 0,
}
const nodeBounds = editor
.getShapePageBounds(nodeId)!
.clone()
.translate({ x: currentNudge.amountX, y: currentNudge.amountY })
// if this node isn't colliding with the expanded parent node, it's already far enough away
// and we don't need to do anything!
if (!nodeBounds.collides(parentExpandedBounds)) return
// we need to nudge this node to make room for the new node.
// direction determines whether we nudge horizontally or vertically
let newNudgeAmountX = 0
let newNudgeAmountY = 0
if (direction === 'horizontal') {
// nudge to the right for horizontal layout
newNudgeAmountX = parentExpandedBounds.right - nodeBounds.left
} else {
// nudge downward for vertical layout
newNudgeAmountY = parentExpandedBounds.bottom - nodeBounds.top
}
toNudge.set(nodeId, {
initialX: currentNudge.initialX,
initialY: currentNudge.initialY,
amountX: currentNudge.amountX + newNudgeAmountX,
amountY: currentNudge.amountY + newNudgeAmountY,
})
// now, we traverse the downstream connections from this node and nudge them all if needed.
nodeBounds
.translate({ x: newNudgeAmountX, y: newNudgeAmountY })
.expandBy(DEFAULT_NODE_SPACING_PX)
for (const connection of getNodePortConnections(editor, node)) {
if (connection.terminal !== 'start') continue
visit(connection.connectedShapeId, nodeBounds)
}
}
editor
// hide the new node for the purposes of the animation
.updateShape({ id: newNodeId, type: 'node', opacity: 0 })
// animate the new node fading in, and all the other nodes to their new positions
.animateShapes(
[
{
id: newNodeId,
type: 'node',
opacity: 1,
},
...Array.from(toNudge.entries()).map(([id, nudge]) => ({
id,
type: 'node' as const,
x: nudge.initialX + nudge.amountX,
y: nudge.initialY + nudge.amountY,
})),
],
{ animation: { duration: 100 } }
)
}

View File

@@ -0,0 +1,116 @@
import { Editor, getIndexBetween, getIndicesBetween, TLParentId } from 'tldraw'
/**
* This function registers side effects that will make sure connection shapes are always below all
* other shapes in the same parent. We do this because we don't want to connections on top of nodes,
* which might be distracting.
*/
export function keepConnectionsAtBottom(editor: Editor) {
// whenever an operation happens, we'll keep track of the parent ids that we might need to
// re-sort the children of. An operation is any atomic change to the tldraw store.
let pendingChangedParentIds = new Set<TLParentId>()
// whenever a shape is created, we'll add the parent id to the set of parent ids that we might
// need to re-sort the children of.
editor.sideEffects.registerAfterCreateHandler('shape', (shape, source) => {
if (source === 'remote') return
pendingChangedParentIds.add(shape.parentId)
})
// whenever a shape's index or parent id changes, we'll add the parent id to the set of parent
// ids that we might need to re-sort the children of.
editor.sideEffects.registerAfterChangeHandler('shape', (oldShape, newShape, source) => {
if (source === 'remote') return
if (oldShape.parentId === newShape.parentId && oldShape.index === newShape.index) return
pendingChangedParentIds.add(newShape.parentId)
})
// then, when the operation is complete, we re-sort the children as needed:
editor.sideEffects.registerOperationCompleteHandler(() => {
if (pendingChangedParentIds.size === 0) return
// reset the set of pending changed parent ids
const changedParentIds = pendingChangedParentIds
pendingChangedParentIds = new Set()
const updates = []
for (const parentId of changedParentIds) {
// iterate through all the children of this parent. We want all the connections at the
// bottom (first), and everything else after that. Because this fn runs all the time,
// things should always be roughly in the right place to begin with. Because of that,
// we'll just find any non-connections that are in the wrong place and move them up.
const childIds = editor.getSortedChildIdsForParent(parentId)
// we iterate in reverse because we want to find the highest connection index:
let i = childIds.length - 1
let highestConnectionIndex = null
let nextIndexAboveHighestConnectionIndex = null
for (; i >= 0; i--) {
const child = editor.getShape(childIds[i])
if (!child) continue
if (child.type === 'connection') {
highestConnectionIndex = child.index
break
} else {
nextIndexAboveHighestConnectionIndex = child.index
}
}
// now that we've found the highest connection index, we switch to looking for
// non-connection shapes. these need to be moved up above the highest connection index.
const shapesToMove = []
for (; i >= 0; i--) {
const child = editor.getShape(childIds[i])
if (child && child.type !== 'connection') shapesToMove.push(child)
}
// the shapesToMove array will be in reverse order, so we need to reverse it:
shapesToMove.reverse()
// now we need the new indexes for each shape. These need to stay in the same relative
// order, and be inserted above the `hightestConnectionIndex`, but below the index of
// the next non-connection shape.
const newIndexes = getIndicesBetween(
highestConnectionIndex,
nextIndexAboveHighestConnectionIndex,
shapesToMove.length
)
// now we can create the update partials for those shapes:
for (let i = 0; i < shapesToMove.length; i++) {
const shape = shapesToMove[i]
updates.push({ id: shape.id, type: shape.type, index: newIndexes[i] } as const)
}
}
editor.updateShapes(updates)
})
}
/**
* Get the index of the next connection shape in the given parent. This will be above all other
* connections, but the connections should be below all other shapes.
*/
export function getNextConnectionIndex(
editor: Editor,
parentId: TLParentId = editor.getCurrentPageId()
) {
const childIds = editor.getSortedChildIdsForParent(parentId)
let prevIndex = null
let highestConnectionIndex = null
for (let i = childIds.length - 1; i >= 0; i--) {
const child = editor.getShape(childIds[i])
if (!child) continue
if (child.type === 'connection') {
highestConnectionIndex = child.index
break
}
prevIndex = child.index
}
return getIndexBetween(highestConnectionIndex, prevIndex)
}

View File

@@ -0,0 +1,9 @@
export const CONNECTION_CENTER_HANDLE_SIZE_PX = 16
export const CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX = 24
export const NODE_WIDTH_PX = 400
export const NODE_HEIGHT_PX = 54
export const PORT_RADIUS_PX = 6
export const DEFAULT_NODE_SPACING_PX = 60

View File

@@ -0,0 +1,13 @@
import { Editor, TLShape } from 'tldraw'
export function disableTransparency(editor: Editor, shapeTypes: string[]) {
const shapeTypesSet = new Set(shapeTypes)
const forceOpaque = (shape: TLShape) =>
shapeTypesSet.has(shape.type) && shape.opacity !== 1 ? { ...shape, opacity: 1 } : shape
editor.sideEffects.registerBeforeCreateHandler('shape', forceOpaque)
editor.sideEffects.registerBeforeChangeHandler('shape', (_shapeOld, shapeNew) =>
forceOpaque(shapeNew)
)
}

View File

@@ -0,0 +1,86 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
@import url('tldraw/tldraw.css');
body {
font-family: 'Inter', sans-serif;
overscroll-behavior: none;
}
.tl-container {
--node-shadows: 0 2px 6px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--node-shadow-border:
0 0 0 1px rgba(0, 0, 0, 0.04), 0 2px 6px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--node-shadow-highlight:
0 0 0 calc(1.2px / var(--tl-zoom)) var(--tl-color-selected), 0 2px 6px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
}
.tl-container .tl-theme__light {
--tl-color-handle-fill: hsla(210, 100%, 56%, 0.24);
}
.tl-container .tl-theme__dark {
--tl-color-handle-fill: hsla(209, 100%, 57%, 0.2);
}
.NodeShape {
border-radius: var(--tl-radius-3);
background: var(--tl-color-panel);
box-shadow: var(--node-shadow-border);
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
.NodeShape-heading {
padding: 0 var(--tl-space-4);
display: flex;
align-items: center;
justify-content: center;
gap: var(--tl-space-4);
height: 40px;
margin-bottom: var(--tl-space-2);
}
}
.Port {
position: absolute;
top: -6px;
left: -6px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--tl-color-panel);
box-sizing: border-box;
z-index: 2;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 1px 3px rgba(0, 0, 0, 0.12);
flex: 0 0 auto;
transition: transform 0.05s ease-in-out;
[data-state='select.idle'] & {
pointer-events: all;
}
&.Port_hinting {
background: var(--tl-color-selected);
}
&.Port_eligible {
box-shadow:
0 0 0 calc(1px / var(--tl-zoom)) var(--tl-color-selected),
0 0 0 1px rgba(0, 0, 0, 0.04),
0 1px 3px rgba(0, 0, 0, 0.12);
}
}
.ConnectionShape {
stroke-width: 2px;
stroke: var(--tl-color-text-3);
fill: none;
}
.ConnectionShapeIndicator {
stroke-width: 2.1px;
stroke-linecap: round;
}

View File

@@ -0,0 +1,5 @@
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)

View File

@@ -0,0 +1,153 @@
import {
Circle2d,
Group2d,
HTMLContainer,
RecordProps,
Rectangle2d,
ShapeUtil,
TLShape,
useEditor,
useValue,
} from 'tldraw'
import { NODE_WIDTH_PX, PORT_RADIUS_PX } from '../constants'
import { Port } from '../ports/Port'
import { getNodePorts } from './nodePorts'
import {
getNodeDefinition,
getNodeHeightPx,
getNodeTypePorts,
getNodeWidthPx,
NodeType,
} from './nodeTypes'
const NODE_TYPE = 'node'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[NODE_TYPE]: { node: NodeType }
}
}
// Define our custom node shape type that extends tldraw's base shape system
export type NodeShape = TLShape<typeof NODE_TYPE>
// This class extends tldraw's ShapeUtil to define how our custom node shapes behave
export class NodeShapeUtil extends ShapeUtil<NodeShape> {
static override type = NODE_TYPE
static override props: RecordProps<NodeShape> = {
node: NodeType,
}
getDefaultProps(): NodeShape['props'] {
return {
node: getNodeDefinition(this.editor, 'message').getDefault(),
}
}
override canEdit(_shape: NodeShape) {
return false
}
override canResize(_shape: NodeShape) {
return false
}
override hideResizeHandles(_shape: NodeShape) {
return true
}
override hideRotateHandle(_shape: NodeShape) {
return true
}
override hideSelectionBoundsBg(_shape: NodeShape) {
return true
}
override hideSelectionBoundsFg(_shape: NodeShape) {
return true
}
override isAspectRatioLocked(_shape: NodeShape) {
return false
}
override getBoundsSnapGeometry(_shape: NodeShape) {
return {
points: [{ x: 0, y: 0 }],
}
}
// Define the geometry of our node shape including ports
getGeometry(shape: NodeShape) {
const ports = getNodePorts(this.editor, shape)
const portGeometries = Object.values(ports).map(
(port) =>
new Circle2d({
x: port.x - PORT_RADIUS_PX,
y: port.y - PORT_RADIUS_PX,
radius: PORT_RADIUS_PX,
isFilled: true,
// not a label, but this hack excludes them from the selection bounds which is useful
isLabel: true,
})
)
const bodyGeometry = new Rectangle2d({
width: getNodeWidthPx(this.editor, shape),
height: getNodeHeightPx(this.editor, shape),
isFilled: true,
})
return new Group2d({
children: [bodyGeometry, ...portGeometries],
})
}
component(shape: NodeShape) {
return <NodeShape shape={shape} />
}
getIndicatorPath(shape: NodeShape) {
const height = getNodeHeightPx(this.editor, shape)
const path = new Path2D()
path.rect(0, 0, NODE_WIDTH_PX, height)
const ports = getNodePorts(this.editor, shape)
for (const port of Object.values(ports)) {
path.moveTo(port.x + PORT_RADIUS_PX, port.y)
path.arc(port.x, port.y, PORT_RADIUS_PX, 0, Math.PI * 2)
}
return path
}
}
// Main node component that renders the HTML content
function NodeShape({ shape }: { shape: NodeShape }) {
const editor = useEditor()
return (
<HTMLContainer
className="NodeShape"
style={{
width: getNodeWidthPx(editor, shape),
height: getNodeHeightPx(editor, shape),
}}
>
<NodeBody shape={shape} />
<NodePorts shape={shape} />
</HTMLContainer>
)
}
function NodeBody({ shape }: { shape: NodeShape }) {
const node = shape.props.node
const editor = useEditor()
const { Component } = getNodeDefinition(editor, node)
return <Component shape={shape} node={node} />
}
function NodePorts({ shape }: { shape: NodeShape }) {
const editor = useEditor()
const ports = useValue('node ports', () => getNodeTypePorts(editor, shape), [shape, editor])
return (
<>
{Object.values(ports).map((port) => (
<Port key={port.id} shapeId={shape.id} port={port} />
))}
</>
)
}

View File

@@ -0,0 +1,96 @@
/**
* This file contains functions for working with ports and connections on nodes.
*/
import { createComputedCache, Editor, TLShapeId } from 'tldraw'
import { getConnectionBindings } from '../connection/ConnectionBindingUtil'
import { PortId } from '../ports/Port'
import { NodeShape } from './NodeShapeUtil'
import { getNodeTypePorts, NodeTypePorts } from './nodeTypes'
/**
* Get the ports for a node. This is cached, because we only want to re-evaluate it when the
* underlying records change.
*/
export function getNodePorts(editor: Editor, shape: NodeShape | TLShapeId): NodeTypePorts {
return nodePortsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const nodePortsCache = createComputedCache(
'ports',
(editor: Editor, node: NodeShape): NodeTypePorts => getNodeTypePorts(editor, node)
)
/**
* A connection from one node to another.
*/
export interface NodePortConnection {
connectedShapeId: TLShapeId
connectionId: TLShapeId
terminal: 'start' | 'end'
ownPortId: PortId
connectedPortId: PortId
}
/**
* Get the connections for a node. This is cached.
*/
export function getNodePortConnections(
editor: Editor,
shape: NodeShape | TLShapeId
): NodePortConnection[] {
return nodePortConnectionsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? []
}
const nodePortConnectionsCache = createComputedCache(
'port connections',
(editor: Editor, node: NodeShape) => {
const bindings = editor.getBindingsToShape(node.id, 'connection')
const connections: NodePortConnection[] = []
for (const binding of bindings) {
const oppositeTerminal = binding.props.terminal === 'start' ? 'end' : 'start'
const oppositeBinding = getConnectionBindings(editor, binding.fromId)[oppositeTerminal]
if (!oppositeBinding) continue
connections.push({
connectedShapeId: oppositeBinding.toId,
connectionId: binding.fromId,
terminal: binding.props.terminal,
ownPortId: binding.props.portId,
connectedPortId: oppositeBinding.props.portId,
})
}
return connections
},
{
areRecordsEqual: (a, b) => a.id === b.id,
}
)
/**
* Traverse the graph from a starting node, in a given direction, returning all the nodes that are
* connected to it (including the starting node).
*/
export function getAllConnectedNodes(
editor: Editor,
startingNode: TLShapeId | NodeShape,
direction?: 'start' | 'end'
) {
const toVisit = [typeof startingNode === 'string' ? startingNode : startingNode.id]
const found = new Set<TLShapeId>()
while (toVisit.length > 0) {
const nodeId = toVisit.shift()!
const node = editor.getShape(nodeId)
if (!node || !editor.isShapeOfType(node, 'node')) continue
if (found.has(node.id)) continue
found.add(node.id)
for (const connection of getNodePortConnections(editor, node)) {
if (direction && connection.terminal !== direction) continue
toVisit.push(connection.connectedShapeId)
}
}
return found
}

View File

@@ -0,0 +1,67 @@
import { Editor, T, WeakCache } from 'tldraw'
import { PortId, shapePort } from '../ports/Port'
import { NodeShape } from './NodeShapeUtil'
import { MessageNodeDefinition } from './types/MessageNode'
import { NodeDefinition, NodeDefinitionConstructor } from './types/shared'
/** All our node types */
export const NodeDefinitions = {
message: MessageNodeDefinition,
} satisfies Record<string, NodeDefinitionConstructor<any>>
const nodeDefinitions = new WeakCache<
Editor,
{ [K in keyof typeof NodeDefinitions]: InstanceType<(typeof NodeDefinitions)[K]> }
>()
export function getNodeDefinitions(editor: Editor) {
return nodeDefinitions.get(
editor,
() =>
Object.fromEntries(
Object.values(NodeDefinitions).map((value) => [value.type, new value(editor)])
) as any
)
}
export function getNodeDefinition(
editor: Editor,
node: NodeType | NodeType['type']
): NodeDefinition<NodeType> {
return getNodeDefinitions(editor)[
typeof node === 'string' ? node : node.type
] as NodeDefinition<NodeType>
}
/**
* A union type of all our node types.
*/
export type NodeType = T.TypeOf<typeof NodeType>
export const NodeType = T.union(
'type',
Object.fromEntries(Object.values(NodeDefinitions).map((type) => [type.type, type.validator])) as {
[K in keyof typeof NodeDefinitions as (typeof NodeDefinitions)[K]['type']]: (typeof NodeDefinitions)[K]['validator']
}
)
export function getNodeHeightPx(editor: Editor, shape: NodeShape): number {
return getNodeDefinition(editor, shape.props.node).getBodyHeightPx(shape, shape.props.node)
}
export function getNodeWidthPx(editor: Editor, shape: NodeShape): number {
return getNodeDefinition(editor, shape.props.node).getBodyWidthPx(shape, shape.props.node)
}
const _nodeTypePorts = T.dict(T.string, shapePort)
export type NodeTypePorts = T.TypeOf<typeof _nodeTypePorts>
export function getNodeTypePorts(editor: Editor, shape: NodeShape): NodeTypePorts {
return getNodeDefinition(editor, shape.props.node).getPorts(shape, shape.props.node)
}
export function onNodePortConnect(editor: Editor, shape: NodeShape, port: PortId) {
getNodeDefinition(editor, shape.props.node).onPortConnect?.(shape, shape.props.node, port)
}
export function onNodePortDisconnect(editor: Editor, shape: NodeShape, port: PortId) {
getNodeDefinition(editor, shape.props.node).onPortDisconnect?.(shape, shape.props.node, port)
}

View File

@@ -0,0 +1,209 @@
import { ModelMessage } from 'ai'
import { useCallback } from 'react'
import { T, TldrawUiButton, TldrawUiButtonIcon, TldrawUiInput, useEditor } from 'tldraw'
import { HandleIcon } from '../../components/icons/HandleIcon'
import { SendIcon } from '../../components/icons/SendIcon'
import { NODE_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
import { getAllConnectedNodes } from '../nodePorts'
import { NodeShape } from '../NodeShapeUtil'
import {
NodeComponentProps,
NodeDefinition,
shapeInputPort,
shapeOutputPort,
updateNode,
} from './shared'
/**
* This node is a message from the user.
*/
export type MessageNode = T.TypeOf<typeof MessageNode>
export const MessageNode = T.object({
type: T.literal('message'),
userMessage: T.string,
assistantMessage: T.string,
})
export class MessageNodeDefinition extends NodeDefinition<MessageNode> {
static type = 'message'
static validator = MessageNode
title = 'Message'
heading = 'Message'
icon = <SendIcon />
getDefault(): MessageNode {
return {
type: 'message',
userMessage: 'hello',
assistantMessage: '',
}
}
getBodyWidthPx(_shape: NodeShape, _node: MessageNode): number {
return NODE_WIDTH_PX
}
getBodyHeightPx(_shape: NodeShape, node: MessageNode): number {
const assistantMessage = node.assistantMessage.trim()
if (assistantMessage === '') return NODE_HEIGHT_PX
const size = this.editor.textMeasure.measureText(assistantMessage, {
fontFamily: 'Inter',
fontSize: 12,
fontWeight: '500',
fontStyle: 'normal',
maxWidth: NODE_WIDTH_PX,
lineHeight: 1.3,
padding: '12px',
})
return NODE_HEIGHT_PX + size.h
}
getPorts(shape: NodeShape, node: MessageNode) {
return {
input: shapeInputPort,
output: {
...shapeOutputPort,
y: this.getBodyHeightPx(shape, node),
},
}
}
Component = MessageNodeComponent
}
function MessageNodeComponent({ node, shape }: NodeComponentProps<MessageNode>) {
const editor = useEditor()
const handleSend = useCallback(() => {
// 1. gather up parents and create message history
// 2. create prompt
// 3. send prompt to ai
// 4. update node with assistant message
const messages: ModelMessage[] = []
for (const connectedShape of getAllConnectedNodes(editor, shape, 'end')) {
const node = editor.getShape(connectedShape)
if (!node || !editor.isShapeOfType(node, 'node')) continue
if (node.props.node.type !== 'message') continue
if (node.props.node.assistantMessage && connectedShape !== shape.id) {
messages.push({ role: 'assistant', content: node.props.node.assistantMessage })
}
messages.push({ role: 'user', content: node.props.node.userMessage })
}
messages.reverse()
// clear any previous assistant message before starting
updateNode<MessageNode>(editor, shape, (node) => ({
...node,
assistantMessage: '...',
}))
// stream the response and append as chunks arrive
;(async () => {
try {
const response = await fetch('/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messages),
})
if (!response.body) return
const reader = response.body.getReader()
const decoder = new TextDecoder()
let accumulatedText = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// Some environments may send SSE-style lines; extract data if so, else use raw chunk
const maybeSse = chunk
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s?/, ''))
.join('')
accumulatedText += maybeSse || chunk
updateNode<MessageNode>(editor, shape, (node) => ({
...node,
assistantMessage: accumulatedText,
}))
}
} catch (e) {
console.error(e)
}
})()
}, [editor, shape])
const handleMessageChange = useCallback(
(value: string) => {
updateNode<MessageNode>(editor, shape, (node) => ({
...node,
userMessage: value,
}))
},
[editor, shape]
)
return (
<div
style={{
pointerEvents: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<div
style={{
height: '100%',
width: 32,
paddingLeft: 4,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'grab',
}}
>
<TldrawUiButtonIcon icon={<HandleIcon />} />
</div>
<div
style={{ padding: '4px 0px 0px 4px', flexGrow: 2 }}
onPointerDown={editor.markEventAsHandled}
>
<div style={{ padding: '0px 12px', borderRadius: 6, border: '1px solid #e2e8f0' }}>
<TldrawUiInput
value={node.userMessage}
onValueChange={handleMessageChange}
onComplete={handleSend}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0px 0px' }}>
<TldrawUiButton
type="primary"
onClick={handleSend}
onPointerDown={editor.markEventAsHandled}
>
<TldrawUiButtonIcon icon={<SendIcon />} />
</TldrawUiButton>
</div>
</div>
{node.assistantMessage && (
<div style={{ padding: 4 }}>
<div
style={{
padding: 8,
lineHeight: '1.3',
fontSize: '12px',
borderRadius: 6,
border: '1px solid #e2e8f0',
fontWeight: '500',
fontFamily: 'Inter',
overflowWrap: 'normal',
whiteSpace: 'pre-wrap',
}}
>
{node.assistantMessage}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Editor, T, TLUiIconJsx } from 'tldraw'
import { NODE_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
import { PortId, ShapePort } from '../../ports/Port'
import { NodeShape } from '../NodeShapeUtil'
import { NodeType, NodeTypePorts } from '../nodeTypes'
export interface NodeComponentProps<Node extends { type: string }> {
shape: NodeShape
node: Node
}
export abstract class NodeDefinition<Node extends { type: string }> {
constructor(public readonly editor: Editor) {
const ctor = this.constructor as NodeDefinitionConstructor<Node>
this.type = ctor.type
this.validator = ctor.validator
}
readonly type: Node['type']
readonly validator: T.Validator<Node>
abstract readonly title: string
abstract readonly heading?: string
abstract readonly icon: TLUiIconJsx
abstract getDefault(): Node
abstract getBodyWidthPx(shape: NodeShape, node: Node): number
abstract getBodyHeightPx(shape: NodeShape, node: Node): number
abstract getPorts(shape: NodeShape, node: Node): NodeTypePorts
onPortConnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
onPortDisconnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
abstract Component: React.ComponentType<NodeComponentProps<Node>>
}
export interface NodeDefinitionConstructor<Node extends { type: string }> {
new (editor: Editor): NodeDefinition<Node>
readonly type: Node['type']
readonly validator: T.Validator<Node>
}
/**
* The standard input port for a node.
*/
export const shapeInputPort: ShapePort = {
id: 'input',
terminal: 'end',
x: NODE_WIDTH_PX / 2,
y: 0,
}
/**
* The standard output port for a node.
*/
export const shapeOutputPort: ShapePort = {
id: 'output',
terminal: 'start',
x: NODE_WIDTH_PX / 2,
y: NODE_HEIGHT_PX,
}
/**
* Update the `node` prop within a node shape.
*/
export function updateNode<T extends NodeType>(
editor: Editor,
shape: NodeShape,
update: (node: T) => T
) {
editor.updateShape({
id: shape.id,
type: shape.type,
props: { node: update(shape.props.node as T) },
})
}

View File

@@ -0,0 +1,150 @@
import { createShapeId, StateNode, TLPointerEventInfo, TLShapeId } from 'tldraw'
import { createOrUpdateConnectionBinding } from '../connection/ConnectionBindingUtil.tsx'
import { createNodeAtConnectionEnd } from '../connection/ConnectionShapeUtil.tsx'
import { getNextConnectionIndex } from '../connection/keepConnectionsAtBottom.tsx'
import { DEFAULT_NODE_SPACING_PX } from '../constants.tsx'
import { getNodePortConnections } from '../nodes/nodePorts.tsx'
import { PortId } from './Port.tsx'
// Information about which port is being pointed at
interface PointingPortInfo {
shapeId: TLShapeId
portId: PortId
terminal: 'start' | 'end'
}
// State node that handles pointing at ports to create connections
// This will be added to tldraw's state machine to customize the built-in select tool
export class PointingPort extends StateNode {
static override id = 'pointing_port'
info?: PointingPortInfo
override onEnter(info: PointingPortInfo): void {
this.info = info
}
private getExistingConnection() {
const { shapeId, portId } = this.info!
return getNodePortConnections(this.editor, shapeId).find((c) => c.ownPortId === portId)
}
override onPointerMove(info: TLPointerEventInfo): void {
// isDragging is true if the user has moved the pointer sufficiently. below this threshold,
// we treat the pointer as a click.
if (!this.editor.inputs.getIsDragging()) return
const { shapeId, portId, terminal: connectingTerminal } = this.info!
const existingConnection = this.getExistingConnection()
// If we can't have multiple connections and one already exists, move the existing
// connection by transitioning to dragging the existing connection's handle.
if (connectingTerminal !== 'start' && existingConnection) {
this.parent.transition('dragging_handle', {
...info,
target: 'handle',
shape: this.editor.getShape(existingConnection.connectionId)!,
handle: this.editor
.getShapeHandles(existingConnection.connectionId)!
.find((h) => h.id === connectingTerminal),
})
return
}
// Otherwise, create a new connection, and start dragging that connection's handle instead.
const creatingMarkId = this.editor.markHistoryStoppingPoint()
const connectionShapeId = createShapeId()
const draggingTerminal = connectingTerminal === 'start' ? 'end' : 'start'
const pagePoint = this.editor.inputs.getCurrentPagePoint()
this.editor.createShape({
type: 'connection',
id: connectionShapeId,
x: pagePoint.x,
y: pagePoint.y,
index: getNextConnectionIndex(this.editor),
props: {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
},
})
// bind one end of the connection to the port the user pointer-down'd on.
createOrUpdateConnectionBinding(this.editor, connectionShapeId, shapeId, {
portId,
terminal: connectingTerminal,
})
// transition to dragging the other end of the connection:
const handle = this.editor
.getShapeHandles(connectionShapeId)
?.find((h) => h.id === draggingTerminal)
this.parent.transition('dragging_handle', {
...info,
target: 'handle',
shape: this.editor.getShape(connectionShapeId)!,
handle: handle!,
creatingMarkId,
isCreating: true,
})
}
override onPointerUp(info: TLPointerEventInfo): void {
// if we get a pointer up while we're still in this state, it means we haven't transitioned
// into a dragging state so we'll treat this as a click:
this.onClick()
// switch back to the idle state when we're done:
this.parent.transition('idle', info)
}
// Handle clicks on ports (without dragging)
private onClick() {
// Only handle clicks on start ports (output ports)
if (this.info?.terminal !== 'start') return
// Don't create new connections if one already exists
if (this.getExistingConnection()) return
const { shapeId, portId } = this.info
// Get the bounds of the source node
const bounds = this.editor.getShapePageBounds(shapeId)
if (!bounds) return
// Calculate position for new node to the right of the source node
const targetPositionInPageSpace = {
x: bounds.midX,
y: bounds.bottom + DEFAULT_NODE_SPACING_PX,
}
// Create a connection shape
const connectionShapeId = createShapeId()
this.editor.createShape({
type: 'connection',
id: connectionShapeId,
x: bounds.right,
y: bounds.top,
index: getNextConnectionIndex(this.editor),
})
// Bind the connection to the source port
createOrUpdateConnectionBinding(this.editor, connectionShapeId, shapeId, {
portId,
terminal: 'start',
})
// Position the connection end point where the new node will be
const targetPositionInConnectionSpace = this.editor
.getPointInShapeSpace(connectionShapeId, targetPositionInPageSpace)
.addXY(0, 200)
this.editor.updateShape({
id: connectionShapeId,
type: 'connection',
props: {
end: targetPositionInConnectionSpace.toJson(),
},
})
createNodeAtConnectionEnd(this.editor, connectionShapeId, targetPositionInPageSpace)
}
}

View File

@@ -0,0 +1,80 @@
import classNames from 'classnames'
import { T, TLShapeId, useEditor, useValue } from 'tldraw'
import { getNodePortConnections } from '../nodes/nodePorts'
import { portState } from './portState'
export type PortId = string
export const shapePort = T.object({
id: T.string,
terminal: T.literalEnum('start', 'end'),
x: T.number,
y: T.number,
})
export type ShapePort = T.TypeOf<typeof shapePort>
/**
* Port ids are unique within a shape. To identify a port we need both the shape id and the port id.
*/
export interface PortIdentifier {
shapeId: TLShapeId
portId: PortId
}
/**
* This react component renders a port.
*/
export function Port({ shapeId, port }: { shapeId: TLShapeId; port: ShapePort }) {
const editor = useEditor()
// isHinting is true if the user is currently dragging a connection to this port. it means we
// should highlight this port.
const isHinting = useValue(
'isHinting',
() => {
const { hintingPort } = portState.get(editor)
return hintingPort && hintingPort.portId === port.id && hintingPort.shapeId === shapeId
},
[editor, shapeId, port.id]
)
// isEligible is true if the the user is currently dragging a connection, and this port is one
// that the connection can be connected to.
const isEligible = useValue(
'isEligible',
() => {
const { eligiblePorts } = portState.get(editor)
if (!eligiblePorts) return false
if (eligiblePorts.terminal !== port.terminal) return false
if (eligiblePorts.excludeNodes?.has(shapeId)) return false
if (port.terminal === 'end') {
// if the port is an end port, it can only have one connection, so it's not eligible
// when there is a connection
const connections = getNodePortConnections(editor, shapeId)
return !connections.some((c) => c.ownPortId === port.id)
}
return true
},
[editor, shapeId, port.id, port.terminal]
)
return (
<div
className={classNames(
`Port`,
isHinting ? 'Port_hinting' : isEligible ? 'Port_eligible' : undefined
)}
style={{
transform: `translate(${port.x}px, ${port.y}px)`,
}}
onPointerDown={() => {
editor.setCurrentTool('select.pointing_port', {
shapeId,
portId: port.id,
terminal: port.terminal,
})
}}
/>
)
}

View File

@@ -0,0 +1,48 @@
import { Editor, Vec, VecLike } from 'tldraw'
import { getNodePortConnections, getNodePorts } from '../nodes/nodePorts'
import { ShapePort } from './Port'
export function getPortAtPoint(
editor: Editor,
point: VecLike,
opts?: { terminal?: 'start' | 'end'; margin?: number }
) {
// find the shape at that point:
const shape = editor.getShapeAtPoint(point, {
hitInside: true,
// only node shapes can have ports
filter: (shape) => editor.isShapeOfType(shape, 'node'),
...opts,
})
if (!shape || !editor.isShapeOfType(shape, 'node')) return null
// get the ports on that shape
const ports = getNodePorts(editor, shape)
// transform the ports to page space
const shapeTransform = editor.getShapePageTransform(shape)
// find the port closest to the point
let bestPort: ShapePort | null = null
let bestDistance = Infinity
for (const port of Object.values(ports)) {
if (opts?.terminal && port.terminal !== opts.terminal) continue
const portInPageSpace = shapeTransform.applyToPoint(port)
const distance = Vec.Dist(point, portInPageSpace)
if (distance < bestDistance) {
bestPort = port
bestDistance = distance
}
}
// if we didn't find a port, return null
if (!bestPort) return null
// otherwise, return the port and it's existing connections
const existingConnections = getNodePortConnections(editor, shape).filter(
(c) => c.ownPortId === bestPort.id
)
return { shape, port: bestPort, existingConnections }
}

View File

@@ -0,0 +1,24 @@
import { Editor, EditorAtom, TLShapeId } from 'tldraw'
import { PortIdentifier } from './Port'
/**
* The UI state for ports. These mostly highlight ports relevant to the user's current action.
*/
export interface PortState {
// hintingPort is the port that the user is currently dragging a connection to.
hintingPort: PortIdentifier | null
// eligiblePorts is the set of ports that the user can connect a new connection to.
eligiblePorts: {
terminal: 'start' | 'end'
excludeNodes: Set<TLShapeId> | null
} | null
}
export const portState = new EditorAtom<PortState>('port state', () => ({
hintingPort: null,
eligiblePorts: null,
}))
export function updatePortState(editor: Editor, update: Partial<PortState>) {
portState.update(editor, (state) => ({ ...state, ...update }))
}