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
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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user