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
208 lines
7.0 KiB
TypeScript
208 lines
7.0 KiB
TypeScript
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 } }
|
|
)
|
|
}
|