Files
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

340 lines
11 KiB
TypeScript

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
}