snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

3
templates/workflow/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/*/node_modules
node_modules
.yarn/

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,78 @@
<div alt style="text-align: center; transform: scale(.5);">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-dark.png" />
<img alt="tldraw" src="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-light.png" />
</picture>
</div>
This repo contains a starter-kit for making a workflow type application using [tldraw](https://github.com/tldraw/tldraw).
## Local development
Install dependencies with `yarn` or `npm install`.
Run the development server with `yarn dev` or `npm run dev`.
Open `http://localhost:5173/` in your browser to see the app.
This starter kit has 3 main concepts:
1. Nodes: Nodes are shapes that accept inputs, and produce outputs.
2. Connections: A connection joins the output of one node to the input of another.
3. Ports: Ports are places on nodes that connections can join to.
There are lots of useful interactions the user can take involving these concepts:
- Clicking on an output port lets the user create a new node
- Dragging from a port lets the user create a new connection
- Dragging from an output port lets the user create a new node
- Clicking in the middle of a port lets the user create a new node
- Dragging a connection lets the user connect it to a different node, or disconnect it entirely
We also include a demonstration of how information might flow through a graph of nodes. Nodes update instantly to show their results. There's also a "play" button to show how execution in a real service that was making API calls might work.
## File structure
- **`src/App.tsx`:** The main entry-point of the app. This renders the `<Tldraw />` component and brings in all the customizations the make this a workflow app.
- **`src/nodes`:** Everything related to nodes
- **`src/nodes/NodeShapeUtil.tsx`:** The node shape util, which defines a custom tldraw shape for our nodes.
- **`src/nodes/nodePorts.tsx`:** Helpers for efficiently working with the ports on a node, and the values flowing through them.
- **`src/nodes/types`:** Each different node type. We have nodes for basic math operations, setting number with a slider, and conditionals.
- **`src/connection`:** Everything related to connections.
- **`src/connection/ConnectionShapeUtil.tsx`:** The connection shape util, which defines a custom tldraw shape for our connections.
- **`src/connection/ConnectionBindingUtil.tsx`:** The connection binding util. A binding is how tldraw models relationships between shapes. Each connection has two bindings - one to the start node, and one to the end node.
- **`src/connection/insertNodeWithinConnection.tsx`:** A helper for inserting a new node in the middle of a connection.
- **`src/connection/keepConnectionsAtBottom.tsx`:** A set of tldraw side effect listeners that make sure connection are below all other shapes in the z-order.
- **`src/ports`:** Everything related to ports.
- **`src/ports/Port.tsx`:** Defines the types for ports, and a React component for rendering them.
- **`src/ports/PointingPort.tsx`:** An entry in tldraw's interaction state tree which extends the built-in select tool with custom behavior when interacting with ports.
- **`src/execution`:** Everything related to async execution of workflow graphs
- **`src/execution/ExecutionGraph.tsx`:** The execution graph, which runs workflows.
- **`src/components`:** UI and on-canvas react components.
- **`src/components/WorkflowToolbar.tsx`:** A replacement for tldraw's default toolbar. This one renders vertically down the left of the screen, and emphasizes our workflow tools.
- **`src/components/OnCanvasComponentPicker.tsx`:** A panel that appears on the canvas that lets users choose a new node to insert. This is used with some of the on-canvas interactions described above.
- **`src/components/WorkflowRegions.tsx`:** Finds sets of connected nodes that forms workflows, draws a box around them, and provides a "play" button for running the workflow.
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/vite-template/blob/main/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,23 @@
# Stuff I want in the SDK after building this
1. ~~Some way to have state scoped to the lifecycle of an editor. We do this a fair bit in the SDK, and it's ad-hoc every time. `EditorState` worked well here~~.
2. `canTranslate` & `canDuplicate` style flags. These operations don't really make sense for connections, but i can't disable them comprehensively.
3. ~~`onHandleDragStart`, `onHandleDragComplete`, `onHandleDragCancel`. Maybe some way of attaching state to these too? It'd be nice to have the full set for all of this genre of callback. `cancel` is important for state management.~~
4. ~~`isCreatingShape` flag for handle dragging~~
5. ~~A better way to insert nodes into the state graph~~
6. More custom ways of controlling snapping & how snap lines render. Snap _lines_ rather than points, maybe?
7. Fast spacial querying e.g. "get me all shapes in this bounding box"
8. `getIndices(n)` returns n + 1 indices which feels very counter intuitive.
9. Generally I'd like an easier way to work with "multiplayer arrays" - objects where the keys are fractional indexes. I ended up writing some of my own helpers for this.
10. A way to have things in geometry that don't contribute to `bounds` calculations. I achieved this by marking them as labels, but that feels wrong maybe?
11. ~~A way to pass custom JSX in the place of icon names to anything that expects an icon~~
12. A canonical way (or at least an example) to have the size of an element derived from how it's rendered in the DOM.
13. A better way of having geometry derived from the DOM (ie port locations).
14. Hide resize handles when no selected items are resizable.
15. Disable rotation?
16. ~~A better way of doing vertical toolbars~~ with overflow
# Other things I maybe want to do
1. API example showing inserting a state node
2. "Ports" addon library

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tldraw workflow builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
{
"name": "tldraw-workflow",
"private": true,
"version": "0.0.0",
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "MIT",
"type": "module",
"tldraw_template": {
"repo": "tldraw/workflow-template",
"cli": {
"name": "Workflow",
"description": "Create a node-based builder for visual workflows.",
"shortDescription": "Visual node-based builder for workflows.",
"order": 4
},
"scripts": {
"lint": null
}
},
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"preview": "vite preview"
},
"dependencies": {
"classnames": "^2.5.1",
"radix-ui": "^1.4.2",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tldraw": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^5.8.3",
"vite": "^8.0.16"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,108 @@
import {
DefaultActionsMenu,
DefaultQuickActions,
DefaultStylePanel,
TLComponents,
Tldraw,
TldrawOptions,
TldrawUiToolbar,
useEditor,
useValue,
} from 'tldraw'
import { OnCanvasComponentPicker } from './components/OnCanvasComponentPicker.tsx'
import { WorkflowRegions } from './components/WorkflowRegions.tsx'
import { overrides, WorkflowToolbar } from './components/WorkflowToolbar.tsx'
import { ConnectionBindingUtil } from './connection/ConnectionBindingUtil'
import { ConnectionCenterHandleOverlayUtil } from './connection/ConnectionCenterHandleOverlayUtil'
import { ConnectionShapeUtil } from './connection/ConnectionShapeUtil'
import { keepConnectionsAtBottom } from './connection/keepConnectionsAtBottom'
import { disableTransparency } from './disableTransparency.tsx'
import { NodeShapeUtil } from './nodes/NodeShapeUtil'
import { PointingPort } from './ports/PointingPort'
// 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 = {
InFrontOfTheCanvas: () => (
<>
<OnCanvasComponentPicker />
<WorkflowRegions />
</>
),
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',
() => {
return (
!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 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)
// 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

View File

@@ -0,0 +1,54 @@
import {
TldrawUiPopover,
TldrawUiPopoverContent,
TldrawUiPopoverTrigger,
TldrawUiToolbar,
TldrawUiToolbarButton,
tlmenus,
ToolbarItem,
useEditor,
useValue,
} from 'tldraw'
import { MathematicalIcon } from './icons/MathematicalIcon'
export const MATH_MENU_ID = 'toolbar mathematical'
// Custom toolbar item that provides mathematical operation nodeslity
export function MathematicalToolbarItem() {
const id = 'mathematical'
const labelStr = 'Math'
const editor = useEditor()
const isOpen = useValue('isOpen', () => tlmenus.isMenuOpen(MATH_MENU_ID, editor.contextId), [
editor,
])
return (
<TldrawUiPopover
id={MATH_MENU_ID}
open={isOpen}
onOpenChange={() => {
tlmenus.addOpenMenu(MATH_MENU_ID, editor.contextId)
}}
>
<TldrawUiPopoverTrigger>
<TldrawUiToolbarButton
aria-label={labelStr}
data-testid={`tools.${id}`}
data-value={id}
title={labelStr}
type="tool"
>
<MathematicalIcon />
</TldrawUiToolbarButton>
</TldrawUiPopoverTrigger>
<TldrawUiPopoverContent side="right" align="center">
<TldrawUiToolbar label={labelStr} id={`${id}_math`}>
<ToolbarItem tool="node-add" />
<ToolbarItem tool="node-subtract" />
<ToolbarItem tool="node-multiply" />
<ToolbarItem tool="node-divide" />
</TldrawUiToolbar>
</TldrawUiPopoverContent>
</TldrawUiPopover>
)
}

View File

@@ -0,0 +1,186 @@
import { Dialog, VisuallyHidden } from 'radix-ui'
import { useCallback, useMemo, useState } from 'react'
import {
TldrawUiButton,
TldrawUiButtonIcon,
TldrawUiButtonLabel,
TldrawUiMenuContextProvider,
TldrawUiMenuGroup,
TLShapeId,
useEditor,
usePassThroughWheelEvents,
useQuickReactor,
useValue,
Vec,
VecModel,
} from 'tldraw'
import { getConnectionTerminals } from '../connection/ConnectionShapeUtil'
import { NODE_WIDTH_PX } from '../constants'
import { getNodeDefinitions, NodeType } from '../nodes/nodeTypes'
import { NodeDefinition } from '../nodes/types/shared'
import { EditorAtom } from '../utils'
export interface OnCanvasComponentPickerState {
connectionShapeId: TLShapeId
location: 'start' | 'end' | 'middle'
onPick: (nodeType: NodeType, position: VecModel) => void
onClose: () => void
}
export const onCanvasComponentPickerState = new EditorAtom<OnCanvasComponentPickerState | null>(
'on canvas component picker',
() => null
)
// Component picker that appears when users drag connection handles without connecting to existing ports
export function OnCanvasComponentPicker() {
const editor = useEditor()
const onClose = useCallback(() => {
const state = onCanvasComponentPickerState.get(editor)
if (!state) return
onCanvasComponentPickerState.set(editor, null)
state.onClose()
}, [editor])
const nodeDefs = getNodeDefinitions(editor)
return (
<OnCanvasComponentPickerDialog onClose={onClose}>
<TldrawUiMenuGroup id="math">
<OnCanvasComponentPickerItem definition={nodeDefs.add} onClose={onClose} />
<OnCanvasComponentPickerItem definition={nodeDefs.subtract} onClose={onClose} />
<OnCanvasComponentPickerItem definition={nodeDefs.multiply} onClose={onClose} />
<OnCanvasComponentPickerItem definition={nodeDefs.divide} onClose={onClose} />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="logic">
<OnCanvasComponentPickerItem definition={nodeDefs.conditional} onClose={onClose} />
</TldrawUiMenuGroup>
</OnCanvasComponentPickerDialog>
)
}
// Dialog component that positions itself at the connection terminal
function OnCanvasComponentPickerDialog({
children,
onClose,
}: {
children: React.ReactNode
onClose: () => void
}) {
const editor = useEditor()
const location = useValue('location', () => onCanvasComponentPickerState.get(editor)?.location, [
editor,
])
const shouldRender = !!location
const [container, setContainer] = useState<HTMLDivElement | null>(null)
// Allow wheel events to pass through to the canvas
usePassThroughWheelEvents(useMemo(() => ({ current: container }), [container]))
// Reactively update the dialog position when the connection or viewport changes
useQuickReactor(
'OnCanvasComponentPicker',
() => {
const state = onCanvasComponentPickerState.get(editor)
if (!state) return
if (!container) return
const connection = editor.getShape(state.connectionShapeId)
if (!connection || !editor.isShapeOfType(connection, 'connection')) {
onClose()
return
}
// Get the connection terminals in connection space
const terminals = getConnectionTerminals(editor, connection)
const terminalInConnectionSpace =
state.location === 'middle'
? Vec.Lrp(terminals.start, terminals.end, 0.5)
: terminals[state.location]
// Transform the position from connection space to page space
const terminalInPageSpace = editor
.getShapePageTransform(connection)
.applyToPoint(terminalInConnectionSpace)
// Transform from page space to viewport space for positioning the dialog
const terminalInViewportSpace = editor.pageToViewport(terminalInPageSpace)
container.style.transform = `translate(${terminalInViewportSpace.x}px, ${terminalInViewportSpace.y}px) scale(${editor.getZoomLevel()}) `
},
[editor, container]
)
return (
<Dialog.Root
open={shouldRender}
modal={false}
onOpenChange={(isOpen) => {
if (!isOpen) onClose()
}}
>
<Dialog.Content
ref={setContainer}
className={`OnCanvasComponentPicker OnCanvasComponentPicker_${location}`}
style={{ width: NODE_WIDTH_PX }}
>
<div className="OnCanvasComponentPicker-content">
<VisuallyHidden.Root>
<Dialog.Title>Insert Node</Dialog.Title>
</VisuallyHidden.Root>
<TldrawUiMenuContextProvider sourceId="dialog" type="menu">
{children}
</TldrawUiMenuContextProvider>
</div>
</Dialog.Content>
</Dialog.Root>
)
}
// Individual menu item for selecting a node type
function OnCanvasComponentPickerItem<T extends NodeType>({
definition,
onClose,
}: {
definition: NodeDefinition<T>
onClose: () => void
}) {
const editor = useEditor()
return (
<TldrawUiButton
key={definition.type}
type="menu"
className="OnCanvasComponentPicker-button"
onPointerDown={editor.markEventAsHandled}
onClick={() => {
const state = onCanvasComponentPickerState.get(editor)
if (!state) return
const connection = editor.getShape(state.connectionShapeId)
if (!connection || !editor.isShapeOfType(connection, 'connection')) {
onClose()
return
}
// Calculate the position where the new node should be created
const terminals = getConnectionTerminals(editor, connection)
const terminalInConnectionSpace =
state.location === 'middle'
? Vec.Lrp(terminals.start, terminals.end, 0.5)
: terminals[state.location]
// Transform from connection space to page space
const terminalInPageSpace = editor
.getShapePageTransform(connection)
.applyToPoint(terminalInConnectionSpace)
// Call the pick handler with the node type and position
state.onPick(definition.getDefault(), terminalInPageSpace)
onClose()
}}
>
<TldrawUiButtonIcon icon={definition.icon} />
<TldrawUiButtonLabel>{definition.title}</TldrawUiButtonLabel>
</TldrawUiButton>
)
}

View File

@@ -0,0 +1,174 @@
import classNames from 'classnames'
import { useRef } from 'react'
import {
Box,
BoxModel,
Editor,
TLShapeId,
useEditor,
usePassThroughWheelEvents,
useQuickReactor,
useValue,
} from 'tldraw'
import { executionState, startExecution, stopExecution } from '../execution/executionState'
import { getNodePortConnections } from '../nodes/nodePorts'
import { NodeShape } from '../nodes/NodeShapeUtil'
import { PlayIcon } from './icons/PlayIcon'
import { StopIcon } from './icons/StopIcon'
/**
* A workflow region is a set of connected shapes. Each shape should only belong to a single
* workflow region: connecting two shapes from separate regions will cause them to be merged into a
* single region.
*/
interface WorkflowRegion {
bounds: BoxModel
nodes: Set<TLShapeId>
startingNodes: Set<TLShapeId>
}
// Find all workflow regions by traversing connected nodes in the canvas
function findWorkflowRegions(editor: Editor): WorkflowRegion[] {
const workflowSetsByShape = new Map<TLShapeId, Set<TLShapeId>>()
const visitedNodes = new Set<TLShapeId>()
// Recursively visit connected nodes to build workflow regions
function visit(node: NodeShape, currentWorkflow?: Set<TLShapeId>) {
if (visitedNodes.has(node.id)) return
visitedNodes.add(node.id)
if (!currentWorkflow) {
currentWorkflow = new Set()
}
workflowSetsByShape.set(node.id, currentWorkflow)
currentWorkflow.add(node.id)
for (const connection of getNodePortConnections(editor, node)) {
visit(editor.getShape(connection.connectedShapeId) as NodeShape, currentWorkflow)
}
}
// Start visiting from all nodes in the current page
for (const node of editor.getCurrentPageShapes()) {
if (editor.isShapeOfType(node, 'node')) {
visit(node)
}
}
// Convert workflow sets to Workflow objects with bounds and starting nodes
return Array.from(new Set(workflowSetsByShape.values()), (nodeIds): WorkflowRegion => {
let bounds: Box | null = null
const startingNodes = new Set<TLShapeId>()
for (const nodeId of nodeIds) {
// Check if this node has input connections (end ports)
// If not, it's a starting node
const hasInputs = getNodePortConnections(editor, nodeId).some((c) => c.terminal === 'end')
if (!hasInputs) {
startingNodes.add(nodeId)
}
// Get the node's bounds in page space
const nodeBounds = editor.getShapePageBounds(nodeId)
if (!nodeBounds) continue
// Union all node bounds to create workflow bounds
if (bounds) {
bounds.union(nodeBounds)
} else {
bounds = Box.From(nodeBounds)
}
}
return {
bounds: bounds!.expandBy(30), // Add padding around the workflow
nodes: nodeIds,
startingNodes,
}
}).filter((w) => w.nodes.size > 1) // Only show workflows with multiple nodes
}
// Component that renders workflow regions as overlays on the canvas
export function WorkflowRegions() {
const editor = useEditor()
// Reactively compute workflows when the canvas changes
const workflows = useValue('workflows', () => findWorkflowRegions(editor), [editor])
return workflows.map((workflow, i) => <WorkflowRegion key={i} workflow={workflow} />)
}
// Individual workflow region component with execution controls
function WorkflowRegion({ workflow }: { workflow: WorkflowRegion }) {
const editor = useEditor()
const ref = useRef<HTMLDivElement>(null)
// Check if this workflow is currently executing
const isExecuting = useValue(
'isExecuting',
() => {
const execution = executionState.get(editor).runningGraph
if (!execution) return false
// Check if any node in this workflow is executing
for (const nodeId of workflow.nodes) {
if (execution.getNodeStatus(nodeId) === 'executing') {
return true
}
}
return false
},
[editor, workflow]
)
// Reactively position the workflow region based on camera and workflow bounds
useQuickReactor(
'WorkflowRegion positioning',
() => {
if (!ref.current) return
const camera = editor.getCamera()
// Hide regions when zoomed out too far
if (camera.z < 0.25) {
ref.current.style.display = 'none'
return
} else {
ref.current.style.display = 'block'
}
// Transform workflow bounds from page space to viewport space
const position = editor.pageToViewport(workflow.bounds)
ref.current.style.transform = `translate(${position.x}px, ${position.y}px)`
// Scale the region based on camera zoom
ref.current.style.width = `${workflow.bounds.w * camera.z}px`
ref.current.style.height = `${workflow.bounds.h * camera.z}px`
},
[workflow, editor]
)
usePassThroughWheelEvents(ref)
return (
<div
className={classNames('WorkflowRegion', { WorkflowRegion_executing: isExecuting })}
ref={ref}
>
<button
className="WorkflowRegion-button"
onPointerDown={editor.markEventAsHandled}
onClick={() => {
if (isExecuting) {
// Stop execution if currently running
stopExecution(editor)
} else {
// Start execution from the workflow's starting nodes
startExecution(editor, workflow.startingNodes)
}
}}
>
{isExecuting ? <StopIcon /> : <PlayIcon />}
</button>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import {
AssetToolbarItem,
CheckBoxToolbarItem,
CloudToolbarItem,
createShapeId,
DefaultToolbar,
DiamondToolbarItem,
DrawToolbarItem,
Editor,
EllipseToolbarItem,
HandToolbarItem,
HeartToolbarItem,
HexagonToolbarItem,
HighlightToolbarItem,
LaserToolbarItem,
NoteToolbarItem,
onDragFromToolbarToCreateShape,
OvalToolbarItem,
RectangleToolbarItem,
RhombusToolbarItem,
SelectToolbarItem,
StarToolbarItem,
TextToolbarItem,
TldrawUiMenuGroup,
tlmenus,
TLShapeId,
TLUiOverrides,
ToolbarItem,
TriangleToolbarItem,
Vec,
XBoxToolbarItem,
} from 'tldraw'
import { NodeShape } from '../nodes/NodeShapeUtil'
import { getNodeDefinitions, NodeType } from '../nodes/nodeTypes'
import { MATH_MENU_ID, MathematicalToolbarItem } from './MathematicalToolbarItem'
function createNodeShape(editor: Editor, shapeId: TLShapeId, center: Vec, node: NodeType) {
// Mark a history stopping point for undo/redo
const markId = 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
const x = center.x - shapeBounds.width / 2
const y = center.y - shapeBounds.height / 2
editor.updateShape({ ...shape, x, y })
// Select the newly created shape
editor.select(shapeId)
})
return markId
}
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()
)
tlmenus.deleteOpenMenu(MATH_MENU_ID, editor.contextId)
},
onDragStart: (_, info) => {
onDragFromToolbarToCreateShape(editor, info, {
createShape: (id) => {
editor.createShape({
id,
type: 'node',
props: { node: nodeDef.getDefault() },
})
},
onDragEnd: () => {
tlmenus.deleteOpenMenu(MATH_MENU_ID, editor.contextId)
},
})
},
}
}
return tools
},
}
export function WorkflowToolbar() {
return (
<DefaultToolbar orientation="vertical" maxItems={8}>
<TldrawUiMenuGroup id="selection">
<SelectToolbarItem />
<HandToolbarItem />
<DrawToolbarItem />
<NoteToolbarItem />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="nodes">
<MathematicalToolbarItem />
<ToolbarItem tool="node-slider" />
<ToolbarItem tool="node-conditional" />
<ToolbarItem tool="node-earthquake" />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="shapes">
<RectangleToolbarItem />
<EllipseToolbarItem />
<TriangleToolbarItem />
<DiamondToolbarItem />
<HexagonToolbarItem />
<OvalToolbarItem />
<RhombusToolbarItem />
<StarToolbarItem />
<CloudToolbarItem />
<HeartToolbarItem />
<XBoxToolbarItem />
<CheckBoxToolbarItem />
<TextToolbarItem />
<AssetToolbarItem />
<HighlightToolbarItem />
<LaserToolbarItem />
</TldrawUiMenuGroup>
</DefaultToolbar>
)
}

