feat: added edit button bar und kommentar-funktion
This commit is contained in:
parent
c13489e232
commit
3a7a34a4f3
5 changed files with 1306 additions and 46 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue