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,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)
}