View File

@@ -0,0 +1,22 @@
export function AddIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 3.33301V12.6663"
stroke="currentColor"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.6667 8H3.33334"
stroke="currentColor"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,22 @@
export function ConditionalIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33333 2.66602V5.33268H12.6667L14 3.99935L12.6667 2.66602H3.33333ZM12.6667 7.66602V10.3327H3.33333L2 8.99935L3.33333 7.66602H12.6667Z"
stroke="currentColor"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.99992 10.333V14.6663M7.99992 5.33301V7.66634M7.99992 1.33301V2.66634M6.33325 14.6663H9.66659"
stroke="currentColor"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,26 @@
export function DivideIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.6569 8H2.34314"
stroke="currentColor"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.99432 3.28027L7.99999 3.28593"
stroke="currentColor"
strokeWidth="2.66667"
strokeLinecap="round"
/>
<path
d="M8 12.7139L8.00567 12.7195"
stroke="currentColor"
strokeWidth="2.66667"
strokeLinecap="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,18 @@
export function EarthquakeIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
>
<path d="M7.49996 1.80002C4.35194 1.80002 1.79996 4.352 1.79996 7.50002C1.79996 10.648 4.35194 13.2 7.49996 13.2C10.648 13.2 13.2 10.648 13.2 7.50002C13.2 4.352 10.648 1.80002 7.49996 1.80002ZM0.899963 7.50002C0.899963 3.85494 3.85488 0.900024 7.49996 0.900024C11.145 0.900024 14.1 3.85494 14.1 7.50002C14.1 11.1451 11.145 14.1 7.49996 14.1C3.85488 14.1 0.899963 11.1451 0.899963 7.50002Z" />
<path d="M13.4999 7.89998H1.49994V7.09998H13.4999V7.89998Z" />
<path d="M7.09991 13.5V1.5H7.89991V13.5H7.09991zM10.375 7.49998C10.375 5.32724 9.59364 3.17778 8.06183 1.75656L8.53793 1.24341C10.2396 2.82218 11.075 5.17273 11.075 7.49998 11.075 9.82724 10.2396 12.1778 8.53793 13.7566L8.06183 13.2434C9.59364 11.8222 10.375 9.67273 10.375 7.49998zM3.99969 7.5C3.99969 5.17611 4.80786 2.82678 6.45768 1.24719L6.94177 1.75281C5.4582 3.17323 4.69969 5.32389 4.69969 7.5 4.6997 9.67611 5.45822 11.8268 6.94179 13.2472L6.45769 13.7528C4.80788 12.1732 3.9997 9.8239 3.99969 7.5z" />
<path d="M7.49996 3.95801C9.66928 3.95801 11.8753 4.35915 13.3706 5.19448 13.5394 5.28875 13.5998 5.50197 13.5055 5.67073 13.4113 5.83948 13.198 5.89987 13.0293 5.8056 11.6794 5.05155 9.60799 4.65801 7.49996 4.65801 5.39192 4.65801 3.32052 5.05155 1.97064 5.8056 1.80188 5.89987 1.58866 5.83948 1.49439 5.67073 1.40013 5.50197 1.46051 5.28875 1.62927 5.19448 3.12466 4.35915 5.33063 3.95801 7.49996 3.95801zM7.49996 10.85C9.66928 10.85 11.8753 10.4488 13.3706 9.6135 13.5394 9.51924 13.5998 9.30601 13.5055 9.13726 13.4113 8.9685 13.198 8.90812 13.0293 9.00238 11.6794 9.75643 9.60799 10.15 7.49996 10.15 5.39192 10.15 3.32052 9.75643 1.97064 9.00239 1.80188 8.90812 1.58866 8.9685 1.49439 9.13726 1.40013 9.30601 1.46051 9.51924 1.62927 9.6135 3.12466 10.4488 5.33063 10.85 7.49996 10.85z" />
</svg>
)
}

View File

@@ -0,0 +1,67 @@
export function MathematicalIcon({ className }: { className?: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M11.6667 9.33301V13.9997"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.9999 11.667H9.33325"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.9999 4.33301H9.33325"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 14L2 10"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 14L6 10"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 6L6 2"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.33325 2.33301H2.34125"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M5.66675 5.66699H5.67475"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,18 @@
export function MultiplyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.8284 5.17188L5.17154 10.8287"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
/>
<path
d="M10.8284 10.8287L5.17154 5.17188"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,22 @@
export function PlayIcon({ className }: { className?: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M4 2L14 8L4 14 Z"
fill="currentColor"
stroke="currentColor"
strokeWidth={2}
fillRule="evenodd"
clipRule="evenodd"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,28 @@
export function SliderIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.3333 8H14.1666"
stroke="#2E2E2E"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 8H6.66667"
stroke="#2E2E2E"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.83333 4.66699H9.16667C8.79847 4.66699 8.5 4.96547 8.5 5.33366V10.5003C8.5 10.8685 8.79847 11.167 9.16667 11.167H9.83333C10.2015 11.167 10.5 10.8685 10.5 10.5003V5.33366C10.5 4.96547 10.2015 4.66699 9.83333 4.66699Z"
stroke="#2E2E2E"
strokeWidth="1.33333"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,16 @@
export function StopIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="2.5"
y="2.5"
width="10"
height="10"
stroke="currentColor"
fill="currentColor"
rx={1}
ry={1}
/>
</svg>
)
}

View File

@@ -0,0 +1,7 @@
export function SubtractIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.4 8H3.80002" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" />
</svg>
)
}

View File

@@ -0,0 +1,198 @@
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 {}
}
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.editor.getShape(binding.toId)
if (!node || !this.editor.isShapeOfType(node, 'node')) return
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
) {
const nodeBefore = this.editor.getShape(bindingBefore.toId)
const nodeAfter = this.editor.getShape(bindingAfter.toId)
if (
!nodeBefore ||
!nodeAfter ||
!this.editor.isShapeOfType(nodeBefore, 'node') ||
!this.editor.isShapeOfType(nodeAfter, 'node')
) {
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.editor.getShape(binding.toId)
if (!node || !this.editor.isShapeOfType(node, 'node')) return
onNodePortDisconnect(this.editor, node, binding.props.portId)
}
}
/** The bindings associated with a specific connection shape. */
export interface ConnectionBindings {
start?: ConnectionBinding
end?: ConnectionBinding
}
/** Get the bindings associated with a specific connection shape. */
export function getConnectionBindings(
editor: Editor,
shape: ConnectionShape | TLShapeId
): ConnectionBindings {
// we cache the bindings so we don't have to recompute them every time - this function gets called a lot.
return connectionBindingsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const connectionBindingsCache = createComputedCache(
'connection bindings',
(editor: Editor, connection: ConnectionShape) => {
const bindings = editor.getBindingsFromShape(connection.id, CONNECTION_TYPE)
let start, end
for (const binding of bindings) {
if (binding.props.terminal === 'start') {
start = binding
} else if (binding.props.terminal === 'end') {
end = binding
}
}
return { start, end }
},
{
// we only look at the arrow IDs:
areRecordsEqual: (a, b) => a.id === b.id,
// the records should stay the same:
areResultsEqual: (a, b) => a.start === b.start && a.end === b.end,
}
)
/** Get the position of a connection binding in page space. */
export function getConnectionBindingPositionInPageSpace(
editor: Editor,
binding: ConnectionBinding
) {
// Find the shape that this binding is bound to
const targetShape = editor.getShape(binding.toId)
if (!targetShape || !editor.isShapeOfType(targetShape, 'node')) return null
// Find the port in the shape that the connection is bound to
const port = getNodePorts(editor, targetShape)?.[binding.props.portId]
if (!port) return null
// Transform the port position from shape space to page space
return editor.getShapePageTransform(targetShape).applyToPoint(port)
}
/**
* Create or update a connection binding. This utility makes sure that there are only ever two
* connection bindings per connection shape - a start and an end.
*/
export function createOrUpdateConnectionBinding(
editor: Editor,
connection: ConnectionShape | TLShapeId,
target: NodeShape | TLShapeId,
props: ConnectionBinding['props']
) {
const connectionId = typeof connection === 'string' ? connection : connection.id
const targetId = typeof target === 'string' ? target : target.id
const existingMany = editor
.getBindingsFromShape(connectionId, CONNECTION_TYPE)
.filter((b) => b.props.terminal === props.terminal)
// if we've somehow ended up with too many bindings, delete the extras
if (existingMany.length > 1) {
editor.deleteBindings(existingMany.slice(1))
}
const existing = existingMany[0]
if (existing) {
editor.updateBinding({
...existing,
toId: targetId,
props,
})
} else {
editor.createBinding({
type: CONNECTION_TYPE,
fromId: connectionId,
toId: targetId,
props,
})
}
}
/** Remove a connection binding. */
export function removeConnectionBinding(
editor: Editor,
connection: ConnectionShape,
terminal: 'start' | 'end'
) {
const existing = editor
.getBindingsFromShape(connection, CONNECTION_TYPE)
.filter((b) => b.props.terminal === terminal)
editor.deleteBindings(existing)
}

View File

@@ -0,0 +1,125 @@
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' as TLCursorType
}
override onPointerDown(overlay: TLConnectionCenterHandleOverlay, _info: TLPointerEventInfo) {
const connection = this.editor.getShape<ConnectionShape>(overlay.props.shapeId)
if (!connection) return false
insertNodeWithinConnection(this.editor, connection)
}
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()
// Sizes are specified in screen pixels, so divide by zoom to get page units.
const ringRadius = CONNECTION_CENTER_HANDLE_SIZE_PX / 2 / zoom
const hoverRadius = CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX / 2 / zoom
const plusReachPage = (CONNECTION_CENTER_HANDLE_SIZE_PX / 3 - 1) / zoom
const iconLineWidth = 2 / zoom
for (const overlay of overlays) {
const { x, y } = overlay.props
const isHovered = overlay.id === hoveredId
if (isHovered) {
ctx.fillStyle = hoverFill
ctx.beginPath()
ctx.arc(x, y, hoverRadius, 0, Math.PI * 2)
ctx.fill()
}
ctx.fillStyle = ringColor
ctx.beginPath()
ctx.arc(x, y, ringRadius, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = iconColor
ctx.lineWidth = iconLineWidth
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(x - plusReachPage, y)
ctx.lineTo(x + plusReachPage, y)
ctx.moveTo(x, y - plusReachPage)
ctx.lineTo(x, y + plusReachPage)
ctx.stroke()
}
}
}

View File

@@ -0,0 +1,386 @@
import classNames from 'classnames'
import {
CubicBezier2d,
Editor,
IndexKey,
Mat,
RecordProps,
SVGContainer,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLShape,
Vec,
VecLike,
VecModel,
clamp,
createShapeId,
useEditor,
useValue,
vecModelValidator,
} from 'tldraw'
import { onCanvasComponentPickerState } from '../components/OnCanvasComponentPicker'
import { getAllConnectedNodes, getNodeOutputPortInfo, getNodePorts } from '../nodes/nodePorts'
import { STOP_EXECUTION } from '../nodes/types/shared'
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) {
return {
// disable snapping other shape to this shape
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 shapeTransform = this.editor.getShapePageTransform(connection)
const handlePagePosition = shapeTransform.applyToPoint(handle)
// Find the port at the new position
const target = getPortAtPoint(this.editor, handlePagePosition, {
margin: 8,
terminal: handle.id as 'start' | 'end',
})
// 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 we were creating a new connection and didn't attach it to anything, open the component
// picker to let the user choose a node to create.
if (isCreatingShape && draggingTerminal === 'end') {
this.editor.selectNone()
onCanvasComponentPickerState.set(this.editor, {
connectionShapeId: connection.id,
location: draggingTerminal,
onClose: () => {
// if we didn't attach the connection to anything, delete it
const bindings = getConnectionBindings(this.editor, connection)
if (!bindings.start || !bindings.end) {
this.editor.deleteShapes([connection.id])
}
},
onPick: (nodeType, terminalInPageSpace) => {
// create the node based on the user's selection:
const newNodeId = createShapeId()
this.editor.createShape({
type: 'node',
id: newNodeId,
x: terminalInPageSpace.x,
y: terminalInPageSpace.y,
props: {
node: nodeType,
},
})
this.editor.select(newNodeId)
// Position the node so its input port aligns with the connection end
const ports = getNodePorts(this.editor, newNodeId)
const firstInputPort = Object.values(ports).find((p) => p.terminal === 'end')
if (firstInputPort) {
this.editor.updateShape({
id: newNodeId,
type: 'node',
x: terminalInPageSpace.x - firstInputPort.x,
y: terminalInPageSpace.y - firstInputPort.y,
})
// bind the connection to the node's first input port
createOrUpdateConnectionBinding(this.editor, connection, newNodeId, {
portId: firstInputPort.id,
terminal: draggingTerminal,
})
}
},
})
} else {
// if we're not creating a new connection and we just let go, there must be bindings. If
// not, let's interpret this as the user disconnecting the shape.
if (!bindings.start || !bindings.end) {
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,
])
// Check if this connection is inactive (carrying STOP_EXECUTION signal)
const isInactive = useValue(
'isInactive',
() => {
const bindings = getConnectionBindings(editor, connection.id)
if (!bindings.start) return false
const originShapeId = bindings.start?.toId
if (!originShapeId) return false
const outputs = getNodeOutputPortInfo(editor, originShapeId)
const output = outputs[bindings.start.props.portId]
return output.value === STOP_EXECUTION
},
[connection.id, editor]
)
return (
<SVGContainer
className={classNames('ConnectionShape', isInactive && 'ConnectionShape_inactive')}
>
<path d={getConnectionPath(start, end)} />
</SVGContainer>
)
}
// Calculate control points for smooth bezier curves
export function getConnectionControlPoints(start: VecLike, end: VecLike): [Vec, Vec] {
const distance = end.x - start.x
const adjustedDistance = Math.max(
30,
distance > 0 ? distance / 3 : clamp(Math.abs(distance) + 30, 0, 100)
)
return [new Vec(start.x + adjustedDistance, start.y), new Vec(end.x - adjustedDistance, end.y)]
}
/**
* 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
)
}
// 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) {
let start, end
// if possible, set the start and end points based on the bindings
const bindings = getConnectionBindings(editor, connection)
const shapeTransform = Mat.Inverse(editor.getShapePageTransform(connection))
if (bindings.start) {
const inPageSpace = getConnectionBindingPositionInPageSpace(editor, bindings.start)
if (inPageSpace) {
start = Mat.applyToPoint(shapeTransform, inPageSpace)
}
}
if (bindings.end) {
const inPageSpace = getConnectionBindingPositionInPageSpace(editor, bindings.end)
if (inPageSpace) {
end = Mat.applyToPoint(shapeTransform, inPageSpace)
}
}
// if we couldn't set the start and end points based on the bindings, use the values stored on
// the shape itself
if (!start) start = connection.props.start
if (!end) end = connection.props.end
return { start, end }
}

View File

@@ -0,0 +1,167 @@
import { Box, createShapeId, Editor, TLShapeId } from 'tldraw'
import { onCanvasComponentPickerState } from '../components/OnCanvasComponentPicker'
import { DEFAULT_NODE_SPACING_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) {
// open the component picker to let the user choose a node to create.
onCanvasComponentPickerState.set(editor, {
connectionShapeId: connection.id,
location: 'middle',
onPick: (nodeType) => {
// 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:
const startBounds = editor.getShapePageBounds(originalBindings.start.toId)!
const endBounds = editor.getShapePageBounds(originalBindings.end.toId)!
const newNodeY = (startBounds.top + endBounds.top) / 2
const newNodeIdealX = (startBounds.right + endBounds.left - NODE_WIDTH_PX) / 2
const newNodeMin = startBounds.right + DEFAULT_NODE_SPACING_PX
const newNodeX = Math.max(newNodeIdealX, newNodeMin)
// create the new node
const newNodeId = createShapeId()
editor.createShape({
type: 'node',
id: newNodeId,
x: newNodeX,
y: newNodeY,
props: { node: nodeType },
})
// 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)
// 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()
},
onClose: () => {},
})
}
/**
* 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) {
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 over to the right to make room for the new
// node. toNudge tracks all the nodes we need to nudge.
const toNudgeRight = new Map<TLShapeId, { initialX: number; amount: 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 = toNudgeRight.get(nodeId) ?? { initialX: node.x, amount: 0 }
const nodeBounds = editor
.getShapePageBounds(nodeId)!
.clone()
.translate({ x: currentNudge.amount, y: 0 })
// 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 the right to make room for the new node.
const newNudgeAmount = parentExpandedBounds.right - nodeBounds.left
toNudgeRight.set(nodeId, {
initialX: currentNudge.initialX,
amount: currentNudge.amount + newNudgeAmount,
})
// now, we traverse the downstream connections from this node and nudge them all if needed.
nodeBounds.translate({ x: newNudgeAmount, y: 0 }).expandBy(DEFAULT_NODE_SPACING_PX)
for (const connection of Object.values(getNodePortConnections(editor, node))) {
if (!connection || 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(toNudgeRight.entries()).map(([id, nudge]) => ({
id,
type: 'node' as const,
x: nudge.initialX + nudge.amount,
})),
],
{ animation: { duration: 100 } }
)
}

View File

@@ -0,0 +1,125 @@
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) continue
if (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]
const newIndex = newIndexes[i]
updates.push({
id: shape.id,
type: shape.type,
index: newIndex,
} 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)
}

View File

@@ -0,0 +1,12 @@
export const CONNECTION_CENTER_HANDLE_SIZE_PX = 16
export const CONNECTION_CENTER_HANDLE_HOVER_SIZE_PX = 24
export const NODE_WIDTH_PX = 235
export const NODE_HEADER_HEIGHT_PX = 40
export const NODE_ROW_HEADER_GAP_PX = 4
export const NODE_ROW_BOTTOM_PADDING_PX = 8
export const NODE_ROW_HEIGHT_PX = 44
export const PORT_RADIUS_PX = 6
export const DEFAULT_NODE_SPACING_PX = 60

View File

@@ -0,0 +1,17 @@
import { Editor } from 'tldraw'
export function disableTransparency(editor: Editor, shapeTypes: string[]) {
const shapeTypesSet = new Set(shapeTypes)
editor.sideEffects.registerBeforeCreateHandler('shape', (shape) => {
if (!shapeTypesSet.has(shape.type)) return shape
if (shape.opacity === 1) return shape
return { ...shape, opacity: 1 }
})
editor.sideEffects.registerBeforeChangeHandler('shape', (_shapeOld, shapeNew) => {
if (!shapeTypesSet.has(shapeNew.type)) return shapeNew
if (shapeNew.opacity === 1) return shapeNew
return { ...shapeNew, opacity: 1 }
})
}

View File

@@ -0,0 +1,172 @@
import { AtomMap, Editor, TLShapeId } from 'tldraw'
import {
getNodeOutputPortInfo,
getNodePortConnections,
NodePortConnection,
} from '../nodes/nodePorts'
import { NodeShape } from '../nodes/NodeShapeUtil'
import { executeNode } from '../nodes/nodeTypes'
import { ExecutionResult, STOP_EXECUTION } from '../nodes/types/shared'
interface PendingExecutionGraphNode {
readonly state: 'waiting' | 'executing'
readonly shape: NodeShape
readonly connections: NodePortConnection[]
}
interface ExecutedExecutionGraphNode {
readonly state: 'executed'
readonly shape: NodeShape
readonly connections: NodePortConnection[]
readonly outputs: ExecutionResult
}
type ExecutionGraphNode = PendingExecutionGraphNode | ExecutedExecutionGraphNode
// This class manages the execution of a workflow by traversing connected nodes
export class ExecutionGraph {
/**
* A map of node objects by their ID. We use this instead of looking them up from the editor so
* they're frozen in time once you start executing.
*/
private readonly nodesById = new AtomMap<TLShapeId, ExecutionGraphNode>('node by id')
constructor(
private readonly editor: Editor,
private readonly startingNodeIds: Set<TLShapeId>
) {
const toVisit = Array.from(startingNodeIds)
// Build the execution graph by traversing all connected nodes
while (toVisit.length > 0) {
const nodeId = toVisit.pop()!
if (this.nodesById.has(nodeId)) continue
const node = this.editor.getShape(nodeId)
if (!node || !this.editor.isShapeOfType(node, 'node')) continue
const connections = getNodePortConnections(this.editor, node)
// Add the node to the execution graph
this.nodesById.set(nodeId, {
state: 'waiting',
shape: node,
connections,
})
// Add all upstream nodes (connected to start ports) to the visit list
for (const connection of Object.values(connections)) {
if (!connection || connection.terminal !== 'start') continue
toVisit.push(connection.connectedShapeId)
}
}
}
private state: 'waiting' | 'executing' | 'stopped' = 'waiting'
// Execute the workflow starting from the specified nodes
async execute() {
if (this.state !== 'waiting') {
throw new Error('ExecutionGraph can only be executed once')
}
this.state = 'executing'
try {
// Start execution from all starting nodes in parallel
const promises = []
for (const nodeId of this.startingNodeIds) {
promises.push(this.executeNodeIfReady(nodeId))
}
await Promise.all(promises)
} finally {
this.state = 'stopped'
}
}
stop() {
this.state = 'stopped'
}
// Execute a node if all its dependencies are ready
private async executeNodeIfReady(nodeId: TLShapeId) {
if (this.state !== 'executing') return
const node = this.nodesById.get(nodeId)
if (!node || node.state !== 'waiting') return
const inputs: Record<string, number> = {}
// Check all input connections (end ports) to see if dependencies are ready
for (const connection of node.connections) {
if (!connection || connection.terminal !== 'end') continue
const dependency = this.nodesById.get(connection.connectedShapeId)
if (dependency) {
// If the dependency hasn't executed yet, we can't execute this node
if (dependency.state !== 'executed') {
return
}
const output = dependency.outputs[connection.connectedPortId]
if (output === STOP_EXECUTION) {
// STOP_EXECUTION is used for conditional execution
// It means this branch should not continue
return
}
inputs[connection.ownPortId] = output
} else {
// If the dependency isn't in nodesById, it's not involved in this execution
// We should retrieve its cached value from last time
const outputs = getNodeOutputPortInfo(this.editor, connection.connectedShapeId)
const output = outputs[connection.connectedPortId]
if (output.value === STOP_EXECUTION) {
// It still might be conditional though, and this branch may be disabled
return
}
inputs[connection.ownPortId] = output.value
}
}
// All dependencies are ready and we have their outputs! Start executing this node.
this.nodesById.set(nodeId, {
...node,
state: 'executing',
})
this.editor.updateShape({
id: nodeId,
type: node.shape.type,
props: { isOutOfDate: true },
})
const outputs = await executeNode(this.editor, node.shape, inputs)
this.editor.updateShape({
id: nodeId,
type: node.shape.type,
props: { isOutOfDate: false },
})
// Mark the node as executed with its outputs
this.nodesById.set(nodeId, {
...node,
state: 'executed',
outputs,
})
// Now that we've executed this node, we can see if any of its dependents are ready
const executingDependentPromises = []
for (const connection of Object.values(node.connections)) {
if (!connection || connection.terminal !== 'start') continue
executingDependentPromises.push(this.executeNodeIfReady(connection.connectedShapeId))
}
await Promise.all(executingDependentPromises)
}
getNodeStatus(nodeId: TLShapeId) {
return this.nodesById.get(nodeId)?.state
}
}

View File

@@ -0,0 +1,50 @@
import { Editor, TLShapeId } from 'tldraw'
import { EditorAtom } from '../utils'
import { ExecutionGraph } from './ExecutionGraph'
/**
* The state of the execution system, so executions are available to the UI.
*/
export interface ExecutionState {
runningGraph: ExecutionGraph | null
}
/**
* We store the execution state in an atom scoped to each editor.
*/
export const executionState = new EditorAtom<ExecutionState>('execution state', () => ({
runningGraph: null,
}))
/**
* Start an execution, scoped to the editor. This will stop any existing execution.
*/
export async function startExecution(editor: Editor, startingNodeIds: Set<TLShapeId>) {
const graph = new ExecutionGraph(editor, startingNodeIds)
executionState.update(editor, (state) => {
state.runningGraph?.stop()
return {
...state,
runningGraph: graph,
}
})
try {
await graph.execute()
} finally {
executionState.update(editor, (state) => ({
...state,
runningGraph: null,
}))
}
}
/**
* Stop the currently running execution.
*/
export function stopExecution(editor: Editor) {
executionState.update(editor, (state) => {
if (!state.runningGraph) return state
state.runningGraph.stop()
return { ...state, runningGraph: null }
})
}

View File

@@ -0,0 +1,180 @@
import { useCallback } from 'react'
import { createShapeId, TLPointerEventInfo, TLShapeId, useEditor, Vec } from 'tldraw'
export interface DragToCreateConfig<T = any> {
// Called when drag starts (after meeting drag threshold)
onDragStart?: (shapeId: TLShapeId, position: Vec, data: T) => string | void // return markId
// Called when pointer is released without dragging (click)
onClick?: (position: Vec, data: T) => void
// Called when drag operation completes
onDragComplete?: () => void
// Called to clean up after interaction ends
onCleanup?: () => void
// Whether to create a shape immediately on drag start
createShapeOnDrag?: boolean
// Custom shape creation function
createShape?: (shapeId: TLShapeId, center: Vec, data: T) => string // returns markId
}
// State machine for tracking drag interaction phases
type DragState =
| {
name: 'pointing'
}
| {
name: 'dragging'
shapeId: TLShapeId
}
| {
name: 'done'
}
// Custom hook that integrates with tldraw's interaction system to enable dragging items out of the
// UI and onto the canvas
export function useDragToCreate<T = any>(config: DragToCreateConfig<T>) {
const editor = useEditor()
const handlePointerDown = useCallback(
(down: React.PointerEvent, data: T) => {
down.preventDefault()
down.stopPropagation()
const el = down.currentTarget as HTMLElement
el.setPointerCapture(down.pointerId)
// State management for drag interaction
let state: DragState = { name: 'pointing' }
function onMove(move: PointerEvent) {
if (editor.getInstanceState().isCoarsePointer) return
move.stopPropagation()
const dx = move.clientX - down.clientX
const dy = move.clientY - down.clientY
// Check if we've moved enough to start dragging
if (
state.name === 'pointing' &&
dx ** 2 + dy ** 2 > editor.options.dragDistanceSquared * 2
) {
editor.run(() => {
const shapeId = createShapeId()
state = { name: 'dragging', shapeId }
const position = new Vec(move.clientX, move.clientY)
let markId: string | void = undefined
// Create shape immediately if configured to do so
if (config.createShapeOnDrag && config.createShape) {
markId = config.createShape(shapeId, editor.screenToPage(position), data)
}
// Call custom drag start handler
if (config.onDragStart) {
const result = config.onDragStart(shapeId, position, data)
if (result) markId = result
}
// Set up drag behavior if a shape was created
if (config.createShapeOnDrag && editor.getShape(shapeId)) {
// Create a pointer event info object that tldraw expects
const pointerInfo: TLPointerEventInfo = {
type: 'pointer',
shiftKey: move.shiftKey,
altKey: move.altKey,
ctrlKey: move.ctrlKey,
metaKey: move.metaKey,
accelKey: move.ctrlKey || move.metaKey,
button: move.button,
isPen: move.pointerType === 'pen',
name: 'pointer_down',
point: position,
pointerId: move.pointerId,
target: 'shape',
shape: editor.getShape(shapeId)!,
}
// Flush the event to tldraw's event system
editor._flushEventForTick(pointerInfo)
editor.inputs.setIsDragging(true) // fake it!
// Switch to translating mode to handle the drag
editor.select(shapeId).setCurrentTool('select.translating', {
...pointerInfo,
isCreating: true,
creatingMarkId: markId,
onInteractionEnd: 'select',
onCreate: () => {
editor.setCurrentTool('select.idle')
cleanUpAfterInteractionEnd()
},
} as any)
// Listen for events that should cancel the drag
function listenForBailEvents() {
if (!editor.isIn('select.translating') || !editor.getShape(shapeId)) {
editor.setCurrentTool('select.idle')
cleanUpAfterInteractionEnd()
stopBailEventListener()
}
}
const stopBailEventListener = editor.store.listen(listenForBailEvents, {
source: 'user',
scope: 'session',
})
}
cleanUpMenuEvents()
})
}
}
function onUp(up: PointerEvent) {
up.stopPropagation()
editor.run(() => {
if (state.name === 'pointing') {
// Click behavior - create in center of viewport
const center = editor.getViewportPageBounds().center
if (config.onClick) {
config.onClick(center, data)
} else if (config.createShape) {
config.createShape(createShapeId(), center, data)
}
}
cleanUpMenuEvents()
cleanUpAfterInteractionEnd()
})
}
function cleanUpAfterInteractionEnd() {
if (state.name !== 'done') {
state = { name: 'done' }
config.onDragComplete?.()
config.onCleanup?.()
}
}
function cleanUpMenuEvents() {
el.releasePointerCapture(down.pointerId)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.style.opacity = ''
// Move the pointer capture to the canvas so tldraw can handle subsequent events
const cvs = document.querySelector('.tl-canvas') as HTMLDivElement
if (cvs) cvs.setPointerCapture(down.pointerId)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
},
[editor, config]
)
return { handlePointerDown }
}

View File

@@ -0,0 +1,322 @@
@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;
padding-bottom: var(--tl-space-3);
.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);
}
.NodeShape-label {
flex: 1 1 auto;
font-weight: 500;
}
.NodeShape-output {
font-variant-numeric: tabular-nums;
}
&.NodeShape_executing {
box-shadow:
0 0 0 calc(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);
}
}
.NodeRow {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
height: 44px;
gap: var(--tl-space-4);
padding: var(--tl-space-2) var(--tl-space-4);
input,
select {
align-self: stretch;
flex: 1 1 auto;
border: 1px solid var(--tl-color-hint);
padding: 0 var(--tl-space-4);
border-radius: var(--tl-radius-3);
background: none;
font: inherit;
outline: none;
&:focus {
border-color: var(--tl-color-selected);
}
[data-state='select.idle'] & {
pointer-events: all;
}
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' clip-rule='evenodd' d='M6.27047 12.3161C6.6482 11.9131 7.28103 11.8927 7.68394 12.2705L15 19.1293L22.3161 12.2705C22.719 11.8927 23.3518 11.9131 23.7295 12.3161C24.1073 12.719 24.0869 13.3518 23.6839 13.7295L15.6839 21.2295C15.2993 21.5902 14.7007 21.5902 14.3161 21.2295L6.31606 13.7295C5.91315 13.3518 5.89274 12.719 6.27047 12.3161Z' fill='black'/></svg>");
background-repeat: no-repeat;
background-position: right var(--tl-space-4) center;
background-size: 16px;
}
}
.NodeInputRow {
.NodeInputRow-buttons {
display: none;
position: absolute;
right: 19px;
button {
margin-right: -8px;
min-width: 38px;
height: 38px;
}
}
&:has(input:not(:disabled)) {
&:hover,
&:has(input:focus) {
.NodeInputRow-buttons {
display: flex;
}
}
}
}
.Port {
position: absolute;
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_end {
left: -6px;
}
&.Port_start {
right: -6px;
}
&.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;
}
.OnCanvasComponentPicker {
position: absolute;
top: 0;
left: 0;
z-index: var(--tl-layer-overlays);
--top: 66px;
transform-origin: 0 0;
.OnCanvasComponentPicker-content {
position: absolute;
margin-top: calc(-1 * var(--top));
width: 150px;
border-radius: var(--tl-radius-3);
background: var(--tl-color-panel);
box-shadow: var(--node-shadow-highlight);
}
.OnCanvasComponentPicker-icon {
font-size: 36px;
}
&.OnCanvasComponentPicker_middle .OnCanvasComponentPicker-content {
margin-left: -75px;
}
}
.ConditionalNode-output {
justify-content: flex-end;
}
.ConditionalNode-output-false {
background: var(--tl-color-muted-0);
border-top: 1px solid var(--tl-color-divider);
margin-bottom: calc(-1 * var(--tl-space-3));
border-bottom-left-radius: var(--tl-radius-3);
border-bottom-right-radius: var(--tl-radius-3);
}
.NodeValue_placeholder {
display: inline-block;
width: 18px;
height: 10px;
border-radius: 10px;
background: hsl(0, 0%, 0%, 10%);
}
.SliderNode {
.tlui-slider__container {
padding: 0;
&:has(.tlui-slider__thumb:focus-visible) {
outline: none;
}
[data-state='select.idle'] & {
pointer-events: all;
}
}
}
.WorkflowRegion {
position: absolute;
color: hsl(0, 0%, 70%);
border: 1px dashed currentColor;
border-radius: var(--tl-radius-3);
pointer-events: none;
.WorkflowRegion-button {
position: absolute;
top: 0;
left: -60px;
width: 40px;
height: 40px;
border-radius: 50%;
color: inherit;
background: var(--tl-color-panel);
border: 1px solid currentColor;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.tl-container[data-state='select.idle'] & {
pointer-events: all;
}
svg {
color: hsl(0, 0%, 60%);
}
}
&::after {
content: '';
display: block;
width: 20px;
height: 0px;
border-bottom: 1px dashed currentColor;
position: absolute;
top: 20px;
left: -20px;
}
&:has(.WorkflowRegion-button:hover) {
color: var(--tl-color-selected);
}
&.WorkflowRegion_executing {
color: var(--tl-color-selected);
border-style: solid;
&::after {
border-bottom-style: solid;
}
.WorkflowRegion-button {
border-width: 2px;
&::after {
content: '';
width: 8px;
height: 4px;
border-radius: 100%;
border: 2px solid var(--tl-color-selected);
background: var(--tl-color-panel);
position: absolute;
animation: WorkflowRegion-button-spin 1s linear infinite;
}
}
}
}
@keyframes WorkflowRegion-button-spin {
0% {
transform: rotate(0deg) translateY(-19px);
}
100% {
transform: rotate(360deg) translateY(-19px);
}
}
.EarthquakeNode-message {
text-align: center;
padding: 24px 12px;
font-size: 12px;
color: #666;
height: 132px; /* 3 * NODE_ROW_HEIGHT_PX (3 * 44) */
display: flex;
align-items: center;
justify-content: center;
}

