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:
99
templates/branching-chat/client/App.tsx
Normal file
99
templates/branching-chat/client/App.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
DefaultActionsMenu,
|
||||
DefaultQuickActions,
|
||||
DefaultStylePanel,
|
||||
TLComponents,
|
||||
Tldraw,
|
||||
TldrawOptions,
|
||||
TldrawUiToolbar,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { overrides, WorkflowToolbar } from './components/WorkflowToolbar.tsx'
|
||||
import { ConnectionBindingUtil } from './connection/ConnectionBindingUtil.tsx'
|
||||
import { ConnectionCenterHandleOverlayUtil } from './connection/ConnectionCenterHandleOverlayUtil.tsx'
|
||||
import { ConnectionShapeUtil } from './connection/ConnectionShapeUtil.tsx'
|
||||
import { keepConnectionsAtBottom } from './connection/keepConnectionsAtBottom.tsx'
|
||||
import { disableTransparency } from './disableTransparency.tsx'
|
||||
import { NodeShapeUtil } from './nodes/NodeShapeUtil.tsx'
|
||||
import { PointingPort } from './ports/PointingPort.tsx'
|
||||
|
||||
// Define custom shape utilities that extend tldraw's shape system
|
||||
const shapeUtils = [NodeShapeUtil, ConnectionShapeUtil]
|
||||
// Define binding utilities that handle relationships between shapes
|
||||
const bindingUtils = [ConnectionBindingUtil]
|
||||
// Canvas overlays — adds the "+" insert handle at the midpoint of each connection
|
||||
const overlayUtils = [ConnectionCenterHandleOverlayUtil]
|
||||
|
||||
// Customize tldraw's UI components to add workflow-specific functionality
|
||||
const components: TLComponents = {
|
||||
Toolbar: () => (
|
||||
<>
|
||||
<WorkflowToolbar />
|
||||
<div className="tlui-main-toolbar tlui-main-toolbar--horizontal">
|
||||
<TldrawUiToolbar className="tlui-main-toolbar__tools" label="Actions">
|
||||
<DefaultQuickActions />
|
||||
<DefaultActionsMenu />
|
||||
</TldrawUiToolbar>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
|
||||
MenuPanel: () => null,
|
||||
StylePanel: () => {
|
||||
const editor = useEditor()
|
||||
const shouldShowStylePanel = useValue(
|
||||
'shouldShowStylePanel',
|
||||
() =>
|
||||
!editor.isIn('select') ||
|
||||
editor.getSelectedShapes().some((s) => s.type !== 'node' && s.type !== 'connection'),
|
||||
[editor]
|
||||
)
|
||||
if (!shouldShowStylePanel) return
|
||||
return <DefaultStylePanel />
|
||||
},
|
||||
}
|
||||
|
||||
const options: Partial<TldrawOptions> = {
|
||||
actionShortcutsLocation: 'menu',
|
||||
maxPages: 1,
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="workflow" style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
persistenceKey="workflow"
|
||||
options={options}
|
||||
overrides={overrides}
|
||||
shapeUtils={shapeUtils}
|
||||
bindingUtils={bindingUtils}
|
||||
overlayUtils={overlayUtils}
|
||||
components={components}
|
||||
onMount={(editor) => {
|
||||
;(window as any).editor = editor
|
||||
if (!editor.getCurrentPageShapes().some((s) => s.type === 'node')) {
|
||||
editor.createShape({ type: 'node', x: 200, y: 200 })
|
||||
}
|
||||
|
||||
editor.user.updateUserPreferences({ isSnapMode: true })
|
||||
|
||||
// Add our custom pointing port tool to the select tool's state machine
|
||||
// This allows users to create connections by pointing at ports
|
||||
editor.getStateDescendant('select')!.addChild(PointingPort)
|
||||
|
||||
// todo: move connections to on the canvas layer
|
||||
|
||||
// Ensure connections always stay at the bottom of the shape stack
|
||||
// This prevents them from covering other shapes
|
||||
keepConnectionsAtBottom(editor)
|
||||
|
||||
// Disable transparency for workflow shapes
|
||||
disableTransparency(editor, ['node', 'connection'])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
131
templates/branching-chat/client/components/WorkflowToolbar.tsx
Normal file
131
templates/branching-chat/client/components/WorkflowToolbar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
AssetToolbarItem,
|
||||
CheckBoxToolbarItem,
|
||||
CloudToolbarItem,
|
||||
createShapeId,
|
||||
DefaultToolbar,
|
||||
DiamondToolbarItem,
|
||||
DrawToolbarItem,
|
||||
Editor,
|
||||
EllipseToolbarItem,
|
||||
HandToolbarItem,
|
||||
HeartToolbarItem,
|
||||
HexagonToolbarItem,
|
||||
HighlightToolbarItem,
|
||||
LaserToolbarItem,
|
||||
NoteToolbarItem,
|
||||
onDragFromToolbarToCreateShape,
|
||||
OvalToolbarItem,
|
||||
RectangleToolbarItem,
|
||||
RhombusToolbarItem,
|
||||
SelectToolbarItem,
|
||||
StarToolbarItem,
|
||||
TextToolbarItem,
|
||||
TldrawUiMenuGroup,
|
||||
TLShapeId,
|
||||
TLUiOverrides,
|
||||
ToolbarItem,
|
||||
TriangleToolbarItem,
|
||||
Vec,
|
||||
XBoxToolbarItem,
|
||||
} from 'tldraw'
|
||||
import { NodeShape } from '../nodes/NodeShapeUtil'
|
||||
import { getNodeDefinitions, NodeType } from '../nodes/nodeTypes'
|
||||
|
||||
function createNodeShape(editor: Editor, shapeId: TLShapeId, center: Vec, node: NodeType) {
|
||||
// Mark a history stopping point for undo/redo
|
||||
editor.markHistoryStoppingPoint('create node')
|
||||
|
||||
editor.run(() => {
|
||||
// Create the shape with the node definition
|
||||
editor.createShape({
|
||||
id: shapeId,
|
||||
type: 'node',
|
||||
props: { node },
|
||||
})
|
||||
|
||||
// Get the created shape and its bounds
|
||||
const shape = editor.getShape<NodeShape>(shapeId)!
|
||||
const shapeBounds = editor.getShapePageBounds(shapeId)!
|
||||
|
||||
// Position the shape so its center aligns with the drop point
|
||||
editor.updateShape({
|
||||
...shape,
|
||||
x: center.x - shapeBounds.width / 2,
|
||||
y: center.y - shapeBounds.height / 2,
|
||||
})
|
||||
|
||||
// Select the newly created shape
|
||||
editor.select(shapeId)
|
||||
})
|
||||
}
|
||||
|
||||
export const overrides: TLUiOverrides = {
|
||||
tools: (editor, tools, _) => {
|
||||
for (const nodeDef of Object.values(getNodeDefinitions(editor))) {
|
||||
tools[`node-${nodeDef.type}`] = {
|
||||
id: `node-${nodeDef.type}`,
|
||||
label: nodeDef.title,
|
||||
icon: nodeDef.icon,
|
||||
onSelect: () => {
|
||||
createNodeShape(
|
||||
editor,
|
||||
createShapeId(),
|
||||
editor.getViewportPageBounds().center,
|
||||
nodeDef.getDefault()
|
||||
)
|
||||
},
|
||||
onDragStart: (_, info) => {
|
||||
onDragFromToolbarToCreateShape(editor, info, {
|
||||
createShape: (id) => {
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'node',
|
||||
props: { node: nodeDef.getDefault() },
|
||||
})
|
||||
},
|
||||
onDragEnd: () => {},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
return tools
|
||||
},
|
||||
}
|
||||
|
||||
export function WorkflowToolbar() {
|
||||
return (
|
||||
<DefaultToolbar orientation="vertical" maxItems={7}>
|
||||
<TldrawUiMenuGroup id="selection">
|
||||
<SelectToolbarItem />
|
||||
<HandToolbarItem />
|
||||
<DrawToolbarItem />
|
||||
</TldrawUiMenuGroup>
|
||||
<TldrawUiMenuGroup id="nodes">
|
||||
<ToolbarItem tool="node-message" />
|
||||
</TldrawUiMenuGroup>
|
||||
<TldrawUiMenuGroup id="shapes">
|
||||
<RectangleToolbarItem />
|
||||
<EllipseToolbarItem />
|
||||
<TriangleToolbarItem />
|
||||
<NoteToolbarItem />
|
||||
|
||||
<DiamondToolbarItem />
|
||||
<HexagonToolbarItem />
|
||||
<OvalToolbarItem />
|
||||
<RhombusToolbarItem />
|
||||
|
||||
<StarToolbarItem />
|
||||
<CloudToolbarItem />
|
||||
<HeartToolbarItem />
|
||||
<XBoxToolbarItem />
|
||||
|
||||
<CheckBoxToolbarItem />
|
||||
<TextToolbarItem />
|
||||
<AssetToolbarItem />
|
||||
<HighlightToolbarItem />
|
||||
<LaserToolbarItem />
|
||||
</TldrawUiMenuGroup>
|
||||
</DefaultToolbar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function HandleIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 4.625C6.12132 4.625 6.625 4.12132 6.625 3.5C6.625 2.87868 6.12132 2.375 5.5 2.375C4.87868 2.375 4.375 2.87868 4.375 3.5C4.375 4.12132 4.87868 4.625 5.5 4.625ZM9.5 4.625C10.1213 4.625 10.625 4.12132 10.625 3.5C10.625 2.87868 10.1213 2.375 9.5 2.375C8.87868 2.375 8.375 2.87868 8.375 3.5C8.375 4.12132 8.87868 4.625 9.5 4.625ZM10.625 7.5C10.625 8.12132 10.1213 8.625 9.5 8.625C8.87868 8.625 8.375 8.12132 8.375 7.5C8.375 6.87868 8.87868 6.375 9.5 6.375C10.1213 6.375 10.625 6.87868 10.625 7.5ZM5.5 8.625C6.12132 8.625 6.625 8.12132 6.625 7.5C6.625 6.87868 6.12132 6.375 5.5 6.375C4.87868 6.375 4.375 6.87868 4.375 7.5C4.375 8.12132 4.87868 8.625 5.5 8.625ZM10.625 11.5C10.625 12.1213 10.1213 12.625 9.5 12.625C8.87868 12.625 8.375 12.1213 8.375 11.5C8.375 10.8787 8.87868 10.375 9.5 10.375C10.1213 10.375 10.625 10.8787 10.625 11.5ZM5.5 12.625C6.12132 12.625 6.625 12.1213 6.625 11.5C6.625 10.8787 6.12132 10.375 5.5 10.375C4.87868 10.375 4.375 10.8787 4.375 11.5C4.375 12.1213 4.87868 12.625 5.5 12.625Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function SendIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M1.20308 1.04312C1.00481 0.954998 0.772341 1.0048 0.627577 1.16641C0.482813 1.32802 0.458794 1.56455 0.568117 1.75196L3.92115 7.50002L0.568117 13.2481C0.458794 13.4355 0.482813 13.672 0.627577 13.8336C0.772341 13.9952 1.00481 14.045 1.20308 13.9569L14.7031 7.95693C14.8836 7.87668 15 7.69762 15 7.50002C15 7.30243 14.8836 7.12337 14.7031 7.04312L1.20308 1.04312ZM4.84553 7.10002L2.21234 2.586L13.2689 7.50002L2.21234 12.414L4.84552 7.90002H9C9.22092 7.90002 9.4 7.72094 9.4 7.50002C9.4 7.27911 9.22092 7.10002 9 7.10002H4.84553Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
9
templates/branching-chat/client/constants.tsx
Normal file
9
templates/branching-chat/client/constants.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export const CONNECTION_CENTER_HANDLE_SIZE_PX = 16
|
||||
export const CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX = 24
|
||||
|
||||
export const NODE_WIDTH_PX = 400
|
||||
export const NODE_HEIGHT_PX = 54
|
||||
|
||||
export const PORT_RADIUS_PX = 6
|
||||
|
||||
export const DEFAULT_NODE_SPACING_PX = 60
|
||||
13
templates/branching-chat/client/disableTransparency.tsx
Normal file
13
templates/branching-chat/client/disableTransparency.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Editor, TLShape } from 'tldraw'
|
||||
|
||||
export function disableTransparency(editor: Editor, shapeTypes: string[]) {
|
||||
const shapeTypesSet = new Set(shapeTypes)
|
||||
|
||||
const forceOpaque = (shape: TLShape) =>
|
||||
shapeTypesSet.has(shape.type) && shape.opacity !== 1 ? { ...shape, opacity: 1 } : shape
|
||||
|
||||
editor.sideEffects.registerBeforeCreateHandler('shape', forceOpaque)
|
||||
editor.sideEffects.registerBeforeChangeHandler('shape', (_shapeOld, shapeNew) =>
|
||||
forceOpaque(shapeNew)
|
||||
)
|
||||
}
|
||||
86
templates/branching-chat/client/index.css
Normal file
86
templates/branching-chat/client/index.css
Normal file
@@ -0,0 +1,86 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
|
||||
@import url('tldraw/tldraw.css');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.tl-container {
|
||||
--node-shadows: 0 2px 6px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--node-shadow-border:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04), 0 2px 6px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--node-shadow-highlight:
|
||||
0 0 0 calc(1.2px / var(--tl-zoom)) var(--tl-color-selected), 0 2px 6px rgba(0, 0, 0, 0.08),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tl-container .tl-theme__light {
|
||||
--tl-color-handle-fill: hsla(210, 100%, 56%, 0.24);
|
||||
}
|
||||
.tl-container .tl-theme__dark {
|
||||
--tl-color-handle-fill: hsla(209, 100%, 57%, 0.2);
|
||||
}
|
||||
|
||||
.NodeShape {
|
||||
border-radius: var(--tl-radius-3);
|
||||
background: var(--tl-color-panel);
|
||||
box-shadow: var(--node-shadow-border);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.NodeShape-heading {
|
||||
padding: 0 var(--tl-space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--tl-space-4);
|
||||
height: 40px;
|
||||
margin-bottom: var(--tl-space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Port {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--tl-color-panel);
|
||||
box-sizing: border-box;
|
||||
z-index: 2;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.05s ease-in-out;
|
||||
|
||||
[data-state='select.idle'] & {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
&.Port_hinting {
|
||||
background: var(--tl-color-selected);
|
||||
}
|
||||
|
||||
&.Port_eligible {
|
||||
box-shadow:
|
||||
0 0 0 calc(1px / var(--tl-zoom)) var(--tl-color-selected),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.ConnectionShape {
|
||||
stroke-width: 2px;
|
||||
stroke: var(--tl-color-text-3);
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.ConnectionShapeIndicator {
|
||||
stroke-width: 2.1px;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
5
templates/branching-chat/client/main.tsx
Normal file
5
templates/branching-chat/client/main.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
|
||||
153
templates/branching-chat/client/nodes/NodeShapeUtil.tsx
Normal file
153
templates/branching-chat/client/nodes/NodeShapeUtil.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Circle2d,
|
||||
Group2d,
|
||||
HTMLContainer,
|
||||
RecordProps,
|
||||
Rectangle2d,
|
||||
ShapeUtil,
|
||||
TLShape,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { NODE_WIDTH_PX, PORT_RADIUS_PX } from '../constants'
|
||||
import { Port } from '../ports/Port'
|
||||
import { getNodePorts } from './nodePorts'
|
||||
import {
|
||||
getNodeDefinition,
|
||||
getNodeHeightPx,
|
||||
getNodeTypePorts,
|
||||
getNodeWidthPx,
|
||||
NodeType,
|
||||
} from './nodeTypes'
|
||||
|
||||
const NODE_TYPE = 'node'
|
||||
|
||||
declare module 'tldraw' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
[NODE_TYPE]: { node: NodeType }
|
||||
}
|
||||
}
|
||||
|
||||
// Define our custom node shape type that extends tldraw's base shape system
|
||||
export type NodeShape = TLShape<typeof NODE_TYPE>
|
||||
|
||||
// This class extends tldraw's ShapeUtil to define how our custom node shapes behave
|
||||
export class NodeShapeUtil extends ShapeUtil<NodeShape> {
|
||||
static override type = NODE_TYPE
|
||||
static override props: RecordProps<NodeShape> = {
|
||||
node: NodeType,
|
||||
}
|
||||
|
||||
getDefaultProps(): NodeShape['props'] {
|
||||
return {
|
||||
node: getNodeDefinition(this.editor, 'message').getDefault(),
|
||||
}
|
||||
}
|
||||
|
||||
override canEdit(_shape: NodeShape) {
|
||||
return false
|
||||
}
|
||||
override canResize(_shape: NodeShape) {
|
||||
return false
|
||||
}
|
||||
override hideResizeHandles(_shape: NodeShape) {
|
||||
return true
|
||||
}
|
||||
override hideRotateHandle(_shape: NodeShape) {
|
||||
return true
|
||||
}
|
||||
override hideSelectionBoundsBg(_shape: NodeShape) {
|
||||
return true
|
||||
}
|
||||
override hideSelectionBoundsFg(_shape: NodeShape) {
|
||||
return true
|
||||
}
|
||||
override isAspectRatioLocked(_shape: NodeShape) {
|
||||
return false
|
||||
}
|
||||
override getBoundsSnapGeometry(_shape: NodeShape) {
|
||||
return {
|
||||
points: [{ x: 0, y: 0 }],
|
||||
}
|
||||
}
|
||||
|
||||
// Define the geometry of our node shape including ports
|
||||
getGeometry(shape: NodeShape) {
|
||||
const ports = getNodePorts(this.editor, shape)
|
||||
|
||||
const portGeometries = Object.values(ports).map(
|
||||
(port) =>
|
||||
new Circle2d({
|
||||
x: port.x - PORT_RADIUS_PX,
|
||||
y: port.y - PORT_RADIUS_PX,
|
||||
radius: PORT_RADIUS_PX,
|
||||
isFilled: true,
|
||||
// not a label, but this hack excludes them from the selection bounds which is useful
|
||||
isLabel: true,
|
||||
})
|
||||
)
|
||||
|
||||
const bodyGeometry = new Rectangle2d({
|
||||
width: getNodeWidthPx(this.editor, shape),
|
||||
height: getNodeHeightPx(this.editor, shape),
|
||||
isFilled: true,
|
||||
})
|
||||
|
||||
return new Group2d({
|
||||
children: [bodyGeometry, ...portGeometries],
|
||||
})
|
||||
}
|
||||
|
||||
component(shape: NodeShape) {
|
||||
return <NodeShape shape={shape} />
|
||||
}
|
||||
|
||||
getIndicatorPath(shape: NodeShape) {
|
||||
const height = getNodeHeightPx(this.editor, shape)
|
||||
const path = new Path2D()
|
||||
path.rect(0, 0, NODE_WIDTH_PX, height)
|
||||
const ports = getNodePorts(this.editor, shape)
|
||||
for (const port of Object.values(ports)) {
|
||||
path.moveTo(port.x + PORT_RADIUS_PX, port.y)
|
||||
path.arc(port.x, port.y, PORT_RADIUS_PX, 0, Math.PI * 2)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Main node component that renders the HTML content
|
||||
function NodeShape({ shape }: { shape: NodeShape }) {
|
||||
const editor = useEditor()
|
||||
|
||||
return (
|
||||
<HTMLContainer
|
||||
className="NodeShape"
|
||||
style={{
|
||||
width: getNodeWidthPx(editor, shape),
|
||||
height: getNodeHeightPx(editor, shape),
|
||||
}}
|
||||
>
|
||||
<NodeBody shape={shape} />
|
||||
<NodePorts shape={shape} />
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeBody({ shape }: { shape: NodeShape }) {
|
||||
const node = shape.props.node
|
||||
const editor = useEditor()
|
||||
const { Component } = getNodeDefinition(editor, node)
|
||||
return <Component shape={shape} node={node} />
|
||||
}
|
||||
|
||||
function NodePorts({ shape }: { shape: NodeShape }) {
|
||||
const editor = useEditor()
|
||||
const ports = useValue('node ports', () => getNodeTypePorts(editor, shape), [shape, editor])
|
||||
return (
|
||||
<>
|
||||
{Object.values(ports).map((port) => (
|
||||
<Port key={port.id} shapeId={shape.id} port={port} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
96
templates/branching-chat/client/nodes/nodePorts.tsx
Normal file
96
templates/branching-chat/client/nodes/nodePorts.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* This file contains functions for working with ports and connections on nodes.
|
||||
*/
|
||||
import { createComputedCache, Editor, TLShapeId } from 'tldraw'
|
||||
import { getConnectionBindings } from '../connection/ConnectionBindingUtil'
|
||||
import { PortId } from '../ports/Port'
|
||||
import { NodeShape } from './NodeShapeUtil'
|
||||
import { getNodeTypePorts, NodeTypePorts } from './nodeTypes'
|
||||
|
||||
/**
|
||||
* Get the ports for a node. This is cached, because we only want to re-evaluate it when the
|
||||
* underlying records change.
|
||||
*/
|
||||
export function getNodePorts(editor: Editor, shape: NodeShape | TLShapeId): NodeTypePorts {
|
||||
return nodePortsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
|
||||
}
|
||||
const nodePortsCache = createComputedCache(
|
||||
'ports',
|
||||
(editor: Editor, node: NodeShape): NodeTypePorts => getNodeTypePorts(editor, node)
|
||||
)
|
||||
|
||||
/**
|
||||
* A connection from one node to another.
|
||||
*/
|
||||
export interface NodePortConnection {
|
||||
connectedShapeId: TLShapeId
|
||||
connectionId: TLShapeId
|
||||
terminal: 'start' | 'end'
|
||||
ownPortId: PortId
|
||||
connectedPortId: PortId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the connections for a node. This is cached.
|
||||
*/
|
||||
export function getNodePortConnections(
|
||||
editor: Editor,
|
||||
shape: NodeShape | TLShapeId
|
||||
): NodePortConnection[] {
|
||||
return nodePortConnectionsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? []
|
||||
}
|
||||
const nodePortConnectionsCache = createComputedCache(
|
||||
'port connections',
|
||||
(editor: Editor, node: NodeShape) => {
|
||||
const bindings = editor.getBindingsToShape(node.id, 'connection')
|
||||
|
||||
const connections: NodePortConnection[] = []
|
||||
for (const binding of bindings) {
|
||||
const oppositeTerminal = binding.props.terminal === 'start' ? 'end' : 'start'
|
||||
const oppositeBinding = getConnectionBindings(editor, binding.fromId)[oppositeTerminal]
|
||||
if (!oppositeBinding) continue
|
||||
|
||||
connections.push({
|
||||
connectedShapeId: oppositeBinding.toId,
|
||||
connectionId: binding.fromId,
|
||||
terminal: binding.props.terminal,
|
||||
ownPortId: binding.props.portId,
|
||||
connectedPortId: oppositeBinding.props.portId,
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
},
|
||||
{
|
||||
areRecordsEqual: (a, b) => a.id === b.id,
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Traverse the graph from a starting node, in a given direction, returning all the nodes that are
|
||||
* connected to it (including the starting node).
|
||||
*/
|
||||
export function getAllConnectedNodes(
|
||||
editor: Editor,
|
||||
startingNode: TLShapeId | NodeShape,
|
||||
direction?: 'start' | 'end'
|
||||
) {
|
||||
const toVisit = [typeof startingNode === 'string' ? startingNode : startingNode.id]
|
||||
const found = new Set<TLShapeId>()
|
||||
|
||||
while (toVisit.length > 0) {
|
||||
const nodeId = toVisit.shift()!
|
||||
const node = editor.getShape(nodeId)
|
||||
if (!node || !editor.isShapeOfType(node, 'node')) continue
|
||||
|
||||
if (found.has(node.id)) continue
|
||||
found.add(node.id)
|
||||
|
||||
for (const connection of getNodePortConnections(editor, node)) {
|
||||
if (direction && connection.terminal !== direction) continue
|
||||
toVisit.push(connection.connectedShapeId)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
67
templates/branching-chat/client/nodes/nodeTypes.tsx
Normal file
67
templates/branching-chat/client/nodes/nodeTypes.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Editor, T, WeakCache } from 'tldraw'
|
||||
import { PortId, shapePort } from '../ports/Port'
|
||||
import { NodeShape } from './NodeShapeUtil'
|
||||
import { MessageNodeDefinition } from './types/MessageNode'
|
||||
import { NodeDefinition, NodeDefinitionConstructor } from './types/shared'
|
||||
|
||||
/** All our node types */
|
||||
export const NodeDefinitions = {
|
||||
message: MessageNodeDefinition,
|
||||
} satisfies Record<string, NodeDefinitionConstructor<any>>
|
||||
|
||||
const nodeDefinitions = new WeakCache<
|
||||
Editor,
|
||||
{ [K in keyof typeof NodeDefinitions]: InstanceType<(typeof NodeDefinitions)[K]> }
|
||||
>()
|
||||
export function getNodeDefinitions(editor: Editor) {
|
||||
return nodeDefinitions.get(
|
||||
editor,
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.values(NodeDefinitions).map((value) => [value.type, new value(editor)])
|
||||
) as any
|
||||
)
|
||||
}
|
||||
|
||||
export function getNodeDefinition(
|
||||
editor: Editor,
|
||||
node: NodeType | NodeType['type']
|
||||
): NodeDefinition<NodeType> {
|
||||
return getNodeDefinitions(editor)[
|
||||
typeof node === 'string' ? node : node.type
|
||||
] as NodeDefinition<NodeType>
|
||||
}
|
||||
|
||||
/**
|
||||
* A union type of all our node types.
|
||||
*/
|
||||
export type NodeType = T.TypeOf<typeof NodeType>
|
||||
export const NodeType = T.union(
|
||||
'type',
|
||||
Object.fromEntries(Object.values(NodeDefinitions).map((type) => [type.type, type.validator])) as {
|
||||
[K in keyof typeof NodeDefinitions as (typeof NodeDefinitions)[K]['type']]: (typeof NodeDefinitions)[K]['validator']
|
||||
}
|
||||
)
|
||||
|
||||
export function getNodeHeightPx(editor: Editor, shape: NodeShape): number {
|
||||
return getNodeDefinition(editor, shape.props.node).getBodyHeightPx(shape, shape.props.node)
|
||||
}
|
||||
|
||||
export function getNodeWidthPx(editor: Editor, shape: NodeShape): number {
|
||||
return getNodeDefinition(editor, shape.props.node).getBodyWidthPx(shape, shape.props.node)
|
||||
}
|
||||
|
||||
const _nodeTypePorts = T.dict(T.string, shapePort)
|
||||
export type NodeTypePorts = T.TypeOf<typeof _nodeTypePorts>
|
||||
|
||||
export function getNodeTypePorts(editor: Editor, shape: NodeShape): NodeTypePorts {
|
||||
return getNodeDefinition(editor, shape.props.node).getPorts(shape, shape.props.node)
|
||||
}
|
||||
|
||||
export function onNodePortConnect(editor: Editor, shape: NodeShape, port: PortId) {
|
||||
getNodeDefinition(editor, shape.props.node).onPortConnect?.(shape, shape.props.node, port)
|
||||
}
|
||||
|
||||
export function onNodePortDisconnect(editor: Editor, shape: NodeShape, port: PortId) {
|
||||
getNodeDefinition(editor, shape.props.node).onPortDisconnect?.(shape, shape.props.node, port)
|
||||
}
|
||||
209
templates/branching-chat/client/nodes/types/MessageNode.tsx
Normal file
209
templates/branching-chat/client/nodes/types/MessageNode.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ModelMessage } from 'ai'
|
||||
import { useCallback } from 'react'
|
||||
import { T, TldrawUiButton, TldrawUiButtonIcon, TldrawUiInput, useEditor } from 'tldraw'
|
||||
import { HandleIcon } from '../../components/icons/HandleIcon'
|
||||
import { SendIcon } from '../../components/icons/SendIcon'
|
||||
import { NODE_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
|
||||
import { getAllConnectedNodes } from '../nodePorts'
|
||||
import { NodeShape } from '../NodeShapeUtil'
|
||||
import {
|
||||
NodeComponentProps,
|
||||
NodeDefinition,
|
||||
shapeInputPort,
|
||||
shapeOutputPort,
|
||||
updateNode,
|
||||
} from './shared'
|
||||
|
||||
/**
|
||||
* This node is a message from the user.
|
||||
*/
|
||||
export type MessageNode = T.TypeOf<typeof MessageNode>
|
||||
export const MessageNode = T.object({
|
||||
type: T.literal('message'),
|
||||
userMessage: T.string,
|
||||
assistantMessage: T.string,
|
||||
})
|
||||
|
||||
export class MessageNodeDefinition extends NodeDefinition<MessageNode> {
|
||||
static type = 'message'
|
||||
static validator = MessageNode
|
||||
title = 'Message'
|
||||
heading = 'Message'
|
||||
icon = <SendIcon />
|
||||
getDefault(): MessageNode {
|
||||
return {
|
||||
type: 'message',
|
||||
userMessage: 'hello',
|
||||
assistantMessage: '',
|
||||
}
|
||||
}
|
||||
getBodyWidthPx(_shape: NodeShape, _node: MessageNode): number {
|
||||
return NODE_WIDTH_PX
|
||||
}
|
||||
getBodyHeightPx(_shape: NodeShape, node: MessageNode): number {
|
||||
const assistantMessage = node.assistantMessage.trim()
|
||||
if (assistantMessage === '') return NODE_HEIGHT_PX
|
||||
const size = this.editor.textMeasure.measureText(assistantMessage, {
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontStyle: 'normal',
|
||||
maxWidth: NODE_WIDTH_PX,
|
||||
lineHeight: 1.3,
|
||||
padding: '12px',
|
||||
})
|
||||
return NODE_HEIGHT_PX + size.h
|
||||
}
|
||||
getPorts(shape: NodeShape, node: MessageNode) {
|
||||
return {
|
||||
input: shapeInputPort,
|
||||
output: {
|
||||
...shapeOutputPort,
|
||||
y: this.getBodyHeightPx(shape, node),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Component = MessageNodeComponent
|
||||
}
|
||||
|
||||
function MessageNodeComponent({ node, shape }: NodeComponentProps<MessageNode>) {
|
||||
const editor = useEditor()
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
// 1. gather up parents and create message history
|
||||
// 2. create prompt
|
||||
// 3. send prompt to ai
|
||||
// 4. update node with assistant message
|
||||
|
||||
const messages: ModelMessage[] = []
|
||||
for (const connectedShape of getAllConnectedNodes(editor, shape, 'end')) {
|
||||
const node = editor.getShape(connectedShape)
|
||||
if (!node || !editor.isShapeOfType(node, 'node')) continue
|
||||
if (node.props.node.type !== 'message') continue
|
||||
|
||||
if (node.props.node.assistantMessage && connectedShape !== shape.id) {
|
||||
messages.push({ role: 'assistant', content: node.props.node.assistantMessage })
|
||||
}
|
||||
messages.push({ role: 'user', content: node.props.node.userMessage })
|
||||
}
|
||||
messages.reverse()
|
||||
|
||||
// clear any previous assistant message before starting
|
||||
updateNode<MessageNode>(editor, shape, (node) => ({
|
||||
...node,
|
||||
assistantMessage: '...',
|
||||
}))
|
||||
|
||||
// stream the response and append as chunks arrive
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch('/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(messages),
|
||||
})
|
||||
if (!response.body) return
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedText = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
// Some environments may send SSE-style lines; extract data if so, else use raw chunk
|
||||
const maybeSse = chunk
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.replace(/^data:\s?/, ''))
|
||||
.join('')
|
||||
accumulatedText += maybeSse || chunk
|
||||
updateNode<MessageNode>(editor, shape, (node) => ({
|
||||
...node,
|
||||
assistantMessage: accumulatedText,
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})()
|
||||
}, [editor, shape])
|
||||
|
||||
const handleMessageChange = useCallback(
|
||||
(value: string) => {
|
||||
updateNode<MessageNode>(editor, shape, (node) => ({
|
||||
...node,
|
||||
userMessage: value,
|
||||
}))
|
||||
},
|
||||
[editor, shape]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: 32,
|
||||
paddingLeft: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={<HandleIcon />} />
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '4px 0px 0px 4px', flexGrow: 2 }}
|
||||
onPointerDown={editor.markEventAsHandled}
|
||||
>
|
||||
<div style={{ padding: '0px 12px', borderRadius: 6, border: '1px solid #e2e8f0' }}>
|
||||
<TldrawUiInput
|
||||
value={node.userMessage}
|
||||
onValueChange={handleMessageChange}
|
||||
onComplete={handleSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0px 0px' }}>
|
||||
<TldrawUiButton
|
||||
type="primary"
|
||||
onClick={handleSend}
|
||||
onPointerDown={editor.markEventAsHandled}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={<SendIcon />} />
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
</div>
|
||||
{node.assistantMessage && (
|
||||
<div style={{ padding: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
lineHeight: '1.3',
|
||||
fontSize: '12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'Inter',
|
||||
overflowWrap: 'normal',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{node.assistantMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
templates/branching-chat/client/nodes/types/shared.tsx
Normal file
73
templates/branching-chat/client/nodes/types/shared.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Editor, T, TLUiIconJsx } from 'tldraw'
|
||||
import { NODE_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
|
||||
import { PortId, ShapePort } from '../../ports/Port'
|
||||
import { NodeShape } from '../NodeShapeUtil'
|
||||
import { NodeType, NodeTypePorts } from '../nodeTypes'
|
||||
|
||||
export interface NodeComponentProps<Node extends { type: string }> {
|
||||
shape: NodeShape
|
||||
node: Node
|
||||
}
|
||||
|
||||
export abstract class NodeDefinition<Node extends { type: string }> {
|
||||
constructor(public readonly editor: Editor) {
|
||||
const ctor = this.constructor as NodeDefinitionConstructor<Node>
|
||||
this.type = ctor.type
|
||||
this.validator = ctor.validator
|
||||
}
|
||||
|
||||
readonly type: Node['type']
|
||||
readonly validator: T.Validator<Node>
|
||||
abstract readonly title: string
|
||||
abstract readonly heading?: string
|
||||
abstract readonly icon: TLUiIconJsx
|
||||
|
||||
abstract getDefault(): Node
|
||||
abstract getBodyWidthPx(shape: NodeShape, node: Node): number
|
||||
abstract getBodyHeightPx(shape: NodeShape, node: Node): number
|
||||
abstract getPorts(shape: NodeShape, node: Node): NodeTypePorts
|
||||
onPortConnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
|
||||
onPortDisconnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
|
||||
abstract Component: React.ComponentType<NodeComponentProps<Node>>
|
||||
}
|
||||
|
||||
export interface NodeDefinitionConstructor<Node extends { type: string }> {
|
||||
new (editor: Editor): NodeDefinition<Node>
|
||||
readonly type: Node['type']
|
||||
readonly validator: T.Validator<Node>
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard input port for a node.
|
||||
*/
|
||||
export const shapeInputPort: ShapePort = {
|
||||
id: 'input',
|
||||
terminal: 'end',
|
||||
x: NODE_WIDTH_PX / 2,
|
||||
y: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard output port for a node.
|
||||
*/
|
||||
export const shapeOutputPort: ShapePort = {
|
||||
id: 'output',
|
||||
terminal: 'start',
|
||||
x: NODE_WIDTH_PX / 2,
|
||||
y: NODE_HEIGHT_PX,
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `node` prop within a node shape.
|
||||
*/
|
||||
export function updateNode<T extends NodeType>(
|
||||
editor: Editor,
|
||||
shape: NodeShape,
|
||||
update: (node: T) => T
|
||||
) {
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { node: update(shape.props.node as T) },
|
||||
})
|
||||
}
|
||||
150
templates/branching-chat/client/ports/PointingPort.tsx
Normal file
150
templates/branching-chat/client/ports/PointingPort.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createShapeId, StateNode, TLPointerEventInfo, TLShapeId } from 'tldraw'
|
||||
import { createOrUpdateConnectionBinding } from '../connection/ConnectionBindingUtil.tsx'
|
||||
import { createNodeAtConnectionEnd } from '../connection/ConnectionShapeUtil.tsx'
|
||||
import { getNextConnectionIndex } from '../connection/keepConnectionsAtBottom.tsx'
|
||||
import { DEFAULT_NODE_SPACING_PX } from '../constants.tsx'
|
||||
import { getNodePortConnections } from '../nodes/nodePorts.tsx'
|
||||
import { PortId } from './Port.tsx'
|
||||
|
||||
// Information about which port is being pointed at
|
||||
interface PointingPortInfo {
|
||||
shapeId: TLShapeId
|
||||
portId: PortId
|
||||
terminal: 'start' | 'end'
|
||||
}
|
||||
|
||||
// State node that handles pointing at ports to create connections
|
||||
// This will be added to tldraw's state machine to customize the built-in select tool
|
||||
export class PointingPort extends StateNode {
|
||||
static override id = 'pointing_port'
|
||||
|
||||
info?: PointingPortInfo
|
||||
|
||||
override onEnter(info: PointingPortInfo): void {
|
||||
this.info = info
|
||||
}
|
||||
|
||||
private getExistingConnection() {
|
||||
const { shapeId, portId } = this.info!
|
||||
return getNodePortConnections(this.editor, shapeId).find((c) => c.ownPortId === portId)
|
||||
}
|
||||
|
||||
override onPointerMove(info: TLPointerEventInfo): void {
|
||||
// isDragging is true if the user has moved the pointer sufficiently. below this threshold,
|
||||
// we treat the pointer as a click.
|
||||
if (!this.editor.inputs.getIsDragging()) return
|
||||
const { shapeId, portId, terminal: connectingTerminal } = this.info!
|
||||
|
||||
const existingConnection = this.getExistingConnection()
|
||||
|
||||
// If we can't have multiple connections and one already exists, move the existing
|
||||
// connection by transitioning to dragging the existing connection's handle.
|
||||
if (connectingTerminal !== 'start' && existingConnection) {
|
||||
this.parent.transition('dragging_handle', {
|
||||
...info,
|
||||
target: 'handle',
|
||||
shape: this.editor.getShape(existingConnection.connectionId)!,
|
||||
handle: this.editor
|
||||
.getShapeHandles(existingConnection.connectionId)!
|
||||
.find((h) => h.id === connectingTerminal),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, create a new connection, and start dragging that connection's handle instead.
|
||||
const creatingMarkId = this.editor.markHistoryStoppingPoint()
|
||||
const connectionShapeId = createShapeId()
|
||||
const draggingTerminal = connectingTerminal === 'start' ? 'end' : 'start'
|
||||
const pagePoint = this.editor.inputs.getCurrentPagePoint()
|
||||
|
||||
this.editor.createShape({
|
||||
type: 'connection',
|
||||
id: connectionShapeId,
|
||||
x: pagePoint.x,
|
||||
y: pagePoint.y,
|
||||
index: getNextConnectionIndex(this.editor),
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
// bind one end of the connection to the port the user pointer-down'd on.
|
||||
createOrUpdateConnectionBinding(this.editor, connectionShapeId, shapeId, {
|
||||
portId,
|
||||
terminal: connectingTerminal,
|
||||
})
|
||||
|
||||
// transition to dragging the other end of the connection:
|
||||
const handle = this.editor
|
||||
.getShapeHandles(connectionShapeId)
|
||||
?.find((h) => h.id === draggingTerminal)
|
||||
|
||||
this.parent.transition('dragging_handle', {
|
||||
...info,
|
||||
target: 'handle',
|
||||
shape: this.editor.getShape(connectionShapeId)!,
|
||||
handle: handle!,
|
||||
creatingMarkId,
|
||||
isCreating: true,
|
||||
})
|
||||
}
|
||||
|
||||
override onPointerUp(info: TLPointerEventInfo): void {
|
||||
// if we get a pointer up while we're still in this state, it means we haven't transitioned
|
||||
// into a dragging state so we'll treat this as a click:
|
||||
this.onClick()
|
||||
// switch back to the idle state when we're done:
|
||||
this.parent.transition('idle', info)
|
||||
}
|
||||
|
||||
// Handle clicks on ports (without dragging)
|
||||
private onClick() {
|
||||
// Only handle clicks on start ports (output ports)
|
||||
if (this.info?.terminal !== 'start') return
|
||||
|
||||
// Don't create new connections if one already exists
|
||||
if (this.getExistingConnection()) return
|
||||
const { shapeId, portId } = this.info
|
||||
|
||||
// Get the bounds of the source node
|
||||
const bounds = this.editor.getShapePageBounds(shapeId)
|
||||
if (!bounds) return
|
||||
|
||||
// Calculate position for new node to the right of the source node
|
||||
const targetPositionInPageSpace = {
|
||||
x: bounds.midX,
|
||||
y: bounds.bottom + DEFAULT_NODE_SPACING_PX,
|
||||
}
|
||||
|
||||
// Create a connection shape
|
||||
const connectionShapeId = createShapeId()
|
||||
this.editor.createShape({
|
||||
type: 'connection',
|
||||
id: connectionShapeId,
|
||||
x: bounds.right,
|
||||
y: bounds.top,
|
||||
index: getNextConnectionIndex(this.editor),
|
||||
})
|
||||
|
||||
// Bind the connection to the source port
|
||||
createOrUpdateConnectionBinding(this.editor, connectionShapeId, shapeId, {
|
||||
portId,
|
||||
terminal: 'start',
|
||||
})
|
||||
|
||||
// Position the connection end point where the new node will be
|
||||
const targetPositionInConnectionSpace = this.editor
|
||||
.getPointInShapeSpace(connectionShapeId, targetPositionInPageSpace)
|
||||
.addXY(0, 200)
|
||||
this.editor.updateShape({
|
||||
id: connectionShapeId,
|
||||
type: 'connection',
|
||||
props: {
|
||||
end: targetPositionInConnectionSpace.toJson(),
|
||||
},
|
||||
})
|
||||
|
||||
createNodeAtConnectionEnd(this.editor, connectionShapeId, targetPositionInPageSpace)
|
||||
}
|
||||
}
|
||||
80
templates/branching-chat/client/ports/Port.tsx
Normal file
80
templates/branching-chat/client/ports/Port.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import classNames from 'classnames'
|
||||
import { T, TLShapeId, useEditor, useValue } from 'tldraw'
|
||||
import { getNodePortConnections } from '../nodes/nodePorts'
|
||||
import { portState } from './portState'
|
||||
|
||||
export type PortId = string
|
||||
|
||||
export const shapePort = T.object({
|
||||
id: T.string,
|
||||
terminal: T.literalEnum('start', 'end'),
|
||||
x: T.number,
|
||||
y: T.number,
|
||||
})
|
||||
|
||||
export type ShapePort = T.TypeOf<typeof shapePort>
|
||||
|
||||
/**
|
||||
* Port ids are unique within a shape. To identify a port we need both the shape id and the port id.
|
||||
*/
|
||||
export interface PortIdentifier {
|
||||
shapeId: TLShapeId
|
||||
portId: PortId
|
||||
}
|
||||
|
||||
/**
|
||||
* This react component renders a port.
|
||||
*/
|
||||
export function Port({ shapeId, port }: { shapeId: TLShapeId; port: ShapePort }) {
|
||||
const editor = useEditor()
|
||||
|
||||
// isHinting is true if the user is currently dragging a connection to this port. it means we
|
||||
// should highlight this port.
|
||||
const isHinting = useValue(
|
||||
'isHinting',
|
||||
() => {
|
||||
const { hintingPort } = portState.get(editor)
|
||||
return hintingPort && hintingPort.portId === port.id && hintingPort.shapeId === shapeId
|
||||
},
|
||||
[editor, shapeId, port.id]
|
||||
)
|
||||
|
||||
// isEligible is true if the the user is currently dragging a connection, and this port is one
|
||||
// that the connection can be connected to.
|
||||
const isEligible = useValue(
|
||||
'isEligible',
|
||||
() => {
|
||||
const { eligiblePorts } = portState.get(editor)
|
||||
if (!eligiblePorts) return false
|
||||
if (eligiblePorts.terminal !== port.terminal) return false
|
||||
if (eligiblePorts.excludeNodes?.has(shapeId)) return false
|
||||
if (port.terminal === 'end') {
|
||||
// if the port is an end port, it can only have one connection, so it's not eligible
|
||||
// when there is a connection
|
||||
const connections = getNodePortConnections(editor, shapeId)
|
||||
return !connections.some((c) => c.ownPortId === port.id)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[editor, shapeId, port.id, port.terminal]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`Port`,
|
||||
isHinting ? 'Port_hinting' : isEligible ? 'Port_eligible' : undefined
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${port.x}px, ${port.y}px)`,
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
editor.setCurrentTool('select.pointing_port', {
|
||||
shapeId,
|
||||
portId: port.id,
|
||||
terminal: port.terminal,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
48
templates/branching-chat/client/ports/getPortAtPoint.tsx
Normal file
48
templates/branching-chat/client/ports/getPortAtPoint.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Editor, Vec, VecLike } from 'tldraw'
|
||||
import { getNodePortConnections, getNodePorts } from '../nodes/nodePorts'
|
||||
import { ShapePort } from './Port'
|
||||
|
||||
export function getPortAtPoint(
|
||||
editor: Editor,
|
||||
point: VecLike,
|
||||
opts?: { terminal?: 'start' | 'end'; margin?: number }
|
||||
) {
|
||||
// find the shape at that point:
|
||||
const shape = editor.getShapeAtPoint(point, {
|
||||
hitInside: true,
|
||||
// only node shapes can have ports
|
||||
filter: (shape) => editor.isShapeOfType(shape, 'node'),
|
||||
...opts,
|
||||
})
|
||||
if (!shape || !editor.isShapeOfType(shape, 'node')) return null
|
||||
|
||||
// get the ports on that shape
|
||||
const ports = getNodePorts(editor, shape)
|
||||
|
||||
// transform the ports to page space
|
||||
const shapeTransform = editor.getShapePageTransform(shape)
|
||||
|
||||
// find the port closest to the point
|
||||
let bestPort: ShapePort | null = null
|
||||
let bestDistance = Infinity
|
||||
|
||||
for (const port of Object.values(ports)) {
|
||||
if (opts?.terminal && port.terminal !== opts.terminal) continue
|
||||
|
||||
const portInPageSpace = shapeTransform.applyToPoint(port)
|
||||
const distance = Vec.Dist(point, portInPageSpace)
|
||||
if (distance < bestDistance) {
|
||||
bestPort = port
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
// if we didn't find a port, return null
|
||||
if (!bestPort) return null
|
||||
|
||||
// otherwise, return the port and it's existing connections
|
||||
const existingConnections = getNodePortConnections(editor, shape).filter(
|
||||
(c) => c.ownPortId === bestPort.id
|
||||
)
|
||||
return { shape, port: bestPort, existingConnections }
|
||||
}
|
||||
24
templates/branching-chat/client/ports/portState.ts
Normal file
24
templates/branching-chat/client/ports/portState.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Editor, EditorAtom, TLShapeId } from 'tldraw'
|
||||
import { PortIdentifier } from './Port'
|
||||
|
||||
/**
|
||||
* The UI state for ports. These mostly highlight ports relevant to the user's current action.
|
||||
*/
|
||||
export interface PortState {
|
||||
// hintingPort is the port that the user is currently dragging a connection to.
|
||||
hintingPort: PortIdentifier | null
|
||||
// eligiblePorts is the set of ports that the user can connect a new connection to.
|
||||
eligiblePorts: {
|
||||
terminal: 'start' | 'end'
|
||||
excludeNodes: Set<TLShapeId> | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export const portState = new EditorAtom<PortState>('port state', () => ({
|
||||
hintingPort: null,
|
||||
eligiblePorts: null,
|
||||
}))
|
||||
|
||||
export function updatePortState(editor: Editor, update: Partial<PortState>) {
|
||||
portState.update(editor, (state) => ({ ...state, ...update }))
|
||||
}
|
||||
Reference in New Issue
Block a user