feat: added edit button bar und kommentar-funktion

This commit is contained in:
Ida 2026-05-13 15:36:16 +02:00
parent c13489e232
commit 3a7a34a4f3
5 changed files with 1306 additions and 46 deletions

View file

@ -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 <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 0 auto;
@ -501,6 +611,165 @@
background-repeat: repeat;
}
.canvasDropZoneConnectionTool {
cursor: crosshair;
}
.canvasStickyNote {
position: relative;
pointer-events: auto;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteResize {
position: absolute;
right: 1px;
bottom: 1px;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: none;
border-radius: 2px 0 6px 0;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.12) 45%,
rgba(0, 0, 0, 0.12) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.18) 58%,
rgba(0, 0, 0, 0.18) 64%,
transparent 64%
);
box-sizing: border-box;
}
.canvasStickyNoteResize:hover {
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.2) 45%,
rgba(0, 0, 0, 0.2) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.26) 58%,
rgba(0, 0, 0, 0.26) 64%,
transparent 64%
);
}
.canvasStickyNoteResize:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteSelected {
box-shadow:
0 0 0 2px var(--primary-color, #007bff),
0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteToolbar {
display: flex;
align-items: center;
gap: 0.35rem;
min-height: 1.5rem;
padding: 0.15rem 0.25rem 0.2rem;
background: rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none;
}
.canvasStickyNoteToolbar:active {
cursor: grabbing;
}
.canvasStickyNoteGrip {
flex: 1;
font-size: 0.7rem;
letter-spacing: -0.12em;
color: var(--text-muted, #666);
opacity: 0.85;
padding: 0 0.15rem;
}
.canvasStickyNoteSwatches {
display: flex;
flex-wrap: wrap;
gap: 3px;
justify-content: flex-end;
}
.canvasStickyNoteSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.22);
padding: 0;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.canvasStickyNoteSwatch:hover {
filter: brightness(0.96);
}
.canvasStickyNoteSwatchActive {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteBody {
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary, #333);
border: 1px solid transparent;
border-radius: 0;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
outline: none;
}
.canvasStickyNoteBody:focus-visible {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.35);
}
.canvasStickyNoteTextarea {
display: block;
width: 100%;
margin: 0;
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
font-family: inherit;
color: var(--text-primary, #333);
border-style: solid;
border-width: 1px;
border-radius: 0;
box-shadow: none;
resize: none;
box-sizing: border-box;
outline: none;
}
.canvasStickyNoteTextarea:focus {
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.25);
}
.canvasContent {
position: absolute;
left: 0;

View file

@ -33,7 +33,14 @@ import {
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
@ -62,6 +69,20 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[Automation2]';
const CANVAS_HISTORY_MAX = 50;
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
return {
nodes: nodes.map((n) => ({
...n,
parameters: n.parameters ? { ...n.parameters } : {},
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
})),
connections: connections.map((c) => ({ ...c })),
};
}
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
@ -106,6 +127,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const suppressCanvasHistoryRef = useRef(false);
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
zoom: 1,
selectedNodeCount: 0,
connectionSelected: false,
});
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
@ -209,8 +242,66 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
if (suppressCanvasHistoryRef.current) return;
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const past = canvasHistoryPastRef.current;
const last = past[past.length - 1];
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
past.push(snap);
if (past.length > CANVAS_HISTORY_MAX) past.shift();
canvasHistoryFutureRef.current = [];
setCanvasHistoryTick((x) => x + 1);
}, [canvasNodes, canvasConnections]);
const onCanvasHistoryCheckpoint = useCallback(() => {
pushCanvasHistoryPastFromCurrent();
}, [pushCanvasHistoryPastFromCurrent]);
const undoCanvasEdit = useCallback(() => {
const past = canvasHistoryPastRef.current;
if (past.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = past.pop()!;
canvasHistoryFutureRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const redoCanvasEdit = useCallback(() => {
const fut = canvasHistoryFutureRef.current;
if (fut.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = fut.pop()!;
canvasHistoryPastRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
(
graph: Automation2Graph | null | undefined,
wfInvocations: WorkflowEntryPoint[] | undefined,
opts?: { skipHistory?: boolean }
) => {
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
pushCanvasHistoryPastFromCurrent();
}
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
setInvocations(inv);
if (!graph?.nodes?.length) {
@ -224,7 +315,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
},
[nodeTypes, language, t]
[nodeTypes, language, t, pushCanvasHistoryPastFromCurrent]
);
const handleFromApiGraph = useCallback(
@ -466,6 +557,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows();
}, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -478,7 +573,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')), {
skipHistory: true,
});
}, [
loading,
nodeTypes.length,
@ -720,6 +817,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
const canvasHeaderEdit = useMemo(
() => ({
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
connectionSelected: canvasViewportEdit.connectionSelected,
connectionToolActive: canvasConnectionToolActive,
canUndo: canCanvasUndo,
canRedo: canCanvasRedo,
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
onResetView: () => flowCanvasRef.current?.resetView(),
onUndo: undoCanvasEdit,
onRedo: redoCanvasEdit,
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}),
[
canvasViewportEdit,
canvasConnectionToolActive,
canCanvasUndo,
canCanvasRedo,
undoCanvasEdit,
redoCanvasEdit,
]
);
return (
<div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
@ -826,10 +953,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNewFromTemplate={() => setTemplatePickerOpen(true)}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
canvasEdit={canvasHeaderEdit}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
@ -841,6 +970,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;

View file

@ -16,6 +16,16 @@ import {
FaChevronLeft,
FaChevronRight,
} from 'react-icons/fa';
import {
HiOutlineMagnifyingGlassMinus,
HiOutlineMagnifyingGlassPlus,
HiOutlineArrowUturnLeft,
HiOutlineArrowUturnRight,
HiOutlineTrash,
HiOutlineDocumentDuplicate,
HiOutlineArrowLongRight,
HiOutlineChatBubbleLeftEllipsis,
} from 'react-icons/hi2';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
@ -23,6 +33,29 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
import { Button } from '../../UiComponents/Button';
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
export interface CanvasHeaderCanvasEditProps {
zoomPercent: number;
selectedNodeCount: number;
connectionSelected: boolean;
connectionToolActive: boolean;
canUndo: boolean;
canRedo: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomPercentCommit: (percent: number) => void;
onFitWindow: () => void;
onResetView: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onDuplicateNode: () => void;
onToggleConnectionTool: () => void;
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
onAddCanvasComment: () => void;
}
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
@ -56,6 +89,7 @@ interface CanvasHeaderProps {
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
canvasEdit?: CanvasHeaderCanvasEditProps;
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -97,6 +131,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
onNewFromTemplate,
verboseSchema,
onVerboseSchemaChange,
canvasEdit,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -111,10 +146,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement>(null);
const [zoomInputDraft, setZoomInputDraft] = useState('');
useEffect(() => {
const zp = canvasEdit?.zoomPercent;
if (zp !== undefined) setZoomInputDraft(String(zp));
}, [canvasEdit?.zoomPercent]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
@ -148,6 +193,23 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
? styles.canvasHeaderExecuteBannerPaused
: styles.canvasHeaderExecuteBannerError;
const _commitZoomDraft = () => {
if (!canvasEdit) return;
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
const n = parseFloat(raw);
if (!Number.isFinite(n)) {
setZoomInputDraft(String(canvasEdit.zoomPercent));
return;
}
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
setZoomMenuOpen(false);
};
const _canDeleteSelection =
!!canvasEdit && (canvasEdit.selectedNodeCount > 0 || canvasEdit.connectionSelected);
const _singleNodeOnly =
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
return (
<div className={styles.canvasHeader}>
<div
@ -311,6 +373,166 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
)}
</div>
{canvasEdit && (
<div
className={styles.canvasHeaderEditRow}
role="toolbar"
aria-label={t('Canvas bearbeiten')}
>
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomInputWrap}>
<input
type="text"
inputMode="numeric"
className={styles.canvasHeaderZoomInput}
value={zoomInputDraft}
onChange={(e) => setZoomInputDraft(e.target.value)}
onBlur={_commitZoomDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitZoomDraft();
}
}}
aria-label={t('Zoomstufe (Prozent)')}
title={t('Zoomstufe (Prozent)')}
/>
<span className={styles.canvasHeaderZoomSuffix} aria-hidden>
%
</span>
</div>
<button
type="button"
className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setZoomMenuOpen((p) => !p)}
aria-label={t('Zoom-Voreinstellungen')}
aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
title={t('Zoom-Voreinstellungen')}
>
<FaCaretDown aria-hidden />
</button>
{zoomMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onFitWindow();
setZoomMenuOpen(false);
}}
>
{t('Ansicht an Fenster anpassen')}
</button>
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onResetView();
setZoomMenuOpen(false);
}}
>
{t('Ansicht zurücksetzen')}
</button>
{ZOOM_PRESET_PERCENTS.map((pct) => (
<button
key={pct}
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onZoomPercentCommit(pct);
setZoomMenuOpen(false);
}}
>
{pct}%
</button>
))}
</div>
)}
</div>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomIn}
title={t('Vergrößern')}
aria-label={t('Vergrößern')}
>
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomOut}
title={t('Verkleinern')}
aria-label={t('Verkleinern')}
>
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canUndo}
onClick={canvasEdit.onUndo}
title={t('Rückgängig')}
aria-label={t('Rückgängig')}
>
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canRedo}
onClick={canvasEdit.onRedo}
title={t('Wiederholen')}
aria-label={t('Wiederholen')}
>
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_canDeleteSelection}
onClick={canvasEdit.onDeleteSelection}
title={t('Auswahl löschen')}
aria-label={t('Auswahl löschen')}
>
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_singleNodeOnly}
onClick={canvasEdit.onDuplicateNode}
title={t('Knoten duplizieren')}
aria-label={t('Knoten duplizieren')}
>
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
aria-pressed={canvasEdit.connectionToolActive}
onClick={canvasEdit.onToggleConnectionTool}
title={t('Verbindungen zeichnen')}
aria-label={t('Verbindungen zeichnen')}
>
<HiOutlineArrowLongRight size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onAddCanvasComment}
title={t('Kommentar auf dem Canvas einfügen')}
aria-label={t('Kommentar auf dem Canvas einfügen')}
>
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
</button>
</div>
)}
{currentWorkflowId && versions && versions.length > 0 && (
<div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>

View file

@ -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> | 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<string, string> = {
@ -294,21 +403,36 @@ const HIGHLIGHT_COLORS: Record<string, string> = {
skipped: '#6c757d',
};
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
connections,
nodeTypes,
onNodesChange,
onConnectionsChange,
onDropNodeType,
getLabel,
getCategoryIcon,
onSelectionChange,
highlightedNodeIds,
nodeErrors,
onExternalDrop,
}) => {
export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(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<HTMLDivElement>(null);
const stickyNotesRef = useRef(stickyNotes);
stickyNotesRef.current = stickyNotes;
const onStickyNotesChangeRef = useRef(onStickyNotesChange);
onStickyNotesChangeRef.current = onStickyNotesChange;
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
@ -336,6 +460,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
startPanY: number;
} | null>(null);
const [editingStickyId, setEditingStickyId] = useState<string | null>(null);
const [stickyFocusSelectAll, setStickyFocusSelectAll] = useState(false);
const stickyTextareaRef = useRef<HTMLTextAreaElement | null>(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<string | null>(null);
useEffect(() => {
if (selectedStickyId && !stickyNotes.some((s) => s.id === selectedStickyId)) {
setSelectedStickyId(null);
}
}, [stickyNotes, selectedStickyId]);
const nodeTypeMap = useMemo(() => {
const m: Record<string, NodeType> = {};
nodeTypes.forEach((nt) => {
@ -351,6 +506,166 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
[nodes, onNodesChange]
);
const patchStickyNote = useCallback(
(
id: string,
patch: Partial<Pick<CanvasStickyNote, 'text' | 'colorId' | 'width' | 'height'>>
) => {
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 (
<div
ref={containerRef}
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId ? styles.canvasSelecting : ''}`}
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId || stickyDragState || stickyResizeState ? styles.canvasSelecting : ''} ${connectionToolActive ? styles.canvasDropZoneConnectionTool : ''}`}
style={{
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
@ -810,6 +1278,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
{t('Anderen Eingang anklicken zum Umleiten')}
</div>
)}
{connectionToolActive && pendingConnClickSource && !selectedConnectionId && (
<div className={styles.connectionHint}>
{t('Klicken Sie auf einen Eingang, um die Verbindung zu erstellen')}
{' · '}
<kbd>Esc</kbd> {t('zum Abbrechen')}
</div>
)}
{connectionToolActive &&
!pendingConnClickSource &&
!connectingFrom &&
!selectedConnectionId &&
selectedNodeIds.size <= 1 && (
<div className={styles.connectionHint}>
{t('Klicken Sie auf einen Ausgang, dann auf einen Eingang')}
{' · '}
<kbd>Esc</kbd> {t('zum Abbrechen')}
</div>
)}
<div
className={styles.canvasContent}
style={{
@ -965,7 +1454,14 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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}
/>
<span className={styles.handleLabel}>{outputLabel}</span>
@ -1093,6 +1600,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
</div>
);
})}
{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 (
<div
key={sn.id}
className={`${styles.canvasStickyNote} ${selectedStickyId === sn.id ? styles.canvasStickyNoteSelected : ''}`}
style={{
position: 'absolute',
left: sn.x,
top: sn.y,
width: sn.width,
zIndex: 25,
}}
onMouseDown={(e) => {
e.stopPropagation();
setSelectedStickyId(sn.id);
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
}}
onClick={(e) => e.stopPropagation()}
>
<div
className={styles.canvasStickyNoteToolbar}
onMouseDown={(e) => handleStickyToolbarMouseDown(e, sn)}
>
<span
className={styles.canvasStickyNoteGrip}
title={t('Canvas-Notiz verschieben')}
aria-hidden
>
</span>
{selectedStickyId === sn.id ? (
<div
className={styles.canvasStickyNoteSwatches}
role="group"
aria-label={t('Notizfarbe')}
>
{STICKY_NOTE_PALETTE.map((p) => (
<button
key={p.id}
type="button"
className={`${styles.canvasStickyNoteSwatch} ${
activeColorId === p.id ? styles.canvasStickyNoteSwatchActive : ''
}`}
style={{ backgroundColor: p.bg }}
aria-pressed={activeColorId === p.id}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
patchStickyNote(sn.id, { colorId: p.id });
}}
/>
))}
</div>
) : null}
</div>
{editingStickyId === sn.id ? (
<textarea
ref={stickyTextareaRef}
className={styles.canvasStickyNoteTextarea}
value={sn.text}
placeholder={t('Kommentar eingeben …')}
spellCheck
style={{
height: contentH,
minHeight: 0,
overflow: 'auto',
boxSizing: 'border-box',
backgroundColor: pal.textareaBg,
borderColor: pal.border,
}}
onChange={(e) => patchStickyNote(sn.id, { text: e.target.value })}
onBlur={() => setEditingStickyId(null)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setEditingStickyId(null);
}
}}
aria-label={t('Kommentar eingeben …')}
/>
) : (
<div
className={styles.canvasStickyNoteBody}
role="note"
tabIndex={0}
title={t('Doppelklick zum Bearbeiten')}
style={{
height: contentH,
minHeight: 0,
overflow: 'auto',
boxSizing: 'border-box',
backgroundColor: pal.bg,
borderColor: pal.border,
}}
onDoubleClick={(e) => {
e.stopPropagation();
setSelectedStickyId(sn.id);
setEditingStickyId(sn.id);
setStickyFocusSelectAll(true);
}}
>
{sn.text}
</div>
)}
{selectedStickyId === sn.id ? (
<button
type="button"
className={styles.canvasStickyNoteResize}
title={t('Notizgröße ändern')}
aria-label={t('Notizgröße ändern')}
onMouseDown={(e) => handleStickyResizeMouseDown(e, sn)}
/>
) : null}
</div>
);
})}
{selectionBox && (
<div
className={styles.selectionBox}
@ -1185,4 +1819,4 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
</div>
</div>
);
};
});

View file

@ -1,11 +1,12 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';