View 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 />)

View File

@@ -0,0 +1,165 @@
import classNames from 'classnames'
import {
Circle2d,
Group2d,
HTMLContainer,
RecordProps,
Rectangle2d,
resizeBox,
ShapeUtil,
T,
TLResizeInfo,
TLShape,
useEditor,
useValue,
} from 'tldraw'
import { NODE_WIDTH_PX, PORT_RADIUS_PX } from '../constants'
import { executionState } from '../execution/executionState'
import { Port } from '../ports/Port'
import { getNodeOutputPortInfo, getNodePorts } from './nodePorts'
import { getNodeDefinition, getNodeHeightPx, NodeBody, NodeType } from './nodeTypes'
import { NodeValue, STOP_EXECUTION } from './types/shared'
const NODE_TYPE = 'node'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
// Define our custom node shape type that extends tldraw's base shape system
[NODE_TYPE]: { node: NodeType; isOutOfDate: boolean }
}
}
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,
isOutOfDate: T.boolean,
}
getDefaultProps(): NodeShape['props'] {
return {
node: getNodeDefinition(this.editor, 'add').getDefault(),
isOutOfDate: false,
}
}
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,
excludeFromShapeBounds: true,
})
)
const bodyGeometry = new Rectangle2d({
width: NODE_WIDTH_PX,
height: getNodeHeightPx(this.editor, shape),
isFilled: true,
})
return new Group2d({
children: [bodyGeometry, ...portGeometries],
})
}
override onResize(shape: any, info: TLResizeInfo<any>) {
return resizeBox(shape, info)
}
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 = Object.values(getNodePorts(this.editor, shape))
for (const port of 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()
// Get the node's output value
const output = useValue(
'output',
() => getNodeOutputPortInfo(editor, shape.id)?.output ?? undefined,
[editor, shape.id]
)
// Check if this node is currently executing using our execution state
const isExecuting = useValue(
'is executing',
() => executionState.get(editor).runningGraph?.getNodeStatus(shape.id) === 'executing',
[editor, shape.id]
)
const nodeDefinition = getNodeDefinition(editor, shape.props.node)
return (
<HTMLContainer
className={classNames('NodeShape', {
NodeShape_executing: isExecuting,
})}
>
<div className="NodeShape-heading">
<div className="NodeShape-label">{nodeDefinition.heading ?? nodeDefinition.title}</div>
{output !== undefined && (
<>
<div className="NodeShape-output">
<NodeValue value={output.isOutOfDate ? STOP_EXECUTION : output.value} />
</div>
<Port shapeId={shape.id} portId="output" />
</>
)}
</div>
<NodeBody shape={shape} />
</HTMLContainer>
)
}

View File

@@ -0,0 +1,157 @@
/**
* 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 { getNodeOutputInfo, getNodeTypePorts } from './nodeTypes'
import { areAnyInputsOutOfDate, InfoValues, STOP_EXECUTION } from './types/shared'
/**
* 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) {
return nodePortsCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const nodePortsCache = createComputedCache('ports', (editor: Editor, node: NodeShape) =>
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,
}
)
/**
* Get the values of the input ports for a node. This is cached.
*/
export function getNodeInputPortValues(editor: Editor, shape: NodeShape | TLShapeId): InfoValues {
return nodeInputPortValuesCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const nodeInputPortValuesCache = createComputedCache(
'node input port values',
(editor: Editor, node: NodeShape) => {
const connections = getNodePortConnections(editor, node)
const values: InfoValues = {}
for (const connection of connections) {
if (!connection || connection.terminal !== 'end') continue
const connectedShapeOutputs = getNodeOutputPortInfo(editor, connection.connectedShapeId)
if (!connectedShapeOutputs) {
continue
}
const output = connectedShapeOutputs[connection.connectedPortId]
values[connection.ownPortId] = output
}
return values
},
{
areRecordsEqual: (a, b) => a.props === b.props,
}
)
/**
* Get the values of the output ports for a node. This is cached.
*/
export function getNodeOutputPortInfo(editor: Editor, shape: NodeShape | TLShapeId): InfoValues {
return nodeOutputPortInfoCache.get(editor, typeof shape === 'string' ? shape : shape.id) ?? {}
}
const nodeOutputPortInfoCache = createComputedCache(
'node output port info',
(editor: Editor, node: NodeShape): InfoValues => {
const inputs = getNodeInputPortValues(editor, node)
if (Object.values(inputs).some((input) => input.value === STOP_EXECUTION)) {
return Object.fromEntries(
Object.values(getNodePorts(editor, node))
.filter((port) => port.terminal === 'start')
.map((port) => [
port.id,
{ value: STOP_EXECUTION, isOutOfDate: areAnyInputsOutOfDate(inputs) },
])
)
}
return getNodeOutputInfo(editor, node, inputs as InfoValues)
},
{
areRecordsEqual: (a, b) => a.props === b.props,
}
)
/**
* 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()
if (!nodeId) continue
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
}

View File

@@ -0,0 +1,115 @@
import { Editor, T, useEditor, WeakCache } from 'tldraw'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_BOTTOM_PADDING_PX,
NODE_ROW_HEADER_GAP_PX,
} from '../constants'
import { PortId, ShapePort } from '../ports/Port'
import { NodeShape } from './NodeShapeUtil'
import { AddNodeDefinition } from './types/AddNode'
import { ConditionalNodeDefinition } from './types/ConditionalNode'
import { DivideNodeDefinition } from './types/DivideNode'
import { EarthquakeNodeDefinition } from './types/EarthquakeNode'
import { MultiplyNodeDefinition } from './types/MultiplyNode'
import {
ExecutionResult,
InfoValues,
NodeDefinition,
NodeDefinitionConstructor,
} from './types/shared'
import { SliderNodeDefinition } from './types/SliderNode'
import { SubtractNodeDefinition } from './types/SubtractNode'
/** All our node types */
export const NodeDefinitions = {
add: AddNodeDefinition,
subtract: SubtractNodeDefinition,
multiply: MultiplyNodeDefinition,
divide: DivideNodeDefinition,
conditional: ConditionalNodeDefinition,
slider: SliderNodeDefinition,
earthquake: EarthquakeNodeDefinition,
} satisfies Record<string, NodeDefinitionConstructor<any>>
/**
* 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']
}
)
const nodeDefinitions = new WeakCache<
Editor,
{ [K in keyof typeof NodeDefinitions]: InstanceType<(typeof NodeDefinitions)[K]> }
>()
export function getNodeDefinitions(editor: Editor) {
return nodeDefinitions.get(editor, () => {
return Object.fromEntries(
Object.values(NodeDefinitions).map((value) => [value.type, new value(editor)])
) as any
})
}
// the other functions in this file are wrappers around the node definitions, dispatching to the
// correct definition for a given node.
export function getNodeDefinition(
editor: Editor,
node: NodeType | NodeType['type']
): NodeDefinition<NodeType> {
return getNodeDefinitions(editor)[
typeof node === 'string' ? node : node.type
] as NodeDefinition<NodeType>
}
export function getNodeBodyHeightPx(editor: Editor, shape: NodeShape): number {
return getNodeDefinition(editor, shape.props.node).getBodyHeightPx(shape, shape.props.node)
}
export function getNodeHeightPx(editor: Editor, shape: NodeShape): number {
return (
NODE_HEADER_HEIGHT_PX +
NODE_ROW_HEADER_GAP_PX +
getNodeBodyHeightPx(editor, shape) +
NODE_ROW_BOTTOM_PADDING_PX
)
}
export function getNodeTypePorts(editor: Editor, shape: NodeShape): Record<string, ShapePort> {
return getNodeDefinition(editor, shape.props.node).getPorts(shape, shape.props.node)
}
export async function executeNode(
editor: Editor,
shape: NodeShape,
inputs: Record<string, number>
): Promise<ExecutionResult> {
return await getNodeDefinition(editor, shape.props.node).execute(shape, shape.props.node, inputs)
}
export function getNodeOutputInfo(
editor: Editor,
shape: NodeShape,
inputs: InfoValues
): InfoValues {
return getNodeDefinition(editor, shape.props.node).getOutputInfo(shape, shape.props.node, inputs)
}
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)
}
export function NodeBody({ shape }: { shape: NodeShape }) {
const editor = useEditor()
const node = shape.props.node
const { Component } = getNodeDefinition(editor, node)
return <Component shape={shape} node={node} />
}

View File

@@ -0,0 +1,202 @@
import { Editor, getIndexAbove, getIndicesBetween, IndexKey, T, useEditor } from 'tldraw'
import { AddIcon } from '../../components/icons/AddIcon'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
NODE_WIDTH_PX,
} from '../../constants'
import { ShapePort } from '../../ports/Port'
import { indexList, indexListEntries, indexListLength } from '../../utils'
import { sleep } from '../../utils/sleep'
import { getNodePortConnections } from '../nodePorts'
import { NodeShape } from '../NodeShapeUtil'
import {
areAnyInputsOutOfDate,
ExecutionResult,
InfoValues,
InputValues,
NodeComponentProps,
NodeDefinition,
NodeInputRow,
updateNode,
} from './shared'
/**
* The add node adds together all of its inputs. It has a variable number of inputs.
*
* The items in the node are stored in an index list - a list where the keys are fractional indexes,
* to allow for elements to be inserted in the middle of the list, and to make sure the indexes of
* other items don't change when item are removed.
*/
export type AddNode = T.TypeOf<typeof AddNode>
export const AddNode = T.object({
type: T.literal('add'),
items: T.dict(T.indexKey, T.number),
lastResult: T.number.nullable(),
})
export class AddNodeDefinition extends NodeDefinition<AddNode> {
static type = 'add'
static validator = AddNode
title = 'Add'
heading = 'Add'
icon = <AddIcon />
getDefault(): AddNode {
return {
type: 'add',
items: indexList([0, 0]),
lastResult: null,
}
}
// The height of the node is the number of items times the height of a row.
getBodyHeightPx(_shape: NodeShape, node: AddNode) {
return NODE_ROW_HEIGHT_PX * indexListLength(node.items)
}
getPorts(_shape: NodeShape, node: AddNode): Record<string, ShapePort> {
return {
// The add node has a single output port...
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
// ...and one input port for each item.
...Object.fromEntries(
Object.keys(node.items)
.sort()
.map((idx, i) => [
`item_${idx}`,
{
id: `item_${idx}`,
x: 0,
y:
NODE_HEADER_HEIGHT_PX +
NODE_ROW_HEADER_GAP_PX +
NODE_ROW_HEIGHT_PX * i +
NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
])
),
}
}
// The output of the add node is the sum of all of its inputs.
async execute(shape: NodeShape, node: AddNode, inputs: InputValues): Promise<ExecutionResult> {
await sleep(1000)
const result = Object.entries(node.items).reduce((acc, [idx, value]) => {
const currentValue = inputs[`item_${idx}`] ?? value
return acc + currentValue
}, 0)
updateNode<AddNode>(this.editor, shape, (node) => ({
...node,
lastResult: result,
}))
return {
output: result,
}
}
getOutputInfo(shape: NodeShape, node: AddNode, inputs: InfoValues): InfoValues {
return {
output: {
value: node.lastResult ?? 0,
isOutOfDate: areAnyInputsOutOfDate(inputs) || shape.props.isOutOfDate,
},
}
}
// When a port is connected, we need to ensure that the node has an item for that port, and that
// there's a spare empty port at the end for adding more items.
onPortConnect(shape: NodeShape, _node: AddNode, portId: string): void {
if (!portId.startsWith('item_')) return
const idx = portId.slice(5) as IndexKey
updateNode<AddNode>(this.editor, shape, (node) => ({
...node,
items: ensureFinalEmptyItem(
this.editor,
shape,
{ ...node.items, [idx]: node.items[idx] ?? 0 },
{ removeUnused: true }
),
}))
}
// when a port is disconnected, we can clean up unused items.
onPortDisconnect(shape: NodeShape, _node: AddNode, _portId: string): void {
updateNode<AddNode>(this.editor, shape, (node) => ({
...node,
items: ensureFinalEmptyItem(this.editor, shape, node.items, { removeUnused: true }),
}))
}
Component = AddNodeComponent
}
export function AddNodeComponent({ shape, node }: NodeComponentProps<AddNode>) {
const editor = useEditor()
return (
<>
{indexListEntries(node.items).map(([idx, value]) => (
<NodeInputRow
key={idx}
shapeId={shape.id}
portId={`item_${idx}`}
value={value}
onChange={(newValue) => {
updateNode<AddNode>(editor, shape, (node) => ({
...node,
items: ensureFinalEmptyItem(editor, shape, { ...node.items, [idx]: newValue }),
}))
}}
onBlur={() => {
updateNode<AddNode>(editor, shape, (node) => ({
...node,
items: ensureFinalEmptyItem(editor, shape, node.items, { removeUnused: true }),
}))
}}
/>
))}
</>
)
}
function ensureFinalEmptyItem(
editor: Editor,
shape: NodeShape,
items: Record<IndexKey, number>,
{ removeUnused = false } = {}
) {
const connections = getNodePortConnections(editor, shape.id)
let entriesToKeep = indexListEntries(items)
const connectedPortIds = new Set(connections.map((c) => c.ownPortId))
if (removeUnused) {
entriesToKeep = entriesToKeep.filter(([idx, value], i) => {
const portId = `item_${idx}`
return (
i === 0 || i === entriesToKeep.length - 1 || value !== 0 || connectedPortIds.has(portId)
)
})
if (entriesToKeep.length < 2) {
for (const index of getIndicesBetween(
entriesToKeep[entriesToKeep.length - 1]?.[0],
null,
2 - entriesToKeep.length
)) {
entriesToKeep.push([index, 0])
}
}
}
const lastEntry = entriesToKeep[entriesToKeep.length - 1]!
if (lastEntry[1] !== 0 || connectedPortIds.has(`item_${lastEntry[0]}`)) {
entriesToKeep.push([getIndexAbove(lastEntry[0]), 0])
}
return Object.fromEntries(entriesToKeep)
}

View File

@@ -0,0 +1,219 @@
import { T, useEditor } from 'tldraw'
import { ConditionalIcon } from '../../components/icons/ConditionalIcon'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_BOTTOM_PADDING_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
NODE_WIDTH_PX,
} from '../../constants'
import { Port, ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
areAnyInputsOutOfDate,
ExecutionResult,
InfoValues,
InputValues,
NodeComponentProps,
NodeDefinition,
NodeInputRow,
NodeRow,
STOP_EXECUTION,
updateNode,
} from './shared'
/**
* The operators that can be used in the conditional node.
*/
export const operators = {
'==': {
title: 'is equal to',
evaluate: (lhs, rhs) => lhs === rhs,
},
'!=': {
title: 'is not equal to',
evaluate: (lhs, rhs) => lhs !== rhs,
},
'>': {
title: 'is greater than',
evaluate: (lhs, rhs) => lhs > rhs,
},
'<': {
title: 'is less than',
evaluate: (lhs, rhs) => lhs < rhs,
},
'>=': {
title: 'is greater than or equal to',
evaluate: (lhs, rhs) => lhs >= rhs,
},
'<=': {
title: 'is less than or equal to',
evaluate: (lhs, rhs) => lhs <= rhs,
},
} satisfies Record<
string,
{
title: string
evaluate: (lhs: number, rhs: number) => boolean
}
>
type Operator = keyof typeof operators
const Operator = T.literalEnum(...(Object.keys(operators) as Operator[]))
/**
* The conditional node has a condition (two inputs and an operator), and two outputs. If the
* condition evaluates to true, the first output port gets a value and the other stops execution. If
* the condition evaluates to false, it's the other way around.
*/
export type ConditionalNode = T.TypeOf<typeof ConditionalNode>
export const ConditionalNode = T.object({
type: T.literal('conditional'),
lhs: T.number,
rhs: T.number,
operator: Operator,
previousResult: T.literalEnum('lhs', 'rhs').nullable(),
})
export class ConditionalNodeDefinition extends NodeDefinition<ConditionalNode> {
static type = 'conditional'
static validator = ConditionalNode
title = 'Conditional'
heading = 'If'
icon = <ConditionalIcon />
getDefault(): ConditionalNode {
return {
type: 'conditional',
lhs: 0,
rhs: 0,
operator: '==',
previousResult: null,
}
}
// There are 5 rows in the conditional, but we have less padding at the bottom than usual.
getBodyHeightPx() {
return NODE_ROW_HEIGHT_PX * 5 - NODE_ROW_BOTTOM_PADDING_PX
}
// We need a port for each input and output.
getPorts(): Record<string, ShapePort> {
return {
lhs: {
id: 'lhs',
x: 0,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX * 0.5,
terminal: 'end',
},
rhs: {
id: 'rhs',
x: 0,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX * 2.5,
terminal: 'end',
},
outputTrue: {
id: 'outputTrue',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX * 3.5,
terminal: 'start',
},
outputFalse: {
id: 'outputFalse',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX * 4.5,
terminal: 'start',
},
}
}
// The output of the conditional node is the value of the first output port, or STOP_EXECUTION
// if the condition is false.
async execute(
shape: NodeShape,
node: ConditionalNode,
inputs: InputValues
): Promise<ExecutionResult> {
await sleep(1000)
const lhs = inputs.lhs ?? node.lhs
const rhs = inputs.rhs ?? node.rhs
if (operators[node.operator].evaluate(lhs, rhs)) {
updateNode<ConditionalNode>(this.editor, shape, (node) => ({
...node,
previousResult: 'lhs',
}))
return { outputTrue: lhs, outputFalse: STOP_EXECUTION }
} else {
updateNode<ConditionalNode>(this.editor, shape, (node) => ({
...node,
previousResult: 'rhs',
}))
return { outputTrue: STOP_EXECUTION, outputFalse: lhs }
}
}
getOutputInfo(shape: NodeShape, node: ConditionalNode, inputs: InfoValues): InfoValues {
const isOutOfDate = areAnyInputsOutOfDate(inputs) || shape.props.isOutOfDate
const lhs = inputs.lhs?.value ?? node.lhs
const rhs = inputs.rhs?.value ?? node.rhs
return {
outputTrue:
node.previousResult === 'rhs'
? { value: STOP_EXECUTION, isOutOfDate }
: { value: lhs, isOutOfDate },
outputFalse:
node.previousResult === 'rhs'
? { value: rhs, isOutOfDate }
: { value: STOP_EXECUTION, isOutOfDate },
}
}
Component = ConditionalNodeComponent
}
export function ConditionalNodeComponent({ shape, node }: NodeComponentProps<ConditionalNode>) {
const editor = useEditor()
return (
<>
<NodeInputRow
shapeId={shape.id}
portId="lhs"
value={node.lhs}
onChange={(newValue) =>
updateNode<ConditionalNode>(editor, shape, (node) => ({ ...node, lhs: newValue }))
}
/>
<NodeRow>
<select
value={node.operator}
onChange={(e) =>
updateNode<ConditionalNode>(editor, shape, (node) => ({
...node,
operator: e.target.value as Operator,
}))
}
>
{Object.entries(operators).map(([operator, { title }]) => (
<option key={operator} value={operator}>
{title}
</option>
))}
</select>
</NodeRow>
<NodeInputRow
shapeId={shape.id}
portId="rhs"
value={node.rhs}
onChange={(newValue) =>
updateNode<ConditionalNode>(editor, shape, (node) => ({ ...node, rhs: newValue }))
}
/>
<NodeRow className="ConditionalNode-output">
Then
<Port shapeId={shape.id} portId="outputTrue" />
</NodeRow>
<NodeRow className="ConditionalNode-output ConditionalNode-output-false">
Otherwise
<Port shapeId={shape.id} portId="outputFalse" />
</NodeRow>
</>
)
}

View File

@@ -0,0 +1,123 @@
import { T, useEditor } from 'tldraw'
import { DivideIcon } from '../../components/icons/DivideIcon'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
NODE_WIDTH_PX,
} from '../../constants'
import { ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
areAnyInputsOutOfDate,
ExecutionResult,
InfoValues,
InputValues,
NodeComponentProps,
NodeDefinition,
NodeInputRow,
updateNode,
} from './shared'
/**
* The divide node divides its two inputs.
*/
export type DivideNode = T.TypeOf<typeof DivideNode>
export const DivideNode = T.object({
type: T.literal('divide'),
a: T.number,
b: T.number,
lastResult: T.number.nullable(),
})
export class DivideNodeDefinition extends NodeDefinition<DivideNode> {
static type = 'divide'
static validator = DivideNode
title = 'Divide'
heading = 'Divide'
icon = <DivideIcon />
getDefault(): DivideNode {
return {
type: 'divide',
a: 0,
b: 0,
lastResult: null,
}
}
getBodyHeightPx(_shape: NodeShape, _node: DivideNode) {
return NODE_ROW_HEIGHT_PX * 2
}
getPorts(_shape: NodeShape, _node: DivideNode): Record<string, ShapePort> {
return {
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
dividend: {
id: 'dividend',
x: 0,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
divisor: {
id: 'divisor',
x: 0,
y:
NODE_HEADER_HEIGHT_PX +
NODE_ROW_HEADER_GAP_PX +
NODE_ROW_HEIGHT_PX +
NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
}
}
async execute(shape: NodeShape, node: DivideNode, inputs: InputValues): Promise<ExecutionResult> {
await sleep(1000)
const result = (inputs.dividend ?? node.a) / (inputs.divisor ?? node.b)
const validResult = !Number.isFinite(result) ? null : result
updateNode<DivideNode>(this.editor, shape, (node) => ({
...node,
lastResult: validResult,
}))
return {
output: validResult ?? 0,
}
}
getOutputInfo(shape: NodeShape, node: DivideNode, inputs: InfoValues): InfoValues {
return {
output: {
value: node.lastResult ?? 0,
isOutOfDate: areAnyInputsOutOfDate(inputs) || shape.props.isOutOfDate,
},
}
}
Component = DivideNodeComponent
}
export function DivideNodeComponent({ shape, node }: NodeComponentProps<DivideNode>) {
const editor = useEditor()
return (
<>
<NodeInputRow
shapeId={shape.id}
portId="dividend"
value={node.a}
onChange={(newValue) =>
updateNode<DivideNode>(editor, shape, (node) => ({ ...node, a: newValue }))
}
/>
<NodeInputRow
shapeId={shape.id}
portId="divisor"
value={node.b}
onChange={(newValue) =>
updateNode<DivideNode>(editor, shape, (node) => ({ ...node, b: newValue }))
}
/>
</>
)
}

View File

@@ -0,0 +1,208 @@
import { T } from 'tldraw'
import { EarthquakeIcon } from '../../components/icons/EarthquakeIcon'
import { NODE_HEADER_HEIGHT_PX, NODE_ROW_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
import { ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
ExecutionResult,
InfoValues,
NodeComponentProps,
NodeDefinition,
NodeRow,
STOP_EXECUTION,
} from './shared'
/**
* The earthquake node fetches data from USGS earthquake API and outputs magnitude.
*/
export type EarthquakeNode = T.TypeOf<typeof EarthquakeNode>
export const EarthquakeNode = T.object({
type: T.literal('earthquake'),
earthquakeData: T.nullable(
T.object({
magnitude: T.number,
location: T.string,
datetime: T.string,
id: T.string, // Add unique ID to avoid duplicate selections
})
),
})
interface EarthquakeFeature {
properties: {
mag: number
place: string
time: number
}
id: string
}
interface EarthquakeApiResponse {
features: EarthquakeFeature[]
}
export class EarthquakeNodeDefinition extends NodeDefinition<EarthquakeNode> {
static type = 'earthquake'
static validator = EarthquakeNode
title = 'Earthquake data'
heading = 'USGS Data'
icon = <EarthquakeIcon />
getDefault(): EarthquakeNode {
return {
type: 'earthquake',
earthquakeData: null,
}
}
getBodyHeightPx(_shape: NodeShape, _node: EarthquakeNode) {
return NODE_ROW_HEIGHT_PX * 3 // Three rows for magnitude, location, datetime
}
getPorts(_shape: NodeShape, _node: EarthquakeNode): Record<string, ShapePort> {
return {
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
}
}
async execute(shape: NodeShape, node: EarthquakeNode): Promise<ExecutionResult> {
try {
// Simulate loading delay
await sleep(500)
const response = await fetch(
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: EarthquakeApiResponse = await response.json()
if (data.features.length === 0) {
// Update node with no data state
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: {
node: {
...node,
earthquakeData: null,
},
isOutOfDate: false,
},
})
return { output: STOP_EXECUTION }
}
// Pick a random earthquake
const randomIndex = Math.floor(Math.random() * data.features.length)
const earthquake = data.features[randomIndex]
console.log(data.features)
const earthquakeData = {
magnitude: earthquake.properties.mag,
location: earthquake.properties.place,
datetime: new Date(earthquake.properties.time).toLocaleString(),
id: earthquake.id,
}
// Update node with fetched data
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: {
node: {
...node,
earthquakeData,
},
isOutOfDate: false,
},
})
return {
output: earthquakeData.magnitude,
}
} catch (error) {
console.error('Failed to fetch earthquake data:', error)
// Update node with error state
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: {
node: {
...node,
earthquakeData: null,
},
isOutOfDate: false,
},
})
return { output: STOP_EXECUTION }
}
}
getOutputInfo(shape: NodeShape, node: EarthquakeNode): InfoValues {
if (shape.props.isOutOfDate) {
return {
output: {
value: STOP_EXECUTION,
isOutOfDate: true,
},
}
}
return {
output: {
value: node.earthquakeData?.magnitude ?? STOP_EXECUTION,
isOutOfDate: shape.props.isOutOfDate,
},
}
}
Component = EarthquakeNodeComponent
}
export function EarthquakeNodeComponent({ shape, node }: NodeComponentProps<EarthquakeNode>) {
const { earthquakeData } = node
const isLoading = shape.props.isOutOfDate
// Show helpful message when no data has been loaded yet (not loading and no data)
if (!isLoading && !earthquakeData) {
return (
<div className="EarthquakeNode">
<div className="EarthquakeNode-message">
Connect this node and run the workflow to fetch earthquake data
</div>
</div>
)
}
return (
<div className="EarthquakeNode">
<NodeRow>
<div className="NodeValue">
<strong>Magnitude:</strong> {isLoading ? '…' : earthquakeData!.magnitude.toFixed(1)}
</div>
</NodeRow>
<NodeRow>
<div className="NodeValue" style={{ fontSize: '12px' }}>
<strong>Location:</strong> {isLoading ? '…' : earthquakeData!.location}
</div>
</NodeRow>
<NodeRow>
<div className="NodeValue" style={{ fontSize: '12px' }}>
<strong>Time:</strong> {isLoading ? '…' : earthquakeData!.datetime}
</div>
</NodeRow>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { T, useEditor } from 'tldraw'
import { MultiplyIcon } from '../../components/icons/MultiplyIcon'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
NODE_WIDTH_PX,
} from '../../constants'
import { ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
areAnyInputsOutOfDate,
ExecutionResult,
InfoValues,
InputValues,
NodeComponentProps,
NodeDefinition,
NodeInputRow,
updateNode,
} from './shared'
/**
* The multiply node multiplies its two inputs.
*/
export type MultiplyNode = T.TypeOf<typeof MultiplyNode>
export const MultiplyNode = T.object({
type: T.literal('multiply'),
a: T.number,
b: T.number,
lastResult: T.number.nullable(),
})
export class MultiplyNodeDefinition extends NodeDefinition<MultiplyNode> {
static type = 'multiply'
static validator = MultiplyNode
title = 'Multiply'
heading = 'Multiply'
icon = <MultiplyIcon />
getDefault(): MultiplyNode {
return {
type: 'multiply',
a: 0,
b: 0,
lastResult: null,
}
}
getBodyHeightPx(_shape: NodeShape, _node: MultiplyNode) {
return NODE_ROW_HEIGHT_PX * 2
}
getPorts(_shape: NodeShape, _node: MultiplyNode): Record<string, ShapePort> {
return {
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
multiplicand: {
id: 'multiplicand',
x: 0,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
multiplier: {
id: 'multiplier',
x: 0,
y:
NODE_HEADER_HEIGHT_PX +
NODE_ROW_HEADER_GAP_PX +
NODE_ROW_HEIGHT_PX +
NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
}
}
async execute(
shape: NodeShape,
node: MultiplyNode,
inputs: InputValues
): Promise<ExecutionResult> {
await sleep(1000)
const result = (inputs.multiplicand ?? node.a) * (inputs.multiplier ?? node.b)
updateNode<MultiplyNode>(this.editor, shape, (node) => ({
...node,
lastResult: result,
}))
return {
output: result,
}
}
getOutputInfo(shape: NodeShape, node: MultiplyNode, inputs: InfoValues): InfoValues {
return {
output: {
value: node.lastResult ?? 0,
isOutOfDate: areAnyInputsOutOfDate(inputs) || shape.props.isOutOfDate,
},
}
}
Component = MultiplyNodeComponent
}
export function MultiplyNodeComponent({ shape, node }: NodeComponentProps<MultiplyNode>) {
const editor = useEditor()
return (
<>
<NodeInputRow
shapeId={shape.id}
portId="multiplicand"
value={node.a}
onChange={(newValue) =>
updateNode<MultiplyNode>(editor, shape, (node) => ({ ...node, a: newValue }))
}
/>
<NodeInputRow
shapeId={shape.id}
portId="multiplier"
value={node.b}
onChange={(newValue) =>
updateNode<MultiplyNode>(editor, shape, (node) => ({ ...node, b: newValue }))
}
/>
</>
)
}

View File

@@ -0,0 +1,85 @@
import { T, TldrawUiSlider, useEditor } from 'tldraw'
import { SliderIcon } from '../../components/icons/SliderIcon'
import { NODE_HEADER_HEIGHT_PX, NODE_ROW_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
import { ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
ExecutionResult,
InfoValues,
NodeComponentProps,
NodeDefinition,
NodeRow,
updateNode,
} from './shared'
/**
* The slider node has a single output port and no inputs.
*/
export type SliderNode = T.TypeOf<typeof SliderNode>
export const SliderNode = T.object({
type: T.literal('slider'),
value: T.number,
})
export class SliderNodeDefinition extends NodeDefinition<SliderNode> {
static type = 'slider'
static validator = SliderNode
title = 'Slider'
heading = 'Slider'
icon = <SliderIcon />
getDefault(): SliderNode {
return {
type: 'slider',
value: 50,
}
}
getBodyHeightPx(_shape: NodeShape, _node: SliderNode) {
return NODE_ROW_HEIGHT_PX
}
getPorts(_shape: NodeShape, _node: SliderNode): Record<string, ShapePort> {
return {
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
}
}
async execute(_shape: NodeShape, node: SliderNode): Promise<ExecutionResult> {
await sleep(1000)
return {
output: node.value,
}
}
getOutputInfo(shape: NodeShape, node: SliderNode): InfoValues {
return {
output: {
value: node.value,
isOutOfDate: shape.props.isOutOfDate,
},
}
}
Component = SliderNodeComponent
}
export function SliderNodeComponent({ shape, node }: NodeComponentProps<SliderNode>) {
const editor = useEditor()
return (
<NodeRow className="SliderNode" onPointerDown={editor.markEventAsHandled}>
<TldrawUiSlider
steps={100}
value={node.value}
label="Slider"
title={node.value.toString()}
onValueChange={(value) => {
editor.setSelectedShapes([shape.id])
updateNode<SliderNode>(editor, shape, (node) => ({ ...node, value }))
}}
onHistoryMark={() => {}}
/>
</NodeRow>
)
}

View File

@@ -0,0 +1,126 @@
import { T, useEditor } from 'tldraw'
import { SubtractIcon } from '../../components/icons/SubtractIcon'
import {
NODE_HEADER_HEIGHT_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
NODE_WIDTH_PX,
} from '../../constants'
import { ShapePort } from '../../ports/Port'
import { sleep } from '../../utils/sleep'
import { NodeShape } from '../NodeShapeUtil'
import {
areAnyInputsOutOfDate,
ExecutionResult,
InfoValues,
InputValues,
NodeComponentProps,
NodeDefinition,
NodeInputRow,
updateNode,
} from './shared'
/**
* The subtract node subtracts its two inputs.
*/
export type SubtractNode = T.TypeOf<typeof SubtractNode>
export const SubtractNode = T.object({
type: T.literal('subtract'),
a: T.number,
b: T.number,
lastResult: T.number.nullable(),
})
export class SubtractNodeDefinition extends NodeDefinition<SubtractNode> {
static type = 'subtract'
static validator = SubtractNode
title = 'Subtract'
heading = 'Subtract'
icon = <SubtractIcon />
getDefault(): SubtractNode {
return {
type: 'subtract',
a: 0,
b: 0,
lastResult: null,
}
}
getBodyHeightPx(_shape: NodeShape, _node: SubtractNode) {
return NODE_ROW_HEIGHT_PX * 2
}
getPorts(_shape: NodeShape, _node: SubtractNode): Record<string, ShapePort> {
return {
output: {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
},
minuend: {
id: 'minuend',
x: 0,
y: NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
subtrahend: {
id: 'subtrahend',
x: 0,
y:
NODE_HEADER_HEIGHT_PX +
NODE_ROW_HEADER_GAP_PX +
NODE_ROW_HEIGHT_PX +
NODE_ROW_HEIGHT_PX / 2,
terminal: 'end',
},
}
}
async execute(
shape: NodeShape,
node: SubtractNode,
inputs: InputValues
): Promise<ExecutionResult> {
await sleep(1000)
const result = (inputs.minuend ?? node.a) - (inputs.subtrahend ?? node.b)
updateNode<SubtractNode>(this.editor, shape, (node) => ({
...node,
lastResult: result,
}))
return {
output: result,
}
}
getOutputInfo(shape: NodeShape, node: SubtractNode, inputs: InfoValues): InfoValues {
return {
output: {
value: node.lastResult ?? 0,
isOutOfDate: areAnyInputsOutOfDate(inputs) || shape.props.isOutOfDate,
},
}
}
Component = SubtractNodeComponent
}
export function SubtractNodeComponent({ shape, node }: NodeComponentProps<SubtractNode>) {
const editor = useEditor()
return (
<>
<NodeInputRow
shapeId={shape.id}
portId="minuend"
value={node.a}
onChange={(newValue) =>
updateNode<SubtractNode>(editor, shape, (node) => ({ ...node, a: newValue }))
}
/>
<NodeInputRow
shapeId={shape.id}
portId="subtrahend"
value={node.b}
onChange={(newValue) =>
updateNode<SubtractNode>(editor, shape, (node) => ({ ...node, b: newValue }))
}
/>
</>
)
}

View File

@@ -0,0 +1,258 @@
import classNames from 'classnames'
import { PointerEvent, useCallback, useRef, useState } from 'react'
import {
Editor,
T,
TldrawUiButton,
TldrawUiButtonIcon,
TLShapeId,
TLUiIconJsx,
useEditor,
useValue,
} from 'tldraw'
import { AddIcon } from '../../components/icons/AddIcon'
import { SubtractIcon } from '../../components/icons/SubtractIcon'
import { NODE_HEADER_HEIGHT_PX, NODE_WIDTH_PX } from '../../constants'
import { Port, PortId, ShapePort } from '../../ports/Port'
import { getNodeInputPortValues } from '../nodePorts'
import { NodeShape } from '../NodeShapeUtil'
import { NodeType } from '../nodeTypes'
export type WorkflowValue = number
/**
* A special value that can be returned from a node to indicate that execution should stop.
*/
export type STOP_EXECUTION = typeof STOP_EXECUTION
export const STOP_EXECUTION = Symbol('STOP_EXECUTION')
export interface InfoValues {
[key: string]: { value: WorkflowValue | STOP_EXECUTION; isOutOfDate: boolean }
}
export interface ExecutionResult {
[key: string]: WorkflowValue | STOP_EXECUTION
}
export interface InputValues {
[key: string]: WorkflowValue
}
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 getBodyHeightPx(shape: NodeShape, node: Node): number
abstract getPorts(shape: NodeShape, node: Node): Record<string, ShapePort>
onPortConnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
onPortDisconnect(_shape: NodeShape, _node: Node, _port: PortId): void {}
abstract getOutputInfo(shape: NodeShape, node: Node, inputs: InfoValues): InfoValues
abstract execute(shape: NodeShape, node: Node, inputs: InputValues): Promise<ExecutionResult>
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 output port for a node, appearing in the node header.
*/
export const outputPort: ShapePort = {
id: 'output',
x: NODE_WIDTH_PX,
y: NODE_HEADER_HEIGHT_PX / 2,
terminal: 'start',
}
/**
* Update the `node` prop within a node shape.
*/
export function updateNode<T extends NodeType>(
editor: Editor,
shape: NodeShape,
update: (node: T) => T,
isOutOfDate: boolean = true
) {
editor.updateShape({
id: shape.id,
type: shape.type,
props: { node: update(shape.props.node as T), isOutOfDate },
})
}
/**
* A row in a node. This component just applies some styling.
*/
export function NodeRow({
children,
className,
...props
}: {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div {...props} className={classNames('NodeRow', className)}>
{children}
</div>
)
}
/**
* A row in a node for a numeric input. If the port is connected, the input is disabled and the
* value is taken from the port. Otherwise, the input is editable with a spinner for incrementing
* and decrementing the value.
*/
export function NodeInputRow({
shapeId,
portId,
value,
onChange,
onBlur,
}: {
shapeId: TLShapeId
portId: PortId
value: number
onChange: (value: number) => void
onBlur?: () => void
}) {
const editor = useEditor()
const inputRef = useRef<HTMLInputElement>(null)
const portInfo = useValue('from port', () => getNodeInputPortValues(editor, shapeId)[portId], [
editor,
shapeId,
portId,
])
const valueFromPort = portInfo?.value
const isOutOfDate = portInfo?.isOutOfDate
const [pendingValue, setPendingValue] = useState<string | null>(null)
const onPointerDown = useCallback((event: PointerEvent) => {
event.stopPropagation()
}, [])
const onSpinner = (delta: number) => {
const newValue = value + delta
onChange(newValue)
setPendingValue(String(newValue))
inputRef.current?.focus()
}
return (
<NodeRow className="NodeInputRow">
<Port shapeId={shapeId} portId={portId} />
{isOutOfDate || valueFromPort === STOP_EXECUTION ? (
<NodeValue value={isOutOfDate ? STOP_EXECUTION : valueFromPort} />
) : (
<input
ref={inputRef}
type="text"
inputMode="decimal"
disabled={valueFromPort != null}
value={valueFromPort ?? pendingValue ?? value}
onChange={(e) => {
setPendingValue(e.currentTarget.value)
const asNumber = Number(e.currentTarget.value.trim())
if (Number.isNaN(asNumber)) return
onChange(asNumber)
}}
onPointerDown={onPointerDown}
onBlur={() => {
setPendingValue(null)
onBlur?.()
}}
onFocus={() => {
editor.setSelectedShapes([shapeId])
}}
/>
)}
<div className="NodeInputRow-buttons">
<TldrawUiButton
title="decrement"
type="icon"
onPointerDown={onPointerDown}
onClick={() => onSpinner(-1)}
>
<TldrawUiButtonIcon icon={<SubtractIcon />} />
</TldrawUiButton>
<TldrawUiButton
title="increment"
type="icon"
onPointerDown={onPointerDown}
onClick={() => onSpinner(1)}
>
<TldrawUiButtonIcon icon={<AddIcon />} />
</TldrawUiButton>
</div>
</NodeRow>
)
}
/**
* Format a number to display with up to 5 significant figures, using suffixes for large numbers.
*/
function formatNumber(value: number): string {
// Handle special cases
if (value === 0) return '0'
if (!isFinite(value)) return value.toString()
const absValue = Math.abs(value)
const sign = value < 0 ? '-' : ''
// For very large numbers, use suffixes
if (absValue >= 1_000_000_000) {
return sign + (absValue / 1_000_000_000).toPrecision(3) + 'B'
}
if (absValue >= 1_000_000) {
return sign + (absValue / 1_000_000).toPrecision(3) + 'M'
}
if (absValue >= 1_000) {
return sign + (absValue / 1_000).toPrecision(3) + 'k'
}
// For smaller numbers, use up to 5 significant figures
if (absValue >= 1) {
// For numbers >= 1, limit to 5 significant figures
return sign + absValue.toPrecision(5).replace(/\.?0+$/, '')
} else if (absValue >= 0.001) {
// For numbers between 0.001 and 1, show up to 5 significant figures
return sign + absValue.toPrecision(3)
} else {
// For very small numbers, use scientific notation
return value.toExponential(2)
}
}
/**
* A value within a node. If the value is STOP_EXECUTION, a placeholder is shown instead.
*/
export function NodeValue({ value }: { value: number | STOP_EXECUTION }) {
if (value === STOP_EXECUTION) {
return <div className="NodeValue_placeholder" />
}
return formatNumber(value)
}
export function areAnyInputsOutOfDate(inputs: InfoValues): boolean {
return Object.values(inputs).some((input) => input.isOutOfDate)
}

View File

@@ -0,0 +1,204 @@
import { createShapeId, StateNode, TLPointerEventInfo, TLShapeId } from 'tldraw'
import { onCanvasComponentPickerState } from '../components/OnCanvasComponentPicker.tsx'
import {
createOrUpdateConnectionBinding,
getConnectionBindings,
} from '../connection/ConnectionBindingUtil'
import { getNextConnectionIndex } from '../connection/keepConnectionsAtBottom'
import {
DEFAULT_NODE_SPACING_PX,
NODE_HEADER_HEIGHT_PX,
NODE_ROW_HEADER_GAP_PX,
NODE_ROW_HEIGHT_PX,
} from '../constants.tsx'
import { getNodePortConnections, getNodePorts } from '../nodes/nodePorts'
import { PortId } from '../ports/Port'
// 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
}
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()) {
const allowsMultipleConnections = this.info?.terminal === 'start'
const hasExistingConnection = getNodePortConnections(this.editor, this.info!.shapeId).find(
(c) => c.ownPortId === this.info!.portId
)
// 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 (!allowsMultipleConnections && hasExistingConnection) {
this.parent.transition('dragging_handle', {
...info,
target: 'handle',
shape: this.editor.getShape(hasExistingConnection.connectionId)!,
handle: this.editor
.getShapeHandles(hasExistingConnection.connectionId)!
.find((h) => h.id === this.info!.terminal),
})
return
}
// Otherwise, create a new connection, and start dragging that connection's handle instead.
const creatingMarkId = this.editor.markHistoryStoppingPoint()
const connectionShapeId = createShapeId()
const connectingTerminal = this.info!.terminal
const draggingTerminal = connectingTerminal === 'start' ? 'end' : 'start'
this.editor.createShape({
type: 'connection',
id: connectionShapeId,
x: this.editor.inputs.getCurrentPagePoint().x,
y: this.editor.inputs.getCurrentPagePoint().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, this.info!.shapeId, {
portId: this.info!.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
const hasExistingConnection = getNodePortConnections(this.editor, this.info!.shapeId).find(
(c) => c.ownPortId === this.info!.portId
)
if (hasExistingConnection) return
// Get the bounds of the source node
const bounds = this.editor.getShapePageBounds(this.info!.shapeId)
if (!bounds) return
// Calculate position for new node to the right of the source node
const targetPositionInPageSpace = {
x: bounds.right + DEFAULT_NODE_SPACING_PX,
y: bounds.top,
}
// 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, this.info!.shapeId, {
portId: this.info!.portId,
terminal: 'start',
})
// Position the connection end point where the new node will be
const targetPositionInConnectionSpace = this.editor
.getPointInShapeSpace(connectionShapeId, targetPositionInPageSpace)
.addXY(0, NODE_HEADER_HEIGHT_PX + NODE_ROW_HEADER_GAP_PX + NODE_ROW_HEIGHT_PX / 2)
this.editor.updateShape({
id: connectionShapeId,
type: 'connection',
props: {
end: targetPositionInConnectionSpace.toJson(),
},
})
// Open the component picker to let the user choose what node to create
onCanvasComponentPickerState.set(this.editor, {
connectionShapeId,
location: 'end',
onPick: (nodeType, terminalInPageSpace) => {
// Create the new node at the specified position
const newNodeId = createShapeId()
this.editor.createShape({
type: 'node',
id: newNodeId,
x: terminalInPageSpace.x,
y: terminalInPageSpace.y,
props: {
node: nodeType,
},
})
this.editor.select(newNodeId)
// Position the node so its input port aligns with the connection end
const ports = getNodePorts(this.editor, newNodeId)
const firstInputPort = Object.values(ports).find((p) => p.terminal === 'end')
if (firstInputPort) {
this.editor.updateShape({
id: newNodeId,
type: 'node',
x: terminalInPageSpace.x - firstInputPort.x,
y: terminalInPageSpace.y - firstInputPort.y,
})
// Connect the new node to the connection
createOrUpdateConnectionBinding(this.editor, connectionShapeId, newNodeId, {
portId: firstInputPort.id,
terminal: 'end',
})
}
},
onClose: () => {
// If the connection isn't fully connected, delete it
const connection = this.editor.getShape(connectionShapeId)
if (!connection || !this.editor.isShapeOfType(connection, 'connection')) return
const bindings = getConnectionBindings(this.editor, connection)
if (!bindings.start || !bindings.end) {
this.editor.deleteShapes([connection.id])
}
},
})
}
}

View File

@@ -0,0 +1,88 @@
import classNames from 'classnames'
import { TLShapeId, useEditor, useValue, VecModel } from 'tldraw'
import { getNodePortConnections, getNodePorts } from '../nodes/nodePorts'
import { portState } from './portState'
export type PortId = string
/**
* 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
}
/**
* Shapes define their ports with a position, id, and whether they're the start (an output) or end
* (an input). of a connection.
*/
export interface ShapePort extends VecModel {
id: PortId
terminal: 'start' | 'end'
}
/**
* This react component renders a port.
*/
export function Port({ shapeId, portId }: { shapeId: TLShapeId; portId: PortId }) {
const editor = useEditor()
// get the port from the the node definition:
const port = useValue(
'port',
() => {
const shape = editor.getShape(shapeId)
if (!shape || !editor.isShapeOfType(shape, 'node')) return null
return getNodePorts(editor, shape)?.[portId]
},
[shapeId, portId, editor]
)
if (!port) throw new Error(`Port ${portId} not found on shape ${shapeId}`)
// 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 === portId && hintingPort.shapeId === shapeId
},
[editor, shapeId, portId]
)
// 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: 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 === portId)
}
return true
},
[editor, shapeId, port.terminal]
)
return (
<div
className={classNames(
`Port Port_${port.terminal}`,
isHinting ? 'Port_hinting' : isEligible ? 'Port_eligible' : undefined
)}
onPointerDown={() => {
editor.setCurrentTool('select.pointing_port', {
shapeId,
portId,
terminal: port.terminal,
})
}}
/>
)
}

