diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index f6e91fa..fecec92 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -275,6 +275,116 @@ margin-top: 0; } +.canvasHeaderEditRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + width: 100%; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color, #e8e8e8); +} + +.canvasHeaderEditRow :global(button) { + margin-top: 0; +} + +.canvasHeaderGhostIconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + background: transparent; + border-radius: 6px; + color: var(--text-primary, #333); + cursor: pointer; + box-sizing: border-box; +} + +.canvasHeaderGhostIconBtn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); +} + +.canvasHeaderGhostIconBtn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.canvasHeaderZoomCombo { + position: relative; + display: inline-flex; + align-items: stretch; + flex: 0 0 auto; +} + +.canvasHeaderZoomInputWrap { + display: inline-flex; + align-items: center; + flex: 0 1 auto; + min-width: 4.25rem; + padding-left: 0.35rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 6px 0 0 6px; + border-right: none; + background: var(--bg-primary, #fff); + box-sizing: border-box; + min-height: 30px; +} + +.canvasHeaderZoomInputWrap:focus-within { + border-color: var(--primary-color, #007bff); +} + +.canvasHeaderZoomInput { + flex: 1 1 auto; + width: 2.25rem; + min-width: 0; + padding: 0.28rem 0.15rem 0.28rem 0; + font-size: 0.8125rem; + border: none; + background: transparent; + color: var(--text-primary, #333); + text-align: right; + box-sizing: border-box; + min-height: 28px; +} + +.canvasHeaderZoomInput:focus { + outline: none; +} + +.canvasHeaderZoomSuffix { + flex-shrink: 0; + padding-right: 0.35rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #666); + user-select: none; +} + +.canvasHeaderZoomChevronBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-height: 30px; + padding: 0; + border: 1px solid var(--border-color, #ccc); + border-radius: 0 6px 6px 0; + background: var(--bg-primary, #fff); + color: var(--text-primary, #333); + cursor: pointer; + box-sizing: border-box; +} + +.canvasHeaderZoomChevronBtn:hover { + background: rgba(0, 0, 0, 0.06); +} + /* Closed setZoomInputDraft(e.target.value)} + onBlur={_commitZoomDraft} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + _commitZoomDraft(); + } + }} + aria-label={t('Zoomstufe (Prozent)')} + title={t('Zoomstufe (Prozent)')} + /> + + % + + + + {zoomMenuOpen && ( +
+ + + {ZOOM_PRESET_PERCENTS.map((pct) => ( + + ))} +
+ )} + + + + + + + + + + + )} + {currentWorkflowId && versions && versions.length > 0 && (
{t('Version:')} diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index 01c5bb1..0961c9d 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -3,7 +3,16 @@ * Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; @@ -34,8 +43,101 @@ export interface CanvasConnection { targetHandle: number; } +/** Freie Benutzer-Notiz auf der Canvas; wird nicht in den Workflow/Graph persistiert. */ +export interface CanvasStickyNote { + id: string; + x: number; + y: number; + width: number; + /** Höhe des Textbereichs unter der Toolbar (Pixel). Standard: ``STICKY_NOTE_DEFAULT_HEIGHT``. */ + height?: number; + text: string; + /** Farbe aus ``STICKY_NOTE_PALETTE`` (Standard: ``yellow``). */ + colorId?: string; +} + +const STICKY_NOTE_DEFAULT_WIDTH = 220; +export const STICKY_NOTE_DEFAULT_HEIGHT = 96; +const STICKY_NOTE_MIN_WIDTH = 120; +const STICKY_NOTE_MIN_HEIGHT = 48; + +export const STICKY_NOTE_DEFAULT_COLOR_ID = 'yellow'; + +/** Vorgaben für Sticky-Hintergrund/-Rand (wie klassische Haftnotizen). */ +export const STICKY_NOTE_PALETTE: ReadonlyArray<{ + id: string; + bg: string; + border: string; + textareaBg: string; +}> = [ + { + id: 'yellow', + bg: 'rgba(255, 249, 196, 0.92)', + border: 'rgba(180, 170, 90, 0.55)', + textareaBg: 'rgba(255, 252, 220, 0.98)', + }, + { + id: 'pink', + bg: 'rgba(255, 228, 238, 0.92)', + border: 'rgba(200, 120, 150, 0.55)', + textareaBg: 'rgba(255, 240, 245, 0.98)', + }, + { + id: 'mint', + bg: 'rgba(220, 248, 230, 0.92)', + border: 'rgba(100, 160, 110, 0.5)', + textareaBg: 'rgba(235, 252, 238, 0.98)', + }, + { + id: 'sky', + bg: 'rgba(220, 236, 255, 0.92)', + border: 'rgba(100, 140, 200, 0.5)', + textareaBg: 'rgba(235, 244, 255, 0.98)', + }, + { + id: 'lavender', + bg: 'rgba(235, 228, 255, 0.92)', + border: 'rgba(140, 120, 200, 0.5)', + textareaBg: 'rgba(245, 240, 255, 0.98)', + }, + { + id: 'peach', + bg: 'rgba(255, 236, 210, 0.92)', + border: 'rgba(200, 140, 90, 0.5)', + textareaBg: 'rgba(255, 245, 228, 0.98)', + }, +]; + +export function getStickyNotePaletteEntry(colorId?: string) { + const id = colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID; + return STICKY_NOTE_PALETTE.find((p) => p.id === id) ?? STICKY_NOTE_PALETTE[0]; +} + const NODE_WIDTH = 200; const NODE_HEIGHT = 72; + +export const FLOW_CANVAS_MIN_ZOOM = 0.25; +export const FLOW_CANVAS_MAX_ZOOM = 4; + +export interface FlowCanvasViewportEditState { + zoom: number; + selectedNodeCount: number; + connectionSelected: boolean; +} + +export type FlowCanvasHandle = { + focusCanvas: () => void; + zoomIn: () => void; + zoomOut: () => void; + setZoomPercent: (percent: number) => void; + fitWindow: () => void; + resetView: () => void; + deleteSelection: () => void; + duplicateSingleSelection: () => void; + toggleConnectionTool: () => void; + /** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */ + addCanvasComment: () => void; +}; const HANDLE_SIZE = 12; const HANDLE_OFFSET = HANDLE_SIZE / 2; const LAYOUT_V_GAP = 80; @@ -285,6 +387,13 @@ interface FlowCanvasProps { * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; + onViewportEditState?: (state: FlowCanvasViewportEditState) => void; + /** Nach diskreten Canvas-Aktionen (Drop, Drag-Ende, Kante, Löschen …) für Undo. */ + onHistoryCheckpoint?: () => void; + onConnectionToolActiveChange?: (active: boolean) => void; + /** Nur Anzeige: Benutzer-Kommentare auf der Fläche (ohne Workflow-Daten). */ + stickyNotes?: CanvasStickyNote[]; + onStickyNotesChange?: (notes: CanvasStickyNote[]) => void; } const HIGHLIGHT_COLORS: Record = { @@ -294,21 +403,36 @@ const HIGHLIGHT_COLORS: Record = { skipped: '#6c757d', }; -export const FlowCanvas: React.FC = ({ nodes, - connections, - nodeTypes, - onNodesChange, - onConnectionsChange, - onDropNodeType, - getLabel, - getCategoryIcon, - onSelectionChange, - highlightedNodeIds, - nodeErrors, - onExternalDrop, -}) => { +export const FlowCanvas = forwardRef(function FlowCanvas( + { + nodes, + connections, + nodeTypes, + onNodesChange, + onConnectionsChange, + onDropNodeType, + getLabel, + getCategoryIcon, + onSelectionChange, + highlightedNodeIds, + nodeErrors, + onExternalDrop, + onViewportEditState, + onHistoryCheckpoint, + onConnectionToolActiveChange, + stickyNotes = [], + onStickyNotesChange, + }, + ref +) { const { t } = useLanguage(); + const tRef = useRef(t); + tRef.current = t; const containerRef = useRef(null); + const stickyNotesRef = useRef(stickyNotes); + stickyNotesRef.current = stickyNotes; + const onStickyNotesChangeRef = useRef(onStickyNotesChange); + onStickyNotesChangeRef.current = onStickyNotesChange; const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const [selectedConnectionId, setSelectedConnectionId] = useState(null); @@ -336,6 +460,13 @@ export const FlowCanvas: React.FC = ({ nodes, }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); + const panZoomRef = useRef({ x: 0, y: 0, zoom: 1 }); + panZoomRef.current = { x: panOffset.x, y: panOffset.y, zoom }; + const [connectionToolActive, setConnectionToolActive] = useState(false); + const [pendingConnClickSource, setPendingConnClickSource] = useState<{ + nodeId: string; + handleIndex: number; + } | null>(null); const [panning, setPanning] = useState<{ startX: number; startY: number; @@ -343,6 +474,30 @@ export const FlowCanvas: React.FC = ({ nodes, startPanY: number; } | null>(null); + const [editingStickyId, setEditingStickyId] = useState(null); + const [stickyFocusSelectAll, setStickyFocusSelectAll] = useState(false); + const stickyTextareaRef = useRef(null); + const [stickyDragState, setStickyDragState] = useState<{ + id: string; + startClientX: number; + startClientY: number; + noteInitial: { x: number; y: number }; + } | null>(null); + const [stickyResizeState, setStickyResizeState] = useState<{ + id: string; + startClientX: number; + startClientY: number; + startWidth: number; + startHeight: number; + } | null>(null); + const [selectedStickyId, setSelectedStickyId] = useState(null); + + useEffect(() => { + if (selectedStickyId && !stickyNotes.some((s) => s.id === selectedStickyId)) { + setSelectedStickyId(null); + } + }, [stickyNotes, selectedStickyId]); + const nodeTypeMap = useMemo(() => { const m: Record = {}; nodeTypes.forEach((nt) => { @@ -351,6 +506,166 @@ export const FlowCanvas: React.FC = ({ nodes, return m; }, [nodeTypes]); + const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); + onHistoryCheckpointRef.current = onHistoryCheckpoint; + + const emitHistoryCheckpoint = useCallback(() => { + onHistoryCheckpointRef.current?.(); + }, []); + + useEffect(() => { + onViewportEditState?.({ + zoom, + selectedNodeCount: selectedNodeIds.size, + connectionSelected: !!selectedConnectionId, + }); + }, [zoom, selectedNodeIds, selectedConnectionId, onViewportEditState]); + + useEffect(() => { + onConnectionToolActiveChange?.(connectionToolActive); + }, [connectionToolActive, onConnectionToolActiveChange]); + + useImperativeHandle( + ref, + () => ({ + focusCanvas: () => { + containerRef.current?.focus(); + }, + zoomIn: () => { + setZoom((z) => + Math.min(FLOW_CANVAS_MAX_ZOOM, Math.round((z * 1.1 + Number.EPSILON) * 1000) / 1000) + ); + }, + zoomOut: () => { + setZoom((z) => + Math.max(FLOW_CANVAS_MIN_ZOOM, Math.round((z / 1.1 + Number.EPSILON) * 1000) / 1000) + ); + }, + setZoomPercent: (percent: number) => { + const p = Math.min(400, Math.max(25, Number.isFinite(percent) ? percent : 100)); + setZoom(p / 100); + }, + fitWindow: () => { + const el = containerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cw = rect.width; + const ch = rect.height; + if (nodes.length === 0) { + setZoom(1); + setPanOffset({ x: 0, y: 0 }); + return; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of nodes) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + NODE_WIDTH); + maxY = Math.max(maxY, n.y + NODE_HEIGHT); + } + const pad = 48; + const bw = Math.max(maxX - minX, 1); + const bh = Math.max(maxY - minY, 1); + const scale = Math.min((cw - 2 * pad) / bw, (ch - 2 * pad) / bh); + const newZoom = Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, scale)); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + setZoom(newZoom); + setPanOffset({ + x: cw / 2 - cx * newZoom, + y: ch / 2 - cy * newZoom, + }); + }, + resetView: () => { + setZoom(1); + setPanOffset({ x: 0, y: 0 }); + }, + deleteSelection: () => { + if (selectedConnectionId) { + onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); + setSelectedConnectionId(null); + emitHistoryCheckpoint(); + return; + } + if (selectedNodeIds.size === 0) return; + const ids = selectedNodeIds; + onNodesChange(nodes.filter((n) => !ids.has(n.id))); + onConnectionsChange( + connections.filter((c) => !ids.has(c.sourceId) && !ids.has(c.targetId)) + ); + setSelectedNodeIds(new Set()); + setEditingNodeId(null); + setEditingField(null); + setSelectedStickyId(null); + emitHistoryCheckpoint(); + }, + duplicateSingleSelection: () => { + if (selectedNodeIds.size !== 1) return; + const id = [...selectedNodeIds][0]; + const node = nodes.find((n) => n.id === id); + if (!node) return; + const newId = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const clone: CanvasNode = { + ...node, + id: newId, + x: node.x + 40, + y: node.y + 40, + parameters: node.parameters ? { ...node.parameters } : {}, + }; + onNodesChange([...nodes, clone]); + setSelectedNodeIds(new Set([newId])); + setSelectedStickyId(null); + emitHistoryCheckpoint(); + }, + addCanvasComment: () => { + const change = onStickyNotesChangeRef.current; + if (!change) return; + const el = containerRef.current; + if (!el) return; + const { x: panX, y: panY, zoom: z } = panZoomRef.current; + const rect = el.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + const w = STICKY_NOTE_DEFAULT_WIDTH; + const canvasX = (cx - panX) / z - w / 2; + const canvasY = (cy - panY) / z - 32; + const id = `sn_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const text = tRef.current('Kommentar eingeben …'); + const note: CanvasStickyNote = { + id, + x: Math.max(8, canvasX), + y: Math.max(8, canvasY), + width: w, + height: STICKY_NOTE_DEFAULT_HEIGHT, + text, + colorId: STICKY_NOTE_DEFAULT_COLOR_ID, + }; + change([...stickyNotesRef.current, note]); + setEditingStickyId(id); + setSelectedStickyId(id); + setStickyFocusSelectAll(true); + }, + toggleConnectionTool: () => { + setConnectionToolActive((p) => !p); + setPendingConnClickSource(null); + setConnectingFrom(null); + setDragPos(null); + }, + }), + [ + connections, + emitHistoryCheckpoint, + nodes, + onConnectionsChange, + onNodesChange, + selectedConnectionId, + selectedNodeIds, + ] + ); + useEffect(() => { if (onSelectionChange) { const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; @@ -362,13 +677,15 @@ export const FlowCanvas: React.FC = ({ nodes, e.stopPropagation(); setSelectedConnectionId(connId); setSelectedNodeIds(new Set()); + setSelectedStickyId(null); }, []); const handleDeleteConnection = useCallback(() => { if (!selectedConnectionId) return; onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); setSelectedConnectionId(null); - }, [selectedConnectionId, connections, onConnectionsChange]); + emitHistoryCheckpoint(); + }, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]); const getHandlePosition = useCallback( (node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => { @@ -450,22 +767,24 @@ export const FlowCanvas: React.FC = ({ nodes, const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; onDropNodeType(type, Math.max(0, x), Math.max(0, y)); + emitHistoryCheckpoint(); } catch (_) {} }, - [onDropNodeType, onExternalDrop, panOffset, zoom] + [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] ); const handleHandleMouseDown = useCallback( (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => { e.stopPropagation(); if (!isOutput) return; + if (connectionToolActive) return; const node = nodes.find((n) => n.id === nodeId); if (!node) return; const pos = getHandlePosition(node, handleIndex); setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y }); setDragPos({ x: e.clientX, y: e.clientY }); }, - [nodes, getHandlePosition] + [nodes, getHandlePosition, connectionToolActive] ); const handleHandleMouseUp = useCallback( @@ -498,42 +817,63 @@ export const FlowCanvas: React.FC = ({ nodes, ) ); setSelectedConnectionId(null); + emitHistoryCheckpoint(); } return; } + const effectiveSource = + connectionToolActive && pendingConnClickSource + ? pendingConnClickSource + : connectingFrom + ? { nodeId: connectingFrom.nodeId, handleIndex: connectingFrom.handleIndex } + : null; + const allowLoopSelfFeedback = + !!effectiveSource && targetNode.type === 'flow.loop' && targetHandleIndex === 0 && - connectingFrom.handleIndex >= targetNode.inputs; + effectiveSource.handleIndex >= targetNode.inputs; if ( - !connectingFrom || - (connectingFrom.nodeId === targetNodeId && !allowLoopSelfFeedback) + !effectiveSource || + (effectiveSource.nodeId === targetNodeId && !allowLoopSelfFeedback) ) { setConnectingFrom(null); setDragPos(null); return; } const key = `${targetNodeId}-${targetHandleIndex}`; - if (getUsedTargetHandles.has(key) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)) { + if ( + getUsedTargetHandles.has(key) && + !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex) + ) { setConnectingFrom(null); setDragPos(null); + setPendingConnClickSource(null); return; } const newConn: CanvasConnection = { id: `c_${Date.now()}`, - sourceId: connectingFrom.nodeId, - sourceHandle: connectingFrom.handleIndex, + sourceId: effectiveSource.nodeId, + sourceHandle: effectiveSource.handleIndex, targetId: targetNodeId, targetHandle: targetHandleIndex, }; - const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId); + const srcNode = nodes.find((n) => n.id === effectiveSource.nodeId); const tgtNode = nodes.find((n) => n.id === targetNodeId); if (srcNode && tgtNode) { - const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs - ? connectingFrom.handleIndex - srcNode.inputs : 0; - const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes); + const sourceOutputIdx = + effectiveSource.handleIndex >= srcNode.inputs + ? effectiveSource.handleIndex - srcNode.inputs + : 0; + const compat = _checkConnectionCompatibility( + srcNode, + sourceOutputIdx, + tgtNode, + targetHandleIndex, + nodeTypes + ); if (compat === 'warning') { setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true })); } @@ -542,8 +882,21 @@ export const FlowCanvas: React.FC = ({ nodes, onConnectionsChange([...connections, newConn]); setConnectingFrom(null); setDragPos(null); + setPendingConnClickSource(null); + emitHistoryCheckpoint(); }, - [connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId] + [ + connectingFrom, + connectionToolActive, + pendingConnClickSource, + connections, + nodes, + getUsedTargetHandles, + onConnectionsChange, + selectedConnectionId, + nodeTypes, + emitHistoryCheckpoint, + ] ); React.useEffect(() => { @@ -608,14 +961,97 @@ export const FlowCanvas: React.FC = ({ nodes, }) ); }; - const onUp = () => setDraggingNodeId(null); + const onUp = () => { + setDraggingNodeId(null); + emitHistoryCheckpoint(); + }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]); + }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom, emitHistoryCheckpoint]); + + React.useEffect(() => { + if (!stickyDragState) return; + const drag = stickyDragState; + const onMove = (e: MouseEvent) => { + const dx = (e.clientX - drag.startClientX) / zoom; + const dy = (e.clientY - drag.startClientY) / zoom; + const change = onStickyNotesChangeRef.current; + const notes = stickyNotesRef.current; + if (!change) return; + change( + notes.map((s) => + s.id === drag.id + ? { ...s, x: drag.noteInitial.x + dx, y: drag.noteInitial.y + dy } + : s + ) + ); + }; + const onUp = () => setStickyDragState(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [stickyDragState, zoom]); + + React.useEffect(() => { + if (!stickyResizeState) return; + const r = stickyResizeState; + const onMove = (e: MouseEvent) => { + const dx = (e.clientX - r.startClientX) / zoom; + const dy = (e.clientY - r.startClientY) / zoom; + const change = onStickyNotesChangeRef.current; + const notes = stickyNotesRef.current; + if (!change) return; + const nextW = Math.max(STICKY_NOTE_MIN_WIDTH, r.startWidth + dx); + const nextH = Math.max(STICKY_NOTE_MIN_HEIGHT, r.startHeight + dy); + change(notes.map((s) => (s.id === r.id ? { ...s, width: nextW, height: nextH } : s))); + }; + const onUp = () => setStickyResizeState(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [stickyResizeState, zoom]); + + const handleStickyResizeMouseDown = useCallback((e: React.MouseEvent, sn: CanvasStickyNote) => { + e.stopPropagation(); + e.preventDefault(); + const h = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT; + setStickyResizeState({ + id: sn.id, + startClientX: e.clientX, + startClientY: e.clientY, + startWidth: sn.width, + startHeight: h, + }); + }, []); + + const handleStickyToolbarMouseDown = useCallback( + (e: React.MouseEvent, sn: CanvasStickyNote) => { + if ((e.target as HTMLElement).closest('button')) return; + e.stopPropagation(); + e.preventDefault(); + setSelectedStickyId(sn.id); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + setEditingStickyId(null); + setStickyDragState({ + id: sn.id, + startClientX: e.clientX, + startClientY: e.clientY, + noteInitial: { x: sn.x, y: sn.y }, + }); + }, + [] + ); const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 }); React.useEffect(() => { @@ -641,9 +1077,11 @@ export const FlowCanvas: React.FC = ({ nodes, const handleCanvasMouseDown = useCallback( (e: React.MouseEvent) => { const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`); - if (hitNode || connectingFrom) return; + const hitSticky = (e.target as HTMLElement).closest(`.${styles.canvasStickyNote}`); + if (hitNode || hitSticky || connectingFrom) return; if (e.shiftKey) { e.preventDefault(); + setSelectedStickyId(null); const pt = clientToCanvas(e.clientX, e.clientY); setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y }); setSelectedNodeIds(new Set()); @@ -663,7 +1101,9 @@ export const FlowCanvas: React.FC = ({ nodes, const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; - setZoom((z) => Math.min(2, Math.max(0.25, z + delta))); + setZoom((z) => + Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, z + delta)) + ); }, []); React.useEffect(() => { @@ -722,6 +1162,7 @@ export const FlowCanvas: React.FC = ({ nodes, if (overlaps) ids.add(n.id); }); setSelectedNodeIds(ids); + setSelectedStickyId(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); @@ -755,7 +1196,8 @@ export const FlowCanvas: React.FC = ({ nodes, setSelectedNodeIds(new Set()); setEditingNodeId(null); setEditingField(null); - }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]); + emitHistoryCheckpoint(); + }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange, emitHistoryCheckpoint]); React.useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -765,6 +1207,11 @@ export const FlowCanvas: React.FC = ({ nodes, setConnectingFrom(null); setDragPos(null); setSelectedConnectionId(null); + setPendingConnClickSource(null); + setEditingStickyId(null); + setStickyDragState(null); + setStickyResizeState(null); + setSelectedStickyId(null); } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedConnectionId) { @@ -789,10 +1236,31 @@ export const FlowCanvas: React.FC = ({ nodes, [nodes, onNodesChange] ); + const patchStickyNote = useCallback( + ( + id: string, + patch: Partial> + ) => { + onStickyNotesChange?.( + stickyNotes.map((s) => (s.id === id ? { ...s, ...patch } : s)) + ); + }, + [stickyNotes, onStickyNotesChange] + ); + + useLayoutEffect(() => { + if (!stickyFocusSelectAll || !editingStickyId) return; + const ta = stickyTextareaRef.current; + if (!ta) return; + ta.focus(); + ta.select(); + setStickyFocusSelectAll(false); + }, [editingStickyId, stickyFocusSelectAll, stickyNotes]); + return (
= ({ nodes, setSelectedConnectionId(null); setConnectingFrom(null); setDragPos(null); + setPendingConnClickSource(null); + setEditingStickyId(null); + setSelectedStickyId(null); }} > {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && ( @@ -840,6 +1311,24 @@ export const FlowCanvas: React.FC = ({ nodes, {t('Anderen Eingang anklicken zum Umleiten')}
)} + {connectionToolActive && pendingConnClickSource && !selectedConnectionId && ( +
+ {t('Klicken Sie auf einen Eingang, um die Verbindung zu erstellen')} + {' · '} + Esc {t('zum Abbrechen')} +
+ )} + {connectionToolActive && + !pendingConnClickSource && + !connectingFrom && + !selectedConnectionId && + selectedNodeIds.size <= 1 && ( +
+ {t('Klicken Sie auf einen Ausgang, dann auf einen Eingang')} + {' · '} + Esc {t('zum Abbrechen')} +
+ )}
= ({ nodes, for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true }); const wireSourceNode = - connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null; + !selectedConnectionId && connectingFrom + ? nodes.find((n) => n.id === connectingFrom.nodeId) + : !selectedConnectionId && connectionToolActive && pendingConnClickSource + ? nodes.find((n) => n.id === pendingConnClickSource.nodeId) + : null; + + const wireSourceHandleIdx = + connectingFrom?.handleIndex ?? pendingConnClickSource?.handleIndex ?? -1; const isSelected = selectedNodeIds.has(node.id); const isEditingTitle = editingNodeId === node.id && editingField === 'title'; @@ -990,6 +1486,7 @@ export const FlowCanvas: React.FC = ({ nodes, onMouseDown={(e) => { e.stopPropagation(); setSelectedConnectionId(null); + setSelectedStickyId(null); if (e.shiftKey) { setSelectedNodeIds((prev) => { const next = new Set(prev); @@ -1051,17 +1548,20 @@ export const FlowCanvas: React.FC = ({ nodes, const isCurrentTargetOfSelection = selConn && selConn.targetId === node.id && selConn.targetHandle === index; let wireTargetOk = true; - if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) { + if (!isOutput && wireSourceNode && wireSourceHandleIdx >= 0) { const sourceOutputIdx = - connectingFrom.handleIndex >= wireSourceNode.inputs - ? connectingFrom.handleIndex - wireSourceNode.inputs + wireSourceHandleIdx >= wireSourceNode.inputs + ? wireSourceHandleIdx - wireSourceNode.inputs : 0; wireTargetOk = _checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok'; } const canConnect = isOutput || - (!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) || + (!used && + !!wireSourceNode && + wireSourceHandleIdx >= 0 && + (!selectedConnectionId ? wireTargetOk : true)) || (!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection)); const nt = nodeTypeMap[node.type]; const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined; @@ -1082,6 +1582,13 @@ export const FlowCanvas: React.FC = ({ nodes, style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + onClick={(e) => { + if (!connectionToolActive || !isOutput) return; + e.stopPropagation(); + setPendingConnClickSource({ nodeId: node.id, handleIndex: index }); + setConnectingFrom(null); + setDragPos(null); + }} title={outputLabel} /> {outputLabel} @@ -1093,6 +1600,13 @@ export const FlowCanvas: React.FC = ({ nodes, style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)} onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + onClick={(e) => { + if (!connectionToolActive || !isOutput) return; + e.stopPropagation(); + setPendingConnClickSource({ nodeId: node.id, handleIndex: index }); + setConnectingFrom(null); + setDragPos(null); + }} title={ outputLabel ?? (selectedConnectionId && !isOutput @@ -1166,6 +1680,126 @@ export const FlowCanvas: React.FC = ({ nodes,
); })} + {stickyNotes.map((sn) => { + const pal = getStickyNotePaletteEntry(sn.colorId); + const activeColorId = sn.colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID; + const contentH = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT; + return ( +
{ + e.stopPropagation(); + setSelectedStickyId(sn.id); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + }} + onClick={(e) => e.stopPropagation()} + > +
handleStickyToolbarMouseDown(e, sn)} + > + + ⋮⋮ + + {selectedStickyId === sn.id ? ( +
+ {STICKY_NOTE_PALETTE.map((p) => ( +
+ ) : null} +
+ {editingStickyId === sn.id ? ( +