feat: node performance enhancement

This commit is contained in:
dextmorgn
2025-03-17 17:13:35 +01:00
parent 72c5827711
commit 2ad4eea017
13 changed files with 758 additions and 1060 deletions

View File

@@ -16,6 +16,8 @@ import { useConfirm } from "@/components/use-confirm-dialog";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { NodeNotesEditor } from "../node-notes-editor";
import { useFlowStore } from "@/store/flow-store";
import { toast } from "sonner";
interface NodeContextType {
setOpenAddNodeModal: any,
@@ -55,6 +57,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
const [loading, setLoading] = useState(false)
const [nodeType, setnodeType] = useState<any | null>(null)
const nodeId = useNodeId();
const { setCurrentNode } = useFlowStore()
const { confirm } = useConfirm();
const returnError = (message: string) => {
@@ -79,53 +82,113 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
await handleAddNode(data);
};
const handleAddNode = async (data: any) => {
setLoading(true)
if (!nodeId) return returnError("No node detected.")
const dataToInsert = { ...data, investigation_id }
if (nodeType.table !== "individuals")
dataToInsert["individual_id"] = nodeId
const node = await supabase.from(nodeType.table).insert(dataToInsert).select("*")
.single()
.then(({ data, error }) => {
if (error)
returnError(error.details)
return data
})
if (!node) return
if (nodeType.table === "individuals") {
// create relation to investigation
await supabase.from("investigation_individuals").insert({
individual_id: node.id,
investigation_id: investigation_id
}).then(({ error }) => console.log(error))
const handleAddNode = async (
data: any,
) => {
try {
setLoading(true)
await supabase.from("relationships").upsert({
individual_a: nodeId,
individual_b: node.id,
relation_type: "relation"
}).then(({ error }) => { if (error) returnError(error.details) }
)
// Validate required data
if (!nodeId) {
toast.error("No node detected.")
setLoading(false)
return
}
// Prepare data for insertion
const dataToInsert = { ...data, investigation_id }
if (nodeType.table !== "individuals") {
dataToInsert["individual_id"] = nodeId
}
// Insert the node
const { data: nodeData, error: insertError } = await supabase
.from(nodeType.table)
.insert(dataToInsert)
.select("*")
.single()
if (insertError) {
toast.error(insertError.details)
setLoading(false)
return
}
if (!nodeData) {
toast.error("Failed to create node.")
setLoading(false)
return
}
// Handle individuals table specific logic
if (nodeType.table === "individuals") {
// Create relation to investigation
const { error: relationError } = await supabase.from("investigation_individuals").insert({
individual_id: nodeData.id,
investigation_id: investigation_id,
})
if (relationError) {
toast.error("Error creating investigation relation:" + JSON.stringify(relationError))
}
// Create relationship between individuals
const { error: relationshipError } = await supabase.from("relationships").upsert({
individual_a: nodeId,
individual_b: nodeData.id,
relation_type: "relation",
})
if (relationshipError) {
toast.error(relationshipError.details)
}
}
// Add node to graph
const newNode = {
id: nodeData.id,
type: nodeType.type,
data: { ...nodeData, label: data[nodeType.fields[0]] },
position: { x: 0, y: 0 },
}
addNodes(newNode)
// Add edge if needed
if (nodeId) {
const newEdge = {
source: nodeId,
target: nodeData.id,
type: "custom",
id: `${nodeId}-${nodeData.id}`.toString(),
label: nodeType.type === "individual" ? "relation" : nodeType.type,
}
addEdges(newEdge)
}
// Use a callback function with setState to ensure we're working with the latest state
// This is the fix for the setCurrentNode issue
const newNodeId = nodeData.id
// Close modal and update state in a specific order
setOpenNodeModal(false)
setError(null)
// Use setTimeout to ensure this happens after the current execution context
// This helps with React's batching of state updates
setTimeout(() => {
setCurrentNode(newNodeId)
setLoading(false)
}, 0)
} catch (error) {
toast.error("An unexpected error occurred")
setLoading(false)
}
addNodes({
id: node.id,
type: nodeType.type,
data: { ...node, label: data[nodeType.fields[0]] },
position: { x: 0, y: 0 }
});
if (nodeId)
addEdges({
source: nodeId,
target: node.id,
type: 'custom',
id: `${nodeId}-${node.id}`.toString(),
label: nodeType.type === "individual" ? "relation" : nodeType.type,
});
setLoading(false)
setError(null)
setOpenNodeModal(false)
}
const handleDuplicateNode = async () => {
await supabase.from("individuals")
.select("*")
@@ -137,7 +200,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
.insert({ full_name: data.full_name })
.select("*")
.single()
if (insertError) returnError(insertError.details)
if (insertError) toast.error(insertError.details)
addNodes({
id: node.id,
type: "individual",
@@ -154,7 +217,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
.delete()
.eq("id", nodeId)
.then(({ error }) => {
if (error) returnError(error.details)
if (error) toast.error(error.details)
})
setNodes((nodes: any[]) => nodes.filter((node: { id: any; }) => node.id !== nodeId.toString()));
setEdges((edges: any[]) => edges.filter((edge: { source: any; }) => edge.source !== nodeId.toString()));

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import type React from "react"
import {
ReactFlow,
ReactFlowProvider,
@@ -10,9 +11,10 @@ import {
type ColorMode,
// @ts-ignore
MiniMap,
type NodeMouseHandler,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import IndividualNode from "./nodes/individual"
import IndividualNode from "./nodes/person"
import PhoneNode from "./nodes/phone"
import IpNode from "./nodes/ip_address"
import EmailNode from "./nodes/email"
@@ -27,10 +29,13 @@ import {
PlusIcon,
GroupIcon,
WorkflowIcon,
Trash2Icon,
CopyIcon,
EditIcon,
LinkIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import NewActions from "./new-actions"
import FloatingEdge from "./floating-edge"
import FloatingConnectionLine from "./floating-connection"
import { useParams } from "next/navigation"
import { Tooltip, TooltipContent } from "@/components/ui/tooltip"
@@ -46,12 +51,12 @@ import AddNodeModal from "./add-node-modal"
import { Dialog, DialogTrigger } from "../ui/dialog"
import { memo } from "react"
import { shallow } from "zustand/shallow"
import CustomEdge from "./custom-edge"
import floatingEdge from "./floating-edge"
import FloatingEdge from "./simple-floating-edge"
import { TooltipProvider } from "@/components/ui/tooltip"
// Define constants outside component to prevent recreation
const edgeTypes = {
custom: floatingEdge
custom: FloatingEdge,
}
const nodeTypes = {
@@ -69,7 +74,14 @@ const nodeEdgeSelector = (store: { nodes: any; edges: any }) => ({
edges: store.edges,
})
const actionsSelector = (store: { onNodesChange: any; onEdgesChange: any; onConnect: any; onNodeClick: any; onPaneClick: any; onLayout: any }) => ({
const actionsSelector = (store: {
onNodesChange: any
onEdgesChange: any
onConnect: any
onNodeClick: any
onPaneClick: any
onLayout: any
}) => ({
onNodesChange: store.onNodesChange,
onEdgesChange: store.onEdgesChange,
onConnect: store.onConnect,
@@ -84,103 +96,188 @@ const stateSelector = (store: { currentNode: any; resetNodeStyles: any; reloadin
reloading: store.reloading,
})
// FlowControls component to reduce LayoutFlow complexity
interface FlowControlsProps {
onLayout: (direction: string, fitView: () => void) => void;
fitView: () => void;
handleRefetch: () => void;
reloading: boolean;
setView: (view: string) => void;
zoomIn: () => void;
zoomOut: () => void;
addNodes: (payload: Node | Node[]) => void;
// Node Context Menu component
interface NodeContextMenuProps {
x: number
y: number
nodeId: string | null
nodeType: string | null
onClose: () => void
}
const FlowControls = memo(({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => {
const NodeContextMenu = memo(({ x, y, nodeId, nodeType, onClose }: NodeContextMenuProps) => {
if (!nodeId) return null
const handleEditNode = () => {
console.log(`Edit node ${nodeId} of type ${nodeType}`)
onClose()
}
const handleDeleteNode = () => {
console.log(`Delete node ${nodeId}`)
onClose()
}
const handleDuplicateNode = () => {
console.log(`Duplicate node ${nodeId}`)
onClose()
}
const handleCreateConnection = () => {
console.log(`Create connection from node ${nodeId}`)
onClose()
}
return (
<>
<Panel position="top-left" className="flex items-center gap-1 w-53">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => {
onLayout("TB", fitView)
setTimeout(() => {
fitView()
}, 100)
}}
>
<AlignCenterVertical className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => setView("large-graph")}>
<WorkflowIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View 2D Graph</TooltipContent>
</Tooltip>
</Panel>
<Panel position="top-right" className="flex items-center gap-1">
<div className="flex flex-col items-end gap-2">
<div className="flex gap-1 items-center">
<Button size="icon" disabled={reloading} variant="outline" onClick={handleRefetch}>
<RotateCwIcon className={cn("h-4 w-4", reloading && "animate-spin")} />
</Button>
<NewActions addNodes={addNodes} />
</div>
</div>
</Panel>
<Panel position="bottom-left" className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => fitView()}>
<MaximizeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Center view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => zoomIn()}>
<ZoomInIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom in</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => zoomOut()}>
<ZoomOutIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom out</TooltipContent>
</Tooltip>
</Panel>
</>
<div
className="absolute z-50 min-w-[80px] bg-popover text-popover-foreground rounded-md border shadow-md py-1 overflow-hidden"
style={{ top: y, left: x }}
>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b">
{nodeType?.toUpperCase()} NODE: {nodeId.slice(0, 8)}
</div>
<div className="py-1">
<button
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={handleEditNode}
>
<EditIcon className="h-4 w-4" />
Edit Node
</button>
<button
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={handleDeleteNode}
>
<Trash2Icon className="h-4 w-4" />
Delete Node
</button>
<button
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={handleDuplicateNode}
>
<CopyIcon className="h-4 w-4" />
Duplicate Node
</button>
<button
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={handleCreateConnection}
>
<LinkIcon className="h-4 w-4" />
Create Connection
</button>
</div>
</div>
)
});
})
interface FlowControlsProps {
onLayout: (direction: string, fitView: () => void) => void
fitView: () => void
handleRefetch: () => void
reloading: boolean
setView: (view: string) => void
zoomIn: () => void
zoomOut: () => void
addNodes: (payload: Node | Node[]) => void
}
const FlowControls = memo(
({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => {
return (
<>
<Panel position="top-left" className="flex items-center gap-1 w-53">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => {
onLayout("TB", fitView)
setTimeout(() => {
fitView()
}, 100)
}}
>
<AlignCenterVertical className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => setView("large-graph")}>
<WorkflowIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View 2D Graph</TooltipContent>
</Tooltip>
</Panel>
<Panel position="top-right" className="flex items-center gap-1">
<div className="flex flex-col items-end gap-2">
<div className="flex gap-1 items-center">
<Button size="icon" disabled={reloading} variant="outline" onClick={handleRefetch}>
<RotateCwIcon className={cn("h-4 w-4", reloading && "animate-spin")} />
</Button>
<NewActions addNodes={addNodes} />
</div>
</div>
</Panel>
<Panel position="bottom-left" className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => fitView()}>
<MaximizeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Center view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => zoomIn()}>
<ZoomInIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom in</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline" onClick={() => zoomOut()}>
<ZoomOutIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom out</TooltipContent>
</Tooltip>
</Panel>
</>
)
},
)
interface LayoutFlowProps {
refetch: () => void;
theme: string;
refetch: () => void
theme: string
}
const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter } = useReactFlow()
const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter, getNodes } = useReactFlow()
const { investigation_id } = useParams()
const { settings } = useInvestigationStore()
const [_, setView] = useQueryState("view", { defaultValue: "flow-graph" })
// Node context menu state
const [nodeContextMenu, setNodeContextMenu] = useState<{
x: number
y: number
nodeId: string | null
nodeType: string | null
} | null>(null)
// Split store access to minimize re-renders
const { nodes, edges } = useFlowStore(nodeEdgeSelector, shallow)
const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } =
useFlowStore(actionsSelector, shallow)
const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } = useFlowStore(
actionsSelector,
shallow,
)
const { currentNode, resetNodeStyles, reloading } = useFlowStore(stateSelector, shallow)
// Initial layout
@@ -204,7 +301,7 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
// Node highlighting effect
useEffect(() => {
resetNodeStyles()
if (!currentNode) return;
if (!currentNode) return
const internalNode = getNode(currentNode)
if (!internalNode) return
@@ -228,71 +325,108 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
}, [currentNode, getNode, setCenter, resetNodeStyles])
// Memoize connection handler to prevent recreation
const handleConnect = useCallback(
(params: any) => onConnect(params, investigation_id),
[onConnect, investigation_id]
const handleConnect = useCallback((params: any) => onConnect(params, investigation_id), [onConnect, investigation_id])
// Handle node context menu
const handleNodeContextMenu: NodeMouseHandler = useCallback((event, node) => {
// Prevent default context menu
event.preventDefault()
setNodeContextMenu({
x: event.clientX - 300,
y: event.clientY - 40,
nodeId: node.id,
nodeType: node.type || "default",
})
}, [])
// Close node context menu
const closeNodeContextMenu = useCallback(() => {
setNodeContextMenu(null)
}, [])
// Handle pane click to close context menu
const handlePaneClick = useCallback(
(event: React.MouseEvent) => {
closeNodeContextMenu()
onPaneClick(event)
},
[onPaneClick, closeNodeContextMenu],
)
return (
<div className="h-[calc(100vh_-_48px)] relative">
<Dialog>
<ContextMenu>
<ContextMenuTrigger className="h-full w-full">
<ReactFlow
colorMode={theme as ColorMode}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
minZoom={0.1}
// @ts-ignore
connectionLineComponent={FloatingConnectionLine}
fitView
proOptions={{ hideAttribution: true }}
nodeTypes={nodeTypes}
// @ts-ignore
// edgeTypes={edgeTypes}
className="!bg-background"
>
<FlowControls
onLayout={onLayout}
fitView={fitView}
handleRefetch={handleRefetch}
reloading={reloading}
setView={setView}
zoomIn={zoomIn}
zoomOut={zoomOut}
<TooltipProvider>
<Dialog>
<ContextMenu>
<ContextMenuTrigger className="h-full w-full">
<ReactFlow
colorMode={theme as ColorMode}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onNodeClick={onNodeClick}
onPaneClick={handlePaneClick}
onNodeContextMenu={handleNodeContextMenu}
minZoom={0.1}
// @ts-ignore
addNodes={addNodes}
/>
<Background />
{settings.showMiniMap && <MiniMap pannable />}
</ReactFlow>
</ContextMenuTrigger>
<ContextMenuContent className="w-32">
<DialogTrigger asChild>
<ContextMenuItem>
<PlusIcon className="h-4 w-4 opacity-60" />
<span>New node</span>
connectionLineComponent={FloatingConnectionLine}
fitView
proOptions={{ hideAttribution: true }}
nodeTypes={nodeTypes}
// @ts-ignore
edgeTypes={edgeTypes}
className="!bg-background"
>
<FlowControls
onLayout={onLayout}
fitView={fitView}
handleRefetch={handleRefetch}
reloading={reloading}
setView={setView}
zoomIn={zoomIn}
zoomOut={zoomOut}
// @ts-ignore
addNodes={addNodes}
/>
<Background />
{settings.showMiniMap && <MiniMap pannable />}
</ReactFlow>
</ContextMenuTrigger>
<ContextMenuContent className="w-32">
<DialogTrigger asChild>
<ContextMenuItem>
<PlusIcon className="h-4 w-4 opacity-60" />
<span>New node</span>
</ContextMenuItem>
</DialogTrigger>
<ContextMenuItem disabled>
<GroupIcon className="h-4 w-4 opacity-60" />
<span>New group</span>
</ContextMenuItem>
</DialogTrigger>
<ContextMenuItem disabled>
<GroupIcon className="h-4 w-4 opacity-60" />
<span>New group</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<AddNodeModal addNodes={addNodes} />
</Dialog>
</ContextMenuContent>
</ContextMenu>
<AddNodeModal addNodes={addNodes} />
</Dialog>
{/* Node-specific context menu */}
{nodeContextMenu && (
<NodeContextMenu
x={nodeContextMenu.x}
y={nodeContextMenu.y}
nodeId={nodeContextMenu.nodeId}
nodeType={nodeContextMenu.nodeType}
onClose={closeNodeContextMenu}
/>
)}
</TooltipProvider>
</div>
)
};
}
// Memoize LayoutFlow to prevent unnecessary re-renders
const MemoizedLayoutFlow = memo(LayoutFlow);
const MemoizedLayoutFlow = memo(LayoutFlow)
function Graph({ graphQuery }: { graphQuery: any }) {
const [mounted, setMounted] = useState(false)
@@ -327,4 +461,5 @@ function Graph({ graphQuery }: { graphQuery: any }) {
}
// Export the memoized Graph component
export default memo(Graph)
export default memo(Graph)

View File

@@ -1,169 +1,41 @@
"use client"
import { memo, useCallback, useMemo } from "react"
import { Handle, Position, useStore } from "@xyflow/react"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { AtSignIcon } from "lucide-react"
import { cn, zoomSelector } from "@/lib/utils"
import { useInvestigationStore } from "@/store/investigation-store"
import { CopyButton } from "@/components/copy"
import { useSearchContext } from "@/components/contexts/search-context"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useFlowStore } from "@/store/flow-store"
import { toast } from "sonner"
import { checkEmail } from "@/lib/actions/search"
function EmailNode({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
const { handleOpenSearchModal } = useSearchContext()
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
const showContent = useStore(zoomSelector, (a, b) => a === b)
// Callbacks mémorisés
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleSearch = useCallback(() => handleOpenSearchModal(data.email), [data.email, handleOpenSearchModal])
const handleCheckEmail = useCallback(() => {
toast.promise(checkEmail(data.email, data.investigation_id), {
loading: "Loading...",
success: () => {
return `Scan on ${data.email} has been launched.`
},
error: (error: any) => {
return (
<div className="overflow-hidden">
<p className="font-bold">An error occured.</p>
<pre className="overflow-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</div>
)
},
})
}, [data.email, data.investigation_id])
// Mémorisation du contenu du nœud
const nodeContent = useMemo(() => {
if (settings.showNodeLabel && showContent) {
return (
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
currentNode === data.id && "border-primary",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<AtSignIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
</div>
</div>
</Card>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" className="rounded-full p-0">
<Avatar className="h-6 w-6">
<AvatarFallback>
<AtSignIcon className="h-3 w-3" />
</AvatarFallback>
</Avatar>
</Button>
</TooltipTrigger>
<TooltipContent>{data.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
// Mémorisation de la poignée
const handle = useMemo(
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
[],
)
// Mémorisation du menu contextuel
const contextMenu = useMemo(
() => (
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Search</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={handleCheckEmail}>Associated socials</ContextMenuItem>
<ContextMenuItem onClick={handleSearch}>HIBPwd</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem>
Copy content
<span className="ml-auto text-xs text-muted-foreground"> C</span>
</ContextMenuItem>
<ContextMenuItem>
Duplicate
<span className="ml-auto text-xs text-muted-foreground"> D</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleDelete} className="text-red-600">
Delete
<span className="ml-auto text-xs text-muted-foreground"> </span>
</ContextMenuItem>
</ContextMenuContent>
),
[handleCheckEmail, handleSearch, handleDelete],
)
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { AtSignIcon } from 'lucide-react';
export default memo(({ data }: any) => {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
{nodeContent}
{handle}
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<AtSignIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
</div>
</div>
</ContextMenuTrigger>
{contextMenu}
</ContextMenu>
)
}
// Composant enveloppé avec une fonction de comparaison personnalisée
const MemoizedNode = memo(
(props: any) => (
<NodeProvider>
<EmailNode {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
// Ne re-rendre que si les données importantes ont changé
return (
prevProps.id === nextProps.id &&
prevProps.data.label === nextProps.data.label &&
prevProps.data.email === nextProps.data.email &&
prevProps.selected === nextProps.selected
)
},
)
export default MemoizedNode
</Card>
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -1,141 +1,41 @@
"use client"
import { memo, useCallback, useMemo } from "react"
import { Handle, Position, useStore } from "@xyflow/react"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { LocateIcon, Zap } from "lucide-react"
import { cn, zoomSelector } from "@/lib/utils"
import { useInvestigationStore } from "@/store/investigation-store"
import { CopyButton } from "@/components/copy"
import { useSearchContext } from "@/components/contexts/search-context"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useFlowStore } from "@/store/flow-store"
function IpNode({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
const { handleOpenSearchModal } = useSearchContext()
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
const showContent = useStore(zoomSelector, (a, b) => a === b)
// Callbacks mémorisés
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
// Mémorisation du contenu du nœud
const nodeContent = useMemo(() => {
if (settings.showNodeLabel && showContent) {
return (
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
currentNode === data.id && "border-primary",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<LocateIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
</div>
</div>
</Card>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" className="rounded-full p-0">
<Avatar className="h-6 w-6">
<AvatarFallback>
<LocateIcon className="h-3 w-3" />
</AvatarFallback>
</Avatar>
</Button>
</TooltipTrigger>
<TooltipContent>{data.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
// Mémorisation de la poignée
const handle = useMemo(
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
[],
)
// Mémorisation du menu contextuel
const contextMenu = useMemo(
() => (
<ContextMenuContent>
<ContextMenuItem onClick={handleSearch}>
Launch search
<Zap className="ml-2 h-4 w-4 text-orange-500" />
</ContextMenuItem>
<ContextMenuItem>
Copy content
<span className="ml-auto text-xs text-muted-foreground"> C</span>
</ContextMenuItem>
<ContextMenuItem>
Duplicate
<span className="ml-auto text-xs text-muted-foreground"> D</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleDelete} className="text-red-600">
Delete
<span className="ml-auto text-xs text-muted-foreground"> </span>
</ContextMenuItem>
</ContextMenuContent>
),
[handleSearch, handleDelete],
)
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { AtSignIcon, LocateIcon } from 'lucide-react';
export default memo(({ data }: any) => {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
{nodeContent}
{handle}
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<LocateIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
</div>
</div>
</ContextMenuTrigger>
{contextMenu}
</ContextMenu>
)
}
// Composant enveloppé avec une fonction de comparaison personnalisée
const MemoizedNode = memo(
(props: any) => (
<NodeProvider>
<IpNode {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
// Ne re-rendre que si les données importantes ont changé
return (
prevProps.id === nextProps.id &&
prevProps.data.label === nextProps.data.label &&
prevProps.selected === nextProps.selected
)
},
)
export default MemoizedNode
</Card>
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -1,156 +0,0 @@
"use client"
import { memo, useMemo, useCallback } from "react"
// @ts-ignore
import { Handle, NodeToolbar, Position, useStore } from "@xyflow/react"
import { useInvestigationStore } from "@/store/investigation-store"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { useSearchContext } from "@/components/contexts/search-context"
import { cn, zoomSelector } from "@/lib/utils"
import { CopyButton } from "@/components/copy"
import { useChatContext } from "@/components/contexts/chatbot-context"
import { AtSign, Camera, Facebook, GithubIcon, Instagram, Locate, MapPin, MessageCircleDashed, Send, SquarePenIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Edit, Phone, User, Zap } from "lucide-react"
import { useFlowStore } from "@/store/flow-store"
import { useQueryState } from "nuqs"
// Composant optimisé pour les performances
function Custom(props: any) {
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
const { setOpenAddNodeModal, handleDuplicateNode, setOpenNote, handleDeleteNode, loading } = useNodeContext()
const { handleOpenSearchModal } = useSearchContext()
const { handleOpenChat } = useChatContext()
// Utilisation d'un sélecteur d'égalité pour éviter les re-rendus inutiles
const showContent = useStore(zoomSelector, (a, b) => a === b)
const [_, setIndividualId] = useQueryState("individual_id")
const { data } = props
// Callbacks mémorisés pour éviter les recréations de fonctions
const handleEditClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId])
const handleChatClick = useCallback(() => handleOpenChat(data), [data, handleOpenChat])
const handleSearchClick = useCallback(
() => handleOpenSearchModal(data.full_name),
[data.full_name, handleOpenSearchModal],
)
const handleDuplicateClick = useCallback(() => handleDuplicateNode(), [handleDuplicateNode])
const handleDeleteClick = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleNoteClick = useCallback(() => setOpenNote(true), [setOpenNote])
const handleDoubleClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId])
// Mémorisation du contenu du nœud pour éviter les recalculs
const nodeContent = useMemo(() => {
if (settings.showNodeLabel && showContent) {
return (
<Card
onDoubleClick={handleDoubleClick}
className={cn(
"p-1 border border-border hover:border-primary duration-100 rounded-lg shadow-none",
currentNode === data.id && "border-primary",
)}
>
<div className="flex gap-2 items-center rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage src={data?.image_url} alt={data.full_name} />
<AvatarFallback>{loading ? "..." : data.full_name[0]}</AvatarFallback>
</Avatar>
{settings.showNodeLabel && showContent && (
<div className="flex items-center gap-1">
<span className="text-sm">{data.full_name}</span>
{settings.showCopyIcon && <CopyButton className="rounded-full" content={data.full_name} />}
</div>
)}
</div>
</Card>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onDoubleClick={handleDoubleClick}
className={cn(
"rounded-full border border-transparent hover:border-primary",
currentNode === data.id && "border-primary",
)}
>
<Avatar className="h-12 w-12">
<AvatarImage src={data?.image_url} alt={data.full_name} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</button>
</TooltipTrigger>
<TooltipContent>{data.full_name}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}, [settings.showNodeLabel, showContent, data, loading, currentNode, handleDoubleClick, settings.showCopyIcon])
// Mémorisation des handles pour éviter les recalculs
const handles = useMemo(
() => (
<>
<Handle
type="target"
position={Position.Top}
// className={cn("w-16 bg-teal-500 opacity-0", showContent ? "group-hover:opacity-100 opacity-0" : "opacity-0")}
/>
<Handle
type="source"
position={Position.Bottom}
// className={cn("w-16 bg-teal-500 opacity-0", showContent ? "group-hover:opacity-100 opacity-0" : "opacity-0")}
/>
</>
),
[showContent],
)
return (
<div style={{ ...props.style, borderRadius: "100000px!important" }}>
<div className={cn(loading ? "opacity-40" : "opacity-100", "overflow-hidden group")}>
{nodeContent}
{handles}
</div>
</div>
)
}
const IndividualNode = memo(
(props: any) => (
<NodeProvider>
<Custom {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
// Comparaison personnalisée pour éviter les re-rendus inutiles
// Ne re-rendre que si les données importantes ont changé
return (
prevProps.id === nextProps.id &&
prevProps.data.full_name === nextProps.data.full_name &&
prevProps.data.image_url === nextProps.data.image_url &&
prevProps.selected === nextProps.selected
)
},
)
export default IndividualNode

View File

@@ -0,0 +1,40 @@
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export default memo(({ data }: any) => {
return (
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
<Card
className={cn(
"p-1 border border-border hover:border-primary duration-100 rounded-lg shadow-none backdrop-blur bg-background/40",
)}
>
<div className="flex gap-2 items-center rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage src={data?.image_url} alt={data.full_name} />
<AvatarFallback>{data.full_name[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1">
<span className="text-sm">{data.full_name}</span>
<CopyButton className="rounded-full" content={data.full_name} />
</div>
</div>
</Card>
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -1,130 +1,41 @@
"use client"
import { memo, useCallback, useMemo } from "react"
import { Handle, Position, useStore } from "@xyflow/react"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { LocateIcon, Zap } from "lucide-react"
import { cn, zoomSelector } from "@/lib/utils"
import { useInvestigationStore } from "@/store/investigation-store"
import { CopyButton } from "@/components/copy"
import { useSearchContext } from "@/components/contexts/search-context"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useFlowStore } from "@/store/flow-store"
function PhoneNode({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
const { handleOpenSearchModal } = useSearchContext()
const showContent = useStore(zoomSelector, (a, b) => a === b)
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
const nodeContent = useMemo(() => {
if (settings.showNodeLabel && showContent) {
return (
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
currentNode === data.id && "border-primary",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<LocateIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
</div>
</div>
</Card>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" className="rounded-full p-0">
<Avatar className="h-6 w-6">
<AvatarFallback>
<LocateIcon className="h-3 w-3" />
</AvatarFallback>
</Avatar>
</Button>
</TooltipTrigger>
<TooltipContent>{data.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
const handle = useMemo(
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
[],
)
const contextMenu = useMemo(
() => (
<ContextMenuContent>
<ContextMenuItem onClick={handleSearch}>
Launch search
<Zap className="ml-2 h-4 w-4 text-orange-500" />
</ContextMenuItem>
<ContextMenuItem>
Copy content
<span className="ml-auto text-xs text-muted-foreground"> C</span>
</ContextMenuItem>
<ContextMenuItem>
Duplicate
<span className="ml-auto text-xs text-muted-foreground"> D</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleDelete} className="text-red-600">
Delete
<span className="ml-auto text-xs text-muted-foreground"> </span>
</ContextMenuItem>
</ContextMenuContent>
),
[handleSearch, handleDelete],
)
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { AtSignIcon, PhoneIcon } from 'lucide-react';
export default memo(({ data }: any) => {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
{nodeContent}
{handle}
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
<PhoneIcon className="h-4 w-4" />
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
</div>
</div>
</ContextMenuTrigger>
{contextMenu}
</ContextMenu>
)
}
const MemoizedNode = memo(
(props: any) => (
<NodeProvider>
<PhoneNode {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.data.label === nextProps.data.label &&
prevProps.selected === nextProps.selected
)
},
)
export default MemoizedNode
</Card>
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -1,42 +1,24 @@
"use client"
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { AtSignIcon, LocateIcon } from 'lucide-react';
import { memo, useCallback, useMemo } from "react"
import { Handle, Position, useStore } from "@xyflow/react"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { LocateIcon, Zap } from "lucide-react"
import { cn, zoomSelector } from "@/lib/utils"
import { useInvestigationStore } from "@/store/investigation-store"
import { CopyButton } from "@/components/copy"
import { useSearchContext } from "@/components/contexts/search-context"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useFlowStore } from "@/store/flow-store"
export default memo(({ data }: any) => {
return (
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
function AddressNode({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
const { handleOpenSearchModal } = useSearchContext()
const showContent = useStore(zoomSelector, (a, b) => a === b)
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
const nodeContent = useMemo(() => {
return (
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
currentNode === data.id && "border-primary",
)}
>
<div className="flex items-center gap-2 p-1">
@@ -45,68 +27,15 @@ function AddressNode({ data }: any) {
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.label}</span>
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
</div>
</div>
</Card>
)
}, [currentNode, data.id, data.label, settings.showCopyIcon])
const handle = useMemo(
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
[],
)
const contextMenu = useMemo(
() => (
<ContextMenuContent>
<ContextMenuItem onClick={handleSearch}>
Launch search
<Zap className="ml-2 h-4 w-4 text-orange-500" />
</ContextMenuItem>
<ContextMenuItem>
Copy content
<span className="ml-auto text-xs text-muted-foreground"> C</span>
</ContextMenuItem>
<ContextMenuItem>
Duplicate
<span className="ml-auto text-xs text-muted-foreground"> D</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleDelete} className="text-red-600">
Delete
<span className="ml-auto text-xs text-muted-foreground"> </span>
</ContextMenuItem>
</ContextMenuContent>
),
[handleSearch, handleDelete],
)
return (
<ContextMenu>
<ContextMenuTrigger>
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
{nodeContent}
{handle}
</div>
</ContextMenuTrigger>
{contextMenu}
</ContextMenu>
)
}
const MemoizedNode = memo(
(props: any) => (
<NodeProvider>
<AddressNode {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.data.label === nextProps.data.label &&
prevProps.selected === nextProps.selected
)
},
)
export default MemoizedNode
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -1,163 +1,45 @@
"use client"
import React, { memo, useMemo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { CopyButton } from '@/components/copy';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { AtSignIcon } from 'lucide-react';
import { usePlatformIcons } from '@/lib/hooks/use-platform-icons';
import { memo, useCallback, useMemo } from "react"
import { Handle, Position, useStore } from "@xyflow/react"
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
import { MapPinIcon, Zap } from "lucide-react"
import { cn, zoomSelector } from "@/lib/utils"
import { useInvestigationStore } from "@/store/investigation-store"
import { CopyButton } from "@/components/copy"
import { useSearchContext } from "@/components/contexts/search-context"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { usePlatformIcons } from "@/lib/hooks/use-platform-icons"
import { useFlowStore } from "@/store/flow-store"
function SocialNode({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationStore()
const { currentNode } = useFlowStore()
export default memo(({ data }: any) => {
const platformsIcons = usePlatformIcons()
const { handleOpenSearchModal } = useSearchContext()
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
const showContent = useStore(zoomSelector, (a, b) => a === b)
// Callbacks mémorisés
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
// Mémorisation de l'icône de la plateforme
const platformIcon = useMemo(() => {
// @ts-ignore
return platformsIcons?.[data?.platform]?.icon
}, [platformsIcons, data?.platform])
// Mémorisation du contenu du nœud
const nodeContent = useMemo(() => {
if (settings.showNodeLabel && showContent) {
return (
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
currentNode === data.id && "border-primary",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
{platformIcon}
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.username || data.profile_url}</span>
{settings.showCopyIcon && (
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.username || data.profile_url} />
)}
</div>
</div>
</Card>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" className="rounded-full p-0">
<Avatar className="h-6 w-6">
<AvatarFallback>
<MapPinIcon className="h-3 w-3" />
</AvatarFallback>
</Avatar>
</Button>
</TooltipTrigger>
<TooltipContent>{data.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}, [
settings.showNodeLabel,
showContent,
currentNode,
data.id,
data.username,
data.profile_url,
data.label,
settings.showCopyIcon,
platformIcon,
])
// Mémorisation de la poignée
const handle = useMemo(
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
[],
)
// Mémorisation du menu contextuel
const contextMenu = useMemo(
() => (
<ContextMenuContent>
<ContextMenuItem onClick={handleSearch}>
Launch search
<Zap className="ml-2 h-4 w-4 text-orange-500" />
</ContextMenuItem>
<ContextMenuItem>
Copy content
<span className="ml-auto text-xs text-muted-foreground"> C</span>
</ContextMenuItem>
<ContextMenuItem>
Duplicate
<span className="ml-auto text-xs text-muted-foreground"> D</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleDelete} className="text-red-600">
Delete
<span className="ml-auto text-xs text-muted-foreground"> </span>
</ContextMenuItem>
</ContextMenuContent>
),
[handleSearch, handleDelete],
)
return (
<ContextMenu>
<ContextMenuTrigger>
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
{nodeContent}
{handle}
<>
<Handle
type="target"
position={Position.Top}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
<Card
className={cn(
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
)}
>
<div className="flex items-center gap-2 p-1">
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
{platformIcon}
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm">{data.username || data.profile_url}</span>
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.username || data.profile_url} />
</div>
</div>
</ContextMenuTrigger>
{contextMenu}
</ContextMenu>
)
}
// Composant enveloppé avec une fonction de comparaison personnalisée
const MemoizedNode = memo(
(props: any) => (
<NodeProvider>
<SocialNode {...props} />
</NodeProvider>
),
(prevProps, nextProps) => {
// Ne re-rendre que si les données importantes ont changé
return (
prevProps.id === nextProps.id &&
prevProps.data.username === nextProps.data.username &&
prevProps.data.profile_url === nextProps.data.profile_url &&
prevProps.data.platform === nextProps.data.platform &&
prevProps.selected === nextProps.selected
)
},
)
export default MemoizedNode
</Card>
<Handle
type="source"
position={Position.Bottom}
className={cn("w-16 bg-teal-500 opacity-0")}
/>
</>
);
});

View File

@@ -0,0 +1,44 @@
import { getBezierPath, getStraightPath, useInternalNode } from '@xyflow/react';
import { getEdgeParams } from '@/lib/utils';
interface FloatingEdgeProps {
id: string;
source: string;
target: string;
markerEnd?: string;
style?: React.CSSProperties;
}
function FloatingEdge({ id, source, target, markerEnd, style }: FloatingEdgeProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) {
return null;
}
const { sx, sy, tx, ty } = getEdgeParams(
sourceNode,
targetNode,
);
const [edgePath] = getStraightPath({
sourceX: sx,
sourceY: sy,
targetX: tx,
targetY: ty,
});
return (
<path
id={id}
className="react-flow__edge-path"
d={edgePath}
markerEnd={markerEnd}
style={style}
/>
);
}
export default FloatingEdge;

View File

@@ -1,5 +1,7 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
//@ts-ignore
import * as d3 from "d3-force"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -8,6 +10,7 @@ export function cn(...inputs: ClassValue[]) {
export const zoomSelector = (s: { transform: number[]; }) => s.transform[2] >= 0.6;
import { Position, MarkerType } from '@xyflow/react';
import { AppNode } from "@/store/flow-store";
// this helper function returns the intersection point
// of the line between the center of the intersectionNode and the target node
@@ -106,4 +109,100 @@ export function initialElements() {
}
return { nodes, edges };
}
}
interface Node {
id: string
position?: { x: number; y: number }
measured?: { width: number; height: number }
[key: string]: any
}
interface Edge {
source: string
target: string
[key: string]: any
}
interface LayoutOptions {
direction?: string
strength?: number
distance?: number
iterations?: number
}
export const getLayoutedElements = (
nodes: AppNode[],
edges: Edge[],
options: LayoutOptions = {
direction: "LR",
strength: -300,
distance: 100,
iterations: 300,
},
) => {
// Create a map of node IDs to indices for the simulation
const nodeMap = new Map(nodes.map((node, i) => [node.id, i]))
// Create a copy of nodes with positions for the simulation
const nodesCopy = nodes.map((node) => ({
...node,
x: node.position?.x || Math.random() * 500,
y: node.position?.y || Math.random() * 500,
width: node.measured?.width || 0,
height: node.measured?.height || 0,
}))
// Create links for the simulation using indices
const links = edges.map((edge) => ({
source: nodeMap.get(edge.source),
target: nodeMap.get(edge.target),
original: edge,
}))
// Create the simulation
const simulation = d3
.forceSimulation(nodesCopy)
.force(
"link",
d3.forceLink(links).id((d: any) => nodeMap.get(d.id)),
)
.force("charge", d3.forceManyBody().strength(options.strength || -300))
.force("center", d3.forceCenter(250, 250))
.force(
"collision",
d3.forceCollide().radius((d: any) => Math.max(d.width, d.height) / 2 + 10),
)
// If direction is horizontal, adjust forces
if (options.direction === "LR") {
simulation.force("x", d3.forceX(250).strength(0.1))
simulation.force("y", d3.forceY(250).strength(0.05))
} else {
simulation.force("x", d3.forceX(250).strength(0.05))
simulation.force("y", d3.forceY(250).strength(0.1))
}
// Run the simulation synchronously
simulation.stop()
for (let i = 0; i < (options.iterations || 300); i++) {
simulation.tick()
}
// Update node positions based on simulation results
const updatedNodes = nodesCopy.map((node) => ({
...node,
position: {
x: node.x - node.width / 2,
y: node.y - node.height / 2,
},
}))
return {
nodes: updatedNodes,
edges,
}
}
export default getLayoutedElements

View File

@@ -8,6 +8,7 @@ import {
type OnNodesChange,
type OnEdgesChange,
} from '@xyflow/react';
import getLayoutedElements from '@/lib/utils';
export type AppNode = Node;
@@ -29,29 +30,6 @@ export type AppState = {
updateNode: (nodeId: string, nodeData: Partial<Node>) => void;
resetNodeStyles: () => void;
};
const getLayoutedElements = (nodes: any[], edges: any[], options: { direction: any; }) => {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: options.direction });
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
nodes.forEach((node) =>
g.setNode(node.id, {
...node,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
Dagre.layout(g);
return {
nodes: nodes.map((node) => {
const position = g.node(node.id);
const x = position.x - (node.measured?.width ?? 0) / 2;
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
}),
edges,
};
};
const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) => {
return create<AppState>((set, get) => ({
nodes: initialNodes,
@@ -149,6 +127,7 @@ const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) =>
onLayout: (direction = 'TB', fitView: () => void) => {
const { nodes, edges } = getLayoutedElements(get().nodes, get().edges, { direction });
set({ nodes });
//@ts-ignore
set({ edges });
fitView();
},

View File

@@ -15,7 +15,7 @@
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(24.6 95% 53.1%);
--primary: hsl(221.2 83.2% 53.3%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 96.1%);
--secondary-foreground: hsl(0 0% 9%);
@@ -28,7 +28,7 @@
--border: hsla(0, 0%, 90%, 0.807);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(24.6 95% 53.1%);
--chart-1: hsl(221.2 83.2% 53.3%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
@@ -43,7 +43,7 @@
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(24.6 95% 53.1%);
--sidebar-ring: hsl(221.2 83.2% 53.3%);
}
.dark {
@@ -53,7 +53,7 @@
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 0% 9.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(24.6 95% 53.1%);
--primary: hsl(221.2 83.2% 53.3%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
@@ -66,14 +66,14 @@
--border: hsla(0, 0%, 23%, 0.67);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(24.6 95% 53.1%);
--chart-1: hsl(221.2 83.2% 53.3%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(24.6 95% 53.1%);
--chart-4: hsl(221.2 83.2% 53.3%);
--chart-5: hsl(340 75% 55%);
--sidebar: hsl(240 0% 9.9%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(24.6 95% 53.1%);
--sidebar-primary: hsl(221.2 83.2% 53.3%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);