mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-17 02:25:57 +00:00
feat: new graph mode setup (#234)
* feat: improve the logic for node width * feat: use dagre to display loops * chore: use animated dots for loops * feat: create graph using cytoscape * feat: use cytoscape html label * feat: setup dynamic label height and width * feat: set reasonable zoom levels * feat: use theme colors for nodes * feat: use mutation observer to change color schemes * feat: implement dark mode with pure css * chore: remove unused libraries * fix: sanitize label with dompurify
This commit is contained in:
@@ -1,19 +1,8 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
ControlButton,
|
||||
Controls,
|
||||
type Edge,
|
||||
MiniMap,
|
||||
type Node,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import dagre from 'dagre'
|
||||
import cytoscape from 'cytoscape'
|
||||
import dagre from 'cytoscape-dagre'
|
||||
// @ts-expect-error no types available
|
||||
import nodeHtmlLabel from 'cytoscape-node-html-label'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Maximize,
|
||||
@@ -24,7 +13,7 @@ import {
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import ProductLogo from '@/assets/product_logo.svg'
|
||||
@@ -35,19 +24,75 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { GraphData } from './CreateGraph'
|
||||
import { CustomNode, type NodeType } from './CustomNode'
|
||||
import type { NodeType } from './CustomNode'
|
||||
|
||||
const nodeTypes: Record<NodeType, typeof CustomNode> = {
|
||||
start: CustomNode,
|
||||
end: CustomNode,
|
||||
nav: CustomNode,
|
||||
act: CustomNode,
|
||||
extract: CustomNode,
|
||||
verify: CustomNode,
|
||||
decision: CustomNode,
|
||||
loop: CustomNode,
|
||||
fork: CustomNode,
|
||||
join: CustomNode,
|
||||
cytoscape.use(dagre)
|
||||
nodeHtmlLabel(cytoscape)
|
||||
|
||||
const NODE_CONFIG: Record<
|
||||
NodeType,
|
||||
{ color: string; bgColor: string; icon: string; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
|
||||
label: 'START',
|
||||
},
|
||||
end: {
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
|
||||
label: 'END',
|
||||
},
|
||||
nav: {
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
|
||||
label: 'NAVIGATE',
|
||||
},
|
||||
act: {
|
||||
color: '#8b5cf6',
|
||||
bgColor: 'rgba(139, 92, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
|
||||
label: 'ACTION',
|
||||
},
|
||||
extract: {
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
|
||||
label: 'EXTRACT',
|
||||
},
|
||||
verify: {
|
||||
color: '#10b981',
|
||||
bgColor: 'rgba(16, 185, 129, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
|
||||
label: 'VERIFY',
|
||||
},
|
||||
decision: {
|
||||
color: '#ec4899',
|
||||
bgColor: 'rgba(236, 72, 153, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
|
||||
label: 'DECISION',
|
||||
},
|
||||
loop: {
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
|
||||
label: 'LOOP',
|
||||
},
|
||||
fork: {
|
||||
color: '#6366f1',
|
||||
bgColor: 'rgba(99, 102, 241, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
|
||||
label: 'FORK',
|
||||
},
|
||||
join: {
|
||||
color: '#84cc16',
|
||||
bgColor: 'rgba(132, 204, 22, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
|
||||
label: 'JOIN',
|
||||
},
|
||||
}
|
||||
|
||||
const initialData: GraphData = {
|
||||
@@ -61,38 +106,69 @@ const initialData: GraphData = {
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
const MIN_NODE_WIDTH = 180
|
||||
const MAX_NODE_WIDTH = 240
|
||||
const BASE_NODE_HEIGHT = 70
|
||||
const CHAR_WIDTH = 7
|
||||
const ICON_AND_PADDING = 62
|
||||
const MAX_ZOOM = 1.2
|
||||
|
||||
const nodeWidth = 180
|
||||
const nodeHeight = 60
|
||||
const calculateNodeDimensions = (
|
||||
label: string,
|
||||
): { width: number; height: number } => {
|
||||
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
|
||||
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
|
||||
|
||||
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
|
||||
dagreGraph.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 })
|
||||
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
|
||||
const lines = Math.ceil(label.length / maxCharsPerLine)
|
||||
const extraHeight = Math.max(0, lines - 1) * 18
|
||||
const height = BASE_NODE_HEIGHT + extraHeight
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight })
|
||||
})
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
node.position = {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
}
|
||||
node.style = {
|
||||
...node.style,
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
const createNodeHtml = (type: NodeType, label: string): string => {
|
||||
const config = NODE_CONFIG[type] || NODE_CONFIG.start
|
||||
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
|
||||
return `
|
||||
<div class="graph-node" style="
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--graph-node-bg);
|
||||
border: 1px solid var(--graph-node-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
">
|
||||
<div style="
|
||||
flex-shrink: 0;
|
||||
color: ${config.color};
|
||||
margin-top: 2px;
|
||||
">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${config.color};
|
||||
margin-bottom: 4px;
|
||||
">${config.label}</div>
|
||||
<div style="
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--graph-node-text);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
">${sanitizedLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
type GraphCanvasProps = {
|
||||
@@ -108,7 +184,7 @@ type GraphCanvasProps = {
|
||||
panelSize?: { asPercentage: number; inPixels: number }
|
||||
}
|
||||
|
||||
const GraphCanvasInner: FC<GraphCanvasProps> = ({
|
||||
export const GraphCanvas: FC<GraphCanvasProps> = ({
|
||||
graphName,
|
||||
onGraphNameChange,
|
||||
graphData = initialData,
|
||||
@@ -121,8 +197,9 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
|
||||
panelSize,
|
||||
}) => {
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const { fitView, zoomIn, zoomOut } = useReactFlow()
|
||||
const navigate = useNavigate()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cyRef = useRef<cytoscape.Core | null>(null)
|
||||
|
||||
const handleBack = () => {
|
||||
if (shouldBlockNavigation) {
|
||||
@@ -153,51 +230,158 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
|
||||
return isSaved ? 'Save Changes' : 'Save Workflow'
|
||||
}
|
||||
|
||||
// Initialize nodes and edges with layout
|
||||
const initialLayout = getLayoutedElements(
|
||||
graphData.nodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, type: n.type },
|
||||
position: { x: 0, y: 0 },
|
||||
})),
|
||||
graphData.edges,
|
||||
)
|
||||
const zoomIn = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialLayout.nodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialLayout.edges)
|
||||
const zoomOut = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
// Handle graph updates from chat
|
||||
const handleGraphUpdate = useCallback(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: graph data from external source
|
||||
(newGraphData: { nodes: any[]; edges: any[] }) => {
|
||||
const layouted = getLayoutedElements(
|
||||
newGraphData.nodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, type: n.type },
|
||||
position: { x: 0, y: 0 },
|
||||
})),
|
||||
newGraphData.edges,
|
||||
)
|
||||
setNodes(layouted.nodes)
|
||||
setEdges(layouted.edges)
|
||||
},
|
||||
[setNodes, setEdges],
|
||||
)
|
||||
const fitView = useCallback(() => {
|
||||
cyRef.current?.fit(undefined, 50)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: [],
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
width: 'data(nodeWidth)',
|
||||
height: 'data(nodeHeight)',
|
||||
'background-opacity': 0,
|
||||
'border-width': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2,
|
||||
'line-color': '#f97316',
|
||||
'target-arrow-color': '#f97316',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge.back-edge',
|
||||
style: {
|
||||
'line-style': 'dashed',
|
||||
'line-dash-pattern': [6, 3],
|
||||
'curve-style': 'unbundled-bezier',
|
||||
'control-point-distances': [100],
|
||||
'control-point-weights': [0.5],
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: { name: 'preset' },
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
selectionType: 'single',
|
||||
autoungrabify: true,
|
||||
autounselectify: true,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: 0.2,
|
||||
})
|
||||
|
||||
// @ts-expect-error nodeHtmlLabel extension
|
||||
cy.nodeHtmlLabel([
|
||||
{
|
||||
query: 'node',
|
||||
halign: 'center',
|
||||
valign: 'center',
|
||||
halignBox: 'center',
|
||||
valignBox: 'center',
|
||||
tpl: (data: { type: NodeType; label: string }) => {
|
||||
return createNodeHtml(data.type, data.label)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
cyRef.current = cy
|
||||
|
||||
return () => {
|
||||
cy.destroy()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateGraph = useCallback((data: GraphData) => {
|
||||
const cy = cyRef.current
|
||||
if (!cy) return
|
||||
|
||||
cy.elements().remove()
|
||||
|
||||
const nodes = data.nodes.map((node) => {
|
||||
const dimensions = calculateNodeDimensions(node.data.label)
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.data.label,
|
||||
type: node.type as NodeType,
|
||||
nodeWidth: dimensions.width,
|
||||
nodeHeight: dimensions.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges = data.edges.map((edge) => ({
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
},
|
||||
}))
|
||||
|
||||
cy.add([...nodes, ...edges])
|
||||
|
||||
cy.layout({
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 80,
|
||||
rankSep: 100,
|
||||
padding: 50,
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
fit: true,
|
||||
} as cytoscape.LayoutOptions).run()
|
||||
|
||||
setTimeout(() => {
|
||||
cy.edges().forEach((edge) => {
|
||||
const sourceNode = edge.source()
|
||||
const targetNode = edge.target()
|
||||
const sourceY = sourceNode.position('y')
|
||||
const targetY = targetNode.position('y')
|
||||
|
||||
if (sourceY > targetY) {
|
||||
edge.addClass('back-edge')
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
}, [])
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
handleGraphUpdate(graphData)
|
||||
setTimeout(() => fitView({ duration: 300, maxZoom: 0.75 }), 50)
|
||||
updateGraph(graphData)
|
||||
}, [graphData])
|
||||
|
||||
// Auto fitView when panel is resized
|
||||
useEffect(() => {
|
||||
if (panelSize?.inPixels !== undefined) {
|
||||
fitView({ duration: 200, maxZoom: 0.75 })
|
||||
cyRef.current?.resize()
|
||||
setTimeout(() => fitView(), 100)
|
||||
}
|
||||
}, [panelSize?.inPixels, fitView])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
|
||||
{/* Graph Header */}
|
||||
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-3 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
@@ -283,93 +467,48 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
|
||||
</header>
|
||||
|
||||
{/* Graph Canvas */}
|
||||
<div className="relative flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ maxZoom: 0.75 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
edgesFocusable={false}
|
||||
nodesFocusable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
style: { stroke: 'var(--accent-orange)', strokeWidth: 2 },
|
||||
type: 'smoothstep',
|
||||
<div className="relative flex-1 bg-[hsl(var(--background))]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, hsl(var(--muted-foreground) / 0.2) 1px, transparent 1px)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls
|
||||
showZoom={false}
|
||||
showFitView={false}
|
||||
showInteractive={false}
|
||||
style={{
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '2px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'clip',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 left-4 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ControlButton
|
||||
onClick={() => zoomIn()}
|
||||
title="Zoom in"
|
||||
className="bg-card! fill-foreground! hover:bg-muted!"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => zoomOut()}
|
||||
title="Zoom out"
|
||||
className="bg-card! fill-foreground! hover:bg-muted!"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => fitView({ duration: 300, maxZoom: 0.75 })}
|
||||
title="Fit view"
|
||||
className="bg-card! fill-foreground! hover:bg-muted!"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={(node) => {
|
||||
const colors: Record<string, string> = {
|
||||
start: '#22c55e',
|
||||
end: '#ef4444',
|
||||
nav: '#3b82f6',
|
||||
act: '#8b5cf6',
|
||||
extract: '#f59e0b',
|
||||
verify: '#10b981',
|
||||
decision: '#ec4899',
|
||||
loop: '#06b6d4',
|
||||
fork: '#6366f1',
|
||||
join: '#84cc16',
|
||||
}
|
||||
return colors[node.type || 'default'] || '#gray'
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitView}
|
||||
title="Fit view"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphCanvas: FC<GraphCanvasProps> = (props) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<GraphCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@sentry/react": "^10.31.0",
|
||||
"@sentry/vite-plugin": "^4.6.1",
|
||||
"@types/cytoscape": "^3.31.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"@wxt-dev/storage": "^1.2.6",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
@@ -47,8 +49,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"cytoscape-node-html-label": "^1.2.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"downshift": "^9.0.10",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"es-toolkit": "^1.42.0",
|
||||
@@ -82,6 +87,7 @@
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/chrome": "^0.1.28",
|
||||
"@types/cytoscape-dagre": "^2.3.4",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
||||
22
bun.lock
22
bun.lock
@@ -44,6 +44,8 @@
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@sentry/react": "^10.31.0",
|
||||
"@sentry/vite-plugin": "^4.6.1",
|
||||
"@types/cytoscape": "^3.31.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"@wxt-dev/storage": "^1.2.6",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
@@ -51,8 +53,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"cytoscape-node-html-label": "^1.2.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"downshift": "^9.0.10",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"es-toolkit": "^1.42.0",
|
||||
@@ -86,6 +91,7 @@
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/chrome": "^0.1.28",
|
||||
"@types/cytoscape-dagre": "^2.3.4",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -117,7 +123,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.40",
|
||||
"version": "0.0.41",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
@@ -1115,6 +1121,10 @@
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/cytoscape": ["@types/cytoscape@3.31.0", "", { "dependencies": { "cytoscape": "*" } }, "sha512-EXHOHxqQjGxLDEh5cP4te6J0bi7LbCzmZkzsR6f703igUac8UGMdEohMyU3GHAayCTZrLQOMnaE/lqB2Ekh8Ww=="],
|
||||
|
||||
"@types/cytoscape-dagre": ["@types/cytoscape-dagre@2.3.4", "", { "dependencies": { "cytoscape": "^3.31" } }, "sha512-uOGXuPfPLFoKZaegjHl9oj4tqONNJuhUl180FiJgRZ35rVijBs6J4UP1Ah6mA6S46h+7pv4ICqpgfdC3EADZlw=="],
|
||||
|
||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
@@ -1181,6 +1191,8 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
@@ -1497,7 +1509,7 @@
|
||||
|
||||
"chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="],
|
||||
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.12.1", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-QREfGxJVVlBrjKdyis9px6UHyXix+Rre9nCkqX7CY7GsU8c6azOwwV8inQB8E3h2/QGqi4sCSF8fmjfAvmE07Q=="],
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.13.0", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-CgotJczVYe6wG2b5cqwNFq7n4VXIM8qfvfutdzVlABPKf0b99b0TDPuRsWrviCthigSHzhpMyFQW03P5Utt1Fg=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
|
||||
|
||||
@@ -1605,8 +1617,12 @@
|
||||
|
||||
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
|
||||
|
||||
"cytoscape-dagre": ["cytoscape-dagre@2.5.0", "", { "dependencies": { "dagre": "^0.8.5" }, "peerDependencies": { "cytoscape": "^3.2.22" } }, "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g=="],
|
||||
|
||||
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
|
||||
|
||||
"cytoscape-node-html-label": ["cytoscape-node-html-label@1.2.2", "", { "peerDependencies": { "@types/cytoscape": "^3.1.0", "cytoscape": "^3.0.0" }, "optionalPeers": ["@types/cytoscape"] }, "sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg=="],
|
||||
|
||||
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
Reference in New Issue
Block a user