View File

@@ -0,0 +1,49 @@
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)
if (!ports) return null
// 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 }
}

View File

@@ -0,0 +1,28 @@
import { Editor, TLShapeId } from 'tldraw'
import { EditorAtom } from '../utils'
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) => {
const newState = { ...state, ...update }
return newState
})
}

View File

@@ -0,0 +1,85 @@
import {
atom,
Atom,
Editor,
getIndexAbove,
getIndices,
IndexKey,
WeakCache,
ZERO_INDEX_KEY,
} from 'tldraw'
/**
* EditorAtom is an atom (tldraw's reactive signal) that is scoped to a specific editor. It's useful
* for storing and reacting to state that's shared between several components.
*
* We could use a single global `atom` for each `EditorAtom` use, but that doesn't work if we ever
* have multiple editors. Even if we don't, scoping UI state to the lifecycle of the editor helps
* prevent bugs that could be caused from using the same state with two different documents.
*/
export class EditorAtom<T> {
private states = new WeakCache<Editor, Atom<T>>()
constructor(
private name: string,
private getInitialState: (editor: Editor) => T
) {}
getAtom(editor: Editor) {
return this.states.get(editor, () => atom(this.name, this.getInitialState(editor)))
}
get(editor: Editor) {
return this.getAtom(editor).get()
}
update(editor: Editor, update: (state: T) => T) {
return this.getAtom(editor).update(update)
}
set(editor: Editor, state: T) {
return this.getAtom(editor).set(state)
}
}
/**
* A list of items keyed by fractional indices. This is useful for collaborative editing, very
* efficient inserts/removals, and keeping the indexes of given items stable as others are
* inserted/removed around them.
*/
export type IndexList<T> = Record<IndexKey, T>
/**
* Convert an array into an index list.
*/
export function indexList<T>(array: T[]): IndexList<T> {
const indices = getIndices(array.length)
return Object.fromEntries(array.map((value, i) => [indices[i], value]))
}
/**
* Convert an index list into an array of entries.
*/
export function indexListEntries<T>(list: IndexList<T>) {
return Object.entries(list).sort((a, b) => {
if (a[0] === b[0]) return 0
if (a[0] < b[0]) return -1
return 1
}) as [IndexKey, T][]
}
/**
* Get the length of an index list.
*/
export function indexListLength<T>(list: IndexList<T>) {
return Object.keys(list).length
}
/**
* Append a value to an index list.
*/
export function appendToIndexList<T>(list: IndexList<T>, value: T) {
const entries = indexListEntries(list)
const lastIndex = entries[entries.length - 1]?.[0] ?? ZERO_INDEX_KEY
entries.push([getIndexAbove(lastIndex), value])
return Object.fromEntries(entries)
}

View File

@@ -0,0 +1,8 @@
/**
* Delays execution for a specified number of milliseconds.
* @param ms - The number of milliseconds to delay.
* @returns A promise that resolves after the specified delay.
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"],
"references": [{ "path": "../../packages/tldraw" }]
}

View File

@@ -0,0 +1,6 @@
{
"framework": "vite",
"installCommand": "yarn",
"buildCommand": "yarn run -T lazy build",
"ignoreCommand": "bash ../../internal/scripts/vercel/should-build-template.sh workflow"
}

View File

@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})