diff --git a/visual_workflow_builder/frontend_v4/index.html b/visual_workflow_builder/frontend_v4/index.html new file mode 100644 index 000000000..dacc7d457 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/index.html @@ -0,0 +1,12 @@ + + + + + + VWB - Visual Workflow Builder + + +
+ + + diff --git a/visual_workflow_builder/frontend_v4/package.json b/visual_workflow_builder/frontend_v4/package.json new file mode 100644 index 000000000..dcbcf4c4a --- /dev/null +++ b/visual_workflow_builder/frontend_v4/package.json @@ -0,0 +1,23 @@ +{ + "name": "vwb-v4", + "version": "4.0.0", + "description": "Visual Workflow Builder - React + API v3", + "type": "module", + "scripts": { + "dev": "vite --port 3002", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@xyflow/react": "^12.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/visual_workflow_builder/frontend_v4/src/App.tsx b/visual_workflow_builder/frontend_v4/src/App.tsx new file mode 100644 index 000000000..7945b6987 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/App.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + ReactFlow, + Controls, + Background, + useNodesState, + useEdgesState, + ReactFlowProvider, +} from '@xyflow/react'; +import type { Node, Edge, NodeTypes } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import * as api from './services/api'; +import type { AppState, Step, ActionType, Capture } from './types'; +import { ACTIONS } from './types'; +import StepNode from './components/StepNode'; +import ToolPalette from './components/ToolPalette'; +import PropertiesPanel from './components/PropertiesPanel'; +import CapturePanel from './components/CapturePanel'; +import WorkflowList from './components/WorkflowList'; +import ExecutionControls from './components/ExecutionControls'; + +const nodeTypes: NodeTypes = { + step: StepNode, +}; + +function App() { + const [appState, setAppState] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [capture, setCapture] = useState(null); + const [error, setError] = useState(null); + + // Charger l'état initial + const loadState = useCallback(async () => { + try { + const state = await api.getState(); + setAppState(state); + updateNodesFromWorkflow(state.workflow?.steps || []); + } catch (err) { + setError((err as Error).message); + } + }, []); + + useEffect(() => { + loadState(); + }, [loadState]); + + // Convertir les étapes en nœuds React Flow + const updateNodesFromWorkflow = (steps: Step[]) => { + const newNodes: Node[] = steps.map((step, index) => ({ + id: step.id, + type: 'step', + position: step.position || { x: 100, y: 100 + index * 120 }, + data: { step }, + })); + + const newEdges: Edge[] = []; + for (let i = 0; i < steps.length - 1; i++) { + newEdges.push({ + id: `e-${steps[i].id}-${steps[i + 1].id}`, + source: steps[i].id, + sourceHandle: 'bottom', + target: steps[i + 1].id, + targetHandle: 'top', + type: 'smoothstep', + animated: false, + style: { strokeWidth: 2 }, + }); + } + + setNodes(newNodes); + setEdges(newEdges); + }; + + // Actions + const handleCreateWorkflow = async () => { + const name = prompt('Nom du workflow:'); + if (!name) return; + try { + await api.createWorkflow(name); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleSelectWorkflow = async (id: string) => { + try { + await api.selectWorkflow(id); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleDeleteWorkflow = async (id: string) => { + if (!confirm('Supprimer ce workflow ?')) return; + try { + await api.deleteWorkflow(id); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleAddStep = async (actionType: ActionType, position?: { x: number; y: number }) => { + if (!appState?.session.active_workflow_id) { + setError('Sélectionnez un workflow d\'abord'); + return; + } + try { + await api.addStep(appState.session.active_workflow_id, actionType, { position }); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleSelectStep = async (id: string) => { + try { + await api.selectStep(id); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleDeleteStep = async (id: string) => { + if (!appState?.session.active_workflow_id) return; + try { + await api.deleteStep(appState.session.active_workflow_id, id); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleUpdateStepParams = async (id: string, params: Record) => { + if (!appState?.session.active_workflow_id) return; + try { + await api.updateStep(appState.session.active_workflow_id, id, { parameters: params }); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleNodeDragStop = async (_: unknown, node: Node) => { + if (!appState?.session.active_workflow_id) return; + try { + await api.updateStep(appState.session.active_workflow_id, node.id, { + position: { x: Math.round(node.position.x), y: Math.round(node.position.y) } + }); + } catch (err) { + console.error('Erreur mise à jour position:', err); + } + }; + + const handleCapture = async () => { + try { + const result = await api.captureScreen(); + setCapture(result.capture); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleSelectAnchor = async (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => { + if (!appState?.session.selected_step_id) { + setError('Sélectionnez une étape d\'abord'); + return; + } + try { + await api.selectAnchor(appState.session.selected_step_id, bbox, undefined, screenshotBase64); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleStartExecution = async () => { + try { + await api.startExecution(); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleStopExecution = async () => { + try { + await api.stopExecution(); + await loadState(); + } catch (err) { + setError((err as Error).message); + } + }; + + // Drop d'un outil sur le canvas + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const actionType = event.dataTransfer.getData('actionType') as ActionType; + if (!actionType) return; + + const reactFlowBounds = event.currentTarget.getBoundingClientRect(); + const position = { + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }; + + handleAddStep(actionType, position); + }, + [appState] + ); + + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const selectedStep = appState?.workflow?.steps.find( + s => s.id === appState?.session.selected_step_id + ); + + return ( +
+ {/* Header */} +
+

VWB - Visual Workflow Builder

+ +
+ + {/* Erreur */} + {error && ( +
+ {error} + +
+ )} + +
+ {/* Sidebar gauche: Workflows + Outils */} + + + {/* Canvas central */} +
+ {appState?.workflow ? ( + handleSelectStep(node.id)} + onNodeDragStop={handleNodeDragStop} + nodeTypes={nodeTypes} + fitView + > + + + + ) : ( +
+

Sélectionnez ou créez un workflow

+
+ )} +
+ + {/* Sidebar droite: Propriétés + Capture */} + +
+
+ ); +} + +export default function AppWrapper() { + return ( + + + + ); +} diff --git a/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx b/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx new file mode 100644 index 000000000..3f4d6dd76 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/CapturePanel.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect } from 'react'; +import type { Capture } from '../types'; + +interface Props { + capture: Capture | null; + onCapture: () => void; + onSelectAnchor: (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => void; + hasSelectedStep: boolean; +} + +interface LibraryItem { + id: string; + capture: Capture; + timestamp: Date; +} + +export default function CapturePanel({ capture, onCapture, onSelectAnchor, hasSelectedStep }: Props) { + const [isFullscreen, setIsFullscreen] = useState(false); + const [library, setLibrary] = useState([]); + const [currentCapture, setCurrentCapture] = useState(null); + const [timerSeconds, setTimerSeconds] = useState(0); + const [countdown, setCountdown] = useState(null); + + // Charger la bibliothèque depuis sessionStorage + useEffect(() => { + const stored = sessionStorage.getItem('captureLibrary'); + if (stored) { + setLibrary(JSON.parse(stored)); + } + }, []); + + // Sauvegarder la bibliothèque + useEffect(() => { + sessionStorage.setItem('captureLibrary', JSON.stringify(library)); + }, [library]); + + // Ajouter capture à la bibliothèque + useEffect(() => { + if (capture) { + setCurrentCapture(capture); + const newItem: LibraryItem = { + id: `cap_${Date.now()}`, + capture, + timestamp: new Date() + }; + setLibrary(prev => [newItem, ...prev.slice(0, 19)]); + } + }, [capture]); + + const handleTimerCapture = () => { + if (timerSeconds === 0) { + onCapture(); + return; + } + + let remaining = timerSeconds; + setCountdown(remaining); + + const interval = setInterval(() => { + remaining--; + if (remaining > 0) { + setCountdown(remaining); + } else { + clearInterval(interval); + setCountdown(null); + onCapture(); + } + }, 1000); + }; + + const handleLibrarySelect = (item: LibraryItem) => { + setCurrentCapture(item.capture); + }; + + const handleDeleteLibraryItem = (id: string) => { + setLibrary(prev => prev.filter(item => item.id !== id)); + }; + + return ( +
+

Capture

+ + {/* Contrôles de capture */} +
+ + + +
+ + {/* Aperçu de la capture */} + {currentCapture && ( +
+ Capture setIsFullscreen(true)} + /> +

+ {currentCapture.width}x{currentCapture.height} + +

+
+ )} + + {!hasSelectedStep && currentCapture && ( +

Sélectionnez une étape pour définir l'ancre

+ )} + + {/* Bibliothèque */} +
+

Bibliothèque ({library.length})

+
+ {library.map(item => ( +
+ Capture handleLibrarySelect(item)} + /> + +
+ ))} +
+
+ + {/* Modal plein écran */} + {isFullscreen && currentCapture && ( + setIsFullscreen(false)} + onSelect={(bbox) => { + onSelectAnchor(bbox, currentCapture.screenshot_base64); + setIsFullscreen(false); + }} + enabled={hasSelectedStep} + /> + )} +
+ ); +} + +// Composant sélecteur plein écran +function FullscreenSelector({ + capture, + onClose, + onSelect, + enabled +}: { + capture: Capture; + onClose: () => void; + onSelect: (bbox: { x: number; y: number; width: number; height: number }) => void; + enabled: boolean; +}) { + const imgRef = useRef(null); + const overlayRef = useRef(null); + const [isSelecting, setIsSelecting] = useState(false); + const [startPos, setStartPos] = useState({ x: 0, y: 0 }); + const [selection, setSelection] = useState({ x: 0, y: 0, width: 0, height: 0 }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleMouseDown = (e: React.MouseEvent) => { + if (!enabled || !imgRef.current) return; + + const rect = imgRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + setIsSelecting(true); + setStartPos({ x, y }); + setSelection({ x, y, width: 0, height: 0 }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isSelecting || !imgRef.current) return; + + const rect = imgRef.current.getBoundingClientRect(); + const currentX = e.clientX - rect.left; + const currentY = e.clientY - rect.top; + + const width = currentX - startPos.x; + const height = currentY - startPos.y; + + setSelection({ + x: width < 0 ? currentX : startPos.x, + y: height < 0 ? currentY : startPos.y, + width: Math.abs(width), + height: Math.abs(height) + }); + }; + + const handleMouseUp = () => { + if (!isSelecting || !imgRef.current) return; + setIsSelecting(false); + + if (selection.width < 10 || selection.height < 10) return; + + // Convertir en coordonnées réelles de l'image + const scaleX = imgRef.current.naturalWidth / imgRef.current.width; + const scaleY = imgRef.current.naturalHeight / imgRef.current.height; + + const realBbox = { + x: Math.round(selection.x * scaleX), + y: Math.round(selection.y * scaleY), + width: Math.round(selection.width * scaleX), + height: Math.round(selection.height * scaleY) + }; + + onSelect(realBbox); + }; + + return ( +
+
+ {enabled ? 'Dessinez un rectangle pour sélectionner l\'ancre' : 'Sélectionnez d\'abord une étape'} + +
+
+ Capture plein écran + {(isSelecting || selection.width > 0) && ( +
+ )} +
+
+ ); +} diff --git a/visual_workflow_builder/frontend_v4/src/components/ExecutionControls.tsx b/visual_workflow_builder/frontend_v4/src/components/ExecutionControls.tsx new file mode 100644 index 000000000..9878dc9b9 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/ExecutionControls.tsx @@ -0,0 +1,45 @@ +import type { Execution } from '../types'; + +interface Props { + execution: Execution | null; + onStart: () => void; + onStop: () => void; +} + +export default function ExecutionControls({ execution, onStart, onStop }: Props) { + const isRunning = execution?.status === 'running' || execution?.status === 'paused'; + + return ( +
+ {!isRunning ? ( + + ) : ( + <> +
+ + {execution?.status === 'running' ? 'En cours' : 'En pause'} + + + {execution?.completed_steps}/{execution?.total_steps} + +
+ + + )} + + {execution?.status === 'completed' && ( + Terminé + )} + + {execution?.status === 'error' && ( + + Erreur + + )} +
+ ); +} diff --git a/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx b/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx new file mode 100644 index 000000000..fa87d1d84 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx @@ -0,0 +1,642 @@ +import { useState, useEffect } from 'react'; +import type { Step, ActionType } from '../types'; +import { ACTIONS } from '../types'; +import { getAnchorThumbnailUrl } from '../services/api'; + +interface Props { + step: Step | null; + onUpdateParams: (id: string, params: Record) => void; + onDelete: (id: string) => void; +} + +export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Props) { + const [params, setParams] = useState>({}); + + useEffect(() => { + if (step) { + setParams(step.parameters || {}); + } + }, [step?.id, step?.parameters]); + + if (!step) { + return ( +
+

Propriétés

+

Sélectionnez une étape

+
+ ); + } + + const action = ACTIONS.find(a => a.type === step.action_type); + + const updateParam = (key: string, value: unknown) => { + setParams(prev => ({ ...prev, [key]: value })); + }; + + const handleSave = () => { + onUpdateParams(step.id, params); + }; + + const renderParamsForAction = (actionType: ActionType) => { + switch (actionType) { + // === SOURIS === + case 'click_anchor': + case 'double_click_anchor': + case 'right_click_anchor': + return ( + <> +
+ + updateParam('delay_before', Number(e.target.value))} + min="0" + step="100" + /> +
+
+ + updateParam('delay_after', Number(e.target.value))} + min="0" + step="100" + /> +
+ + ); + + case 'hover_anchor': + return ( +
+ + updateParam('hover_duration', Number(e.target.value))} + min="0" + step="100" + /> +
+ ); + + case 'drag_drop_anchor': + return ( + <> +
+ + updateParam('target_anchor_id', e.target.value)} + placeholder="ID de l'ancre cible" + /> +
+
+ + +
+ + ); + + case 'scroll_to_anchor': + return ( +
+ + +
+ ); + + case 'focus_anchor': + return ( +
+ + +
+ ); + + // === CLAVIER === + case 'type_text': + return ( + <> +
+ +