Source: components/Canvas/Canvas.jsx

/**
 * The main workspace of the AutoRA Workflow Editor. Renders the pannable/zoomable
 * canvas that hosts workflow nodes and the SVG connections between them, and handles
 * drag-and-drop node creation, panning, zooming, selection, and port-to-port connecting.
 *
 * @module components/Canvas/Canvas
 */
import React, { useRef, useCallback, useState, useEffect } from 'react'
import { useWorkflow } from '../../context/WorkflowContext'
import Node from '../Node/Node'
import Connection from './Connection'
import './Canvas.css'

/**
 * Renders the interactive workflow canvas, including all nodes, connections, and the
 * live zoom/node/connection info overlay. Wires up drop, pan, zoom, click-to-deselect,
 * and connection-creation interactions against the shared workflow state.
 *
 * @returns {JSX.Element}
 */
function Canvas() {
  const canvasRef = useRef(null)
  const { state, dispatch } = useWorkflow()
  const [isPanning, setIsPanning] = useState(false)
  const [panStart, setPanStart] = useState({ x: 0, y: 0 })
  const [hasDragged, setHasDragged] = useState(false)
  const { connectingFrom } = state

  /**
   * Converts screen (viewport) coordinates into canvas-space coordinates, accounting
   * for the current zoom level and pan offset.
   *
   * @param {number} screenX - The x coordinate in screen pixels (e.g. e.clientX).
   * @param {number} screenY - The y coordinate in screen pixels (e.g. e.clientY).
   * @returns {{x: number, y: number}} The equivalent point in canvas space.
   */
  const screenToCanvas = useCallback((screenX, screenY) => {
    if (!canvasRef.current) return { x: 0, y: 0 }
    const rect = canvasRef.current.getBoundingClientRect()
    return {
      x: (screenX - rect.left) / state.zoom - state.pan.x,
      y: (screenY - rect.top) / state.zoom - state.pan.y
    }
  }, [state.zoom, state.pan])

  /**
   * Allows dropping palette components onto the canvas by preventing the default
   * behavior and signalling a "copy" drop effect.
   *
   * @param {DragEvent} e - The dragover event.
   * @returns {void}
   */
  const handleDragOver = (e) => {
    e.preventDefault()
    e.dataTransfer.dropEffect = 'copy'
  }

  /**
   * Handles dropping a component from the palette onto the canvas: reads the dragged
   * component JSON, converts the drop point to canvas coordinates, and dispatches an
   * ADD_NODE action (offset so the node is centered on the cursor).
   *
   * @param {DragEvent} e - The drop event carrying the component JSON payload.
   * @returns {void}
   */
  const handleDrop = useCallback((e) => {
    e.preventDefault()
    const data = e.dataTransfer.getData('application/json')
    if (!data) return

    const componentData = JSON.parse(data)
    const { x, y } = screenToCanvas(e.clientX, e.clientY)

    dispatch({
      type: 'ADD_NODE',
      payload: { componentData, x: x - 80, y: y - 30 }
    })
  }, [screenToCanvas, dispatch])

  /**
   * Deselects all nodes/connections when the user clicks empty canvas space, but only
   * if the interaction was a genuine click rather than the end of a pan drag.
   *
   * @param {MouseEvent} e - The click event.
   * @returns {void}
   */
  const handleCanvasClick = (e) => {
    // Only deselect if user didn't drag (was a real click)
    if (!hasDragged && (e.target === canvasRef.current || e.target.classList.contains('canvas-inner'))) {
      dispatch({ type: 'DESELECT_ALL' })
    }
  }

  /**
   * Zooms the canvas in/out on mouse wheel, clamped to the range 0.25x-2x, and adjusts
   * the pan so the canvas point under the cursor stays fixed during the zoom.
   *
   * @param {WheelEvent} e - The wheel event.
   * @returns {void}
   */
  const handleWheel = useCallback((e) => {
    e.preventDefault()
    const delta = e.deltaY > 0 ? -0.1 : 0.1
    const newZoom = Math.max(0.25, Math.min(2, state.zoom + delta))
    if (newZoom === state.zoom) return

    // Get canvas rect for calculations
    const rect = canvasRef.current.getBoundingClientRect()

    // Calculate the point under the mouse in canvas coordinates (before zoom)
    const mouseX = (e.clientX - rect.left) / state.zoom - state.pan.x
    const mouseY = (e.clientY - rect.top) / state.zoom - state.pan.y

    // After zoom, adjust pan so the same canvas point stays under the mouse
    const newPanX = (e.clientX - rect.left) / newZoom - mouseX
    const newPanY = (e.clientY - rect.top) / newZoom - mouseY

    dispatch({ type: 'SET_ZOOM', payload: newZoom })
    dispatch({ type: 'SET_PAN', payload: { x: newPanX, y: newPanY } })
  }, [state.zoom, state.pan, dispatch])

  /**
   * Begins panning the canvas when the user presses the middle mouse button, or the
   * left button on empty canvas space or with Alt held. Records the starting offset.
   *
   * @param {MouseEvent} e - The mousedown event.
   * @returns {void}
   */
  const handleMouseDown = (e) => {
    // Left click on empty canvas, middle mouse button, or Alt + left click - start panning
    const isCanvasClick = e.target === canvasRef.current || e.target.classList.contains('canvas-inner')
    if (e.button === 1 || (e.button === 0 && (isCanvasClick || e.altKey))) {
      e.preventDefault()
      setIsPanning(true)
      setHasDragged(false)
      setPanStart({ x: e.clientX - state.pan.x * state.zoom, y: e.clientY - state.pan.y * state.zoom })
    }
  }

  /**
   * While panning is active, updates the pan offset to follow the cursor and marks
   * that a drag occurred (so the subsequent click is not treated as a deselect).
   *
   * @param {MouseEvent} e - The mousemove event.
   * @returns {void}
   */
  const handleMouseMove = useCallback((e) => {
    if (isPanning) {
      setHasDragged(true)
      dispatch({
        type: 'SET_PAN',
        payload: {
          x: (e.clientX - panStart.x) / state.zoom,
          y: (e.clientY - panStart.y) / state.zoom
        }
      })
    }
  }, [isPanning, panStart, state.zoom, dispatch])

  /**
   * Ends panning and clears the drag flag on the next tick, giving the click handler
   * a chance to read hasDragged before it resets.
   *
   * @returns {void}
   */
  const handleMouseUp = () => {
    setIsPanning(false)
    // Reset hasDragged after a short delay to allow click handler to check it
    setTimeout(() => setHasDragged(false), 0)
  }

  useEffect(() => {
    const canvas = canvasRef.current
    if (canvas) {
      canvas.addEventListener('wheel', handleWheel, { passive: false })
      return () => canvas.removeEventListener('wheel', handleWheel)
    }
  }, [handleWheel])

  /**
   * Handles clicking a node's border port. If no connection is in progress, starts one
   * from the clicked port; otherwise, if the click is on a different node, completes the
   * connection from the pending source to this target.
   *
   * @param {string} nodeId - The id of the clicked node.
   * @param {{x: number, y: number}} point - The clicked port position in canvas space.
   * @returns {void}
   */
  const handleBorderClick = useCallback((nodeId, point) => {
    if (!connectingFrom) {
      // Start new connection
      dispatch({ type: 'START_CONNECTING', payload: { nodeId, point } })
    } else if (connectingFrom.nodeId !== nodeId) {
      // Complete connection to different node (ADD_CONNECTION clears connectingFrom)
      dispatch({
        type: 'ADD_CONNECTION',
        payload: {
          sourceId: connectingFrom.nodeId,
          targetId: nodeId,
          sourcePoint: connectingFrom.point,
          targetPoint: point
        }
      })
    }
  }, [connectingFrom, dispatch])

  /**
   * Persists updated source/target port positions for a connection when its auto-routing
   * selects different ports than were previously stored.
   *
   * @param {string} connectionId - The id of the connection whose ports changed.
   * @param {{x: number, y: number}} sourcePoint - The new source port position.
   * @param {{x: number, y: number}} targetPoint - The new target port position.
   * @returns {void}
   */
  const handlePortsChanged = useCallback((connectionId, sourcePoint, targetPoint) => {
    dispatch({
      type: 'UPDATE_CONNECTION_PORTS',
      payload: { id: connectionId, sourcePoint, targetPoint }
    })
  }, [dispatch])

  return (
    <div
      ref={canvasRef}
      className={`canvas ${isPanning ? 'panning' : ''} ${connectingFrom ? 'connecting-mode' : ''}`}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
      onClick={handleCanvasClick}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
    >
      <div
        className="canvas-inner"
        style={{
          transform: `scale(${state.zoom}) translate(${state.pan.x}px, ${state.pan.y}px)`
        }}
      >
        <svg className="connections-layer">
          <defs>
            <marker
              id="arrowhead"
              markerWidth="10"
              markerHeight="7"
              refX="9"
              refY="3.5"
              orient="auto"
            >
              <polygon points="0 0, 10 3.5, 0 7" fill="var(--accent-primary)" />
            </marker>
            <marker
              id="arrowhead-selected"
              markerWidth="10"
              markerHeight="7"
              refX="9"
              refY="3.5"
              orient="auto"
            >
              <polygon points="0 0, 10 3.5, 0 7" fill="var(--accent-danger)" />
            </marker>
          </defs>
          {state.connections.map(connection => {
            const sourceNode = state.nodes.find(n => n.id === connection.sourceId)
            const targetNode = state.nodes.find(n => n.id === connection.targetId)
            if (!sourceNode || !targetNode) return null
            return (
              <Connection
                key={connection.id}
                connection={connection}
                sourceNode={sourceNode}
                targetNode={targetNode}
                allNodes={state.nodes}
                allConnections={state.connections}
                isSelected={state.selectedConnectionId === connection.id}
                onSelect={(id) => dispatch({ type: 'SELECT_CONNECTION', payload: id })}
                onPortsChanged={handlePortsChanged}
              />
            )
          })}
        </svg>
        {state.nodes.map(node => (
          <Node
            key={node.id}
            node={node}
            isSelected={state.selectedNodeId === node.id}
            isConnecting={connectingFrom?.nodeId === node.id}
            connections={state.connections}
            onSelect={(id) => dispatch({ type: 'SELECT_NODE', payload: id })}
            onPositionChange={(id, x, y) => dispatch({ type: 'UPDATE_NODE_POSITION', payload: { id, x, y } })}
            onBorderClick={handleBorderClick}
            onDragStart={() => dispatch({ type: 'START_DRAG' })}
            onDragEnd={() => dispatch({ type: 'END_DRAG' })}
            zoom={state.zoom}
          />
        ))}
      </div>
      <div className="canvas-info">
        <span>Zoom: {Math.round(state.zoom * 100)}%</span>
        <span>Nodes: {state.nodes.length}</span>
        <span>Connections: {state.connections.length}</span>
        {connectingFrom && <span className="connecting-hint">Click a port on another node to connect</span>}
      </div>
    </div>
  )
}

export default Canvas