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;
|
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. */
|
/* Closed <select> width must not follow the longest option label. */
|
||||||
.canvasHeaderWorkflowSelect {
|
.canvasHeaderWorkflowSelect {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
@ -501,6 +611,165 @@
|
||||||
background-repeat: repeat;
|
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 {
|
.canvasContent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,14 @@ import {
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
|
|
@ -62,6 +69,20 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
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[] =>
|
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
||||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
||||||
|
|
||||||
|
|
@ -106,6 +127,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
);
|
);
|
||||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
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 [executing, setExecuting] = useState(false);
|
||||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
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 hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
||||||
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [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(
|
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'));
|
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
||||||
setInvocations(inv);
|
setInvocations(inv);
|
||||||
if (!graph?.nodes?.length) {
|
if (!graph?.nodes?.length) {
|
||||||
|
|
@ -224,7 +315,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setCanvasNodes(synced.nodes);
|
setCanvasNodes(synced.nodes);
|
||||||
setCanvasConnections(synced.connections);
|
setCanvasConnections(synced.connections);
|
||||||
},
|
},
|
||||||
[nodeTypes, language, t]
|
[nodeTypes, language, t, pushCanvasHistoryPastFromCurrent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFromApiGraph = useCallback(
|
const handleFromApiGraph = useCallback(
|
||||||
|
|
@ -466,6 +557,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
loadWorkflows();
|
loadWorkflows();
|
||||||
}, [loadWorkflows]);
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanvasStickyNotes([]);
|
||||||
|
}, [currentWorkflowId]);
|
||||||
|
|
||||||
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
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 (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) return;
|
if (currentWorkflowId || initialWorkflowId) return;
|
||||||
if (canvasNodes.length > 0) 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,
|
loading,
|
||||||
nodeTypes.length,
|
nodeTypes.length,
|
||||||
|
|
@ -720,6 +817,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
'redmine.',
|
'redmine.',
|
||||||
].some((p) => selectedNode.type.startsWith(p));
|
].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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
||||||
|
|
@ -826,10 +953,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
verboseSchema={verboseSchema}
|
verboseSchema={verboseSchema}
|
||||||
onVerboseSchemaChange={setVerboseSchema}
|
onVerboseSchemaChange={setVerboseSchema}
|
||||||
|
canvasEdit={canvasHeaderEdit}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<FlowCanvas
|
<FlowCanvas
|
||||||
|
ref={flowCanvasRef}
|
||||||
nodes={canvasNodes}
|
nodes={canvasNodes}
|
||||||
connections={canvasConnections}
|
connections={canvasConnections}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
|
@ -841,6 +970,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||||
nodeErrors={nodeErrors}
|
nodeErrors={nodeErrors}
|
||||||
|
onViewportEditState={setCanvasViewportEdit}
|
||||||
|
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
|
||||||
|
onConnectionToolActiveChange={setCanvasConnectionToolActive}
|
||||||
|
stickyNotes={canvasStickyNotes}
|
||||||
|
onStickyNotesChange={setCanvasStickyNotes}
|
||||||
onExternalDrop={async (mime, payload) => {
|
onExternalDrop={async (mime, payload) => {
|
||||||
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
||||||
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ import {
|
||||||
FaChevronLeft,
|
FaChevronLeft,
|
||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
} from 'react-icons/fa';
|
} 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 type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -23,6 +33,29 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../../utils/userCache';
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
import { Button } from '../../UiComponents/Button';
|
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 {
|
interface CanvasHeaderProps {
|
||||||
workflows: Automation2Workflow[];
|
workflows: Automation2Workflow[];
|
||||||
currentWorkflowId: string | null;
|
currentWorkflowId: string | null;
|
||||||
|
|
@ -56,6 +89,7 @@ interface CanvasHeaderProps {
|
||||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||||
verboseSchema?: boolean;
|
verboseSchema?: boolean;
|
||||||
onVerboseSchemaChange?: (next: boolean) => void;
|
onVerboseSchemaChange?: (next: boolean) => void;
|
||||||
|
canvasEdit?: CanvasHeaderCanvasEditProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||||
|
|
@ -97,6 +131,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
verboseSchema,
|
verboseSchema,
|
||||||
onVerboseSchemaChange,
|
onVerboseSchemaChange,
|
||||||
|
canvasEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||||
|
|
@ -111,10 +146,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const _handleClickOutside = (e: MouseEvent) => {
|
const _handleClickOutside = (e: MouseEvent) => {
|
||||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
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 (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);
|
document.addEventListener('mousedown', _handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||||
|
|
@ -148,6 +193,23 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
? styles.canvasHeaderExecuteBannerPaused
|
? styles.canvasHeaderExecuteBannerPaused
|
||||||
: styles.canvasHeaderExecuteBannerError;
|
: 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 (
|
return (
|
||||||
<div className={styles.canvasHeader}>
|
<div className={styles.canvasHeader}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -311,6 +373,166 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{currentWorkflowId && versions && versions.length > 0 && (
|
||||||
<div className={styles.canvasHeaderVersionRow}>
|
<div className={styles.canvasHeaderVersionRow}>
|
||||||
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
<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.
|
* 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 type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -34,8 +43,101 @@ export interface CanvasConnection {
|
||||||
targetHandle: number;
|
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_WIDTH = 200;
|
||||||
const NODE_HEIGHT = 72;
|
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_SIZE = 12;
|
||||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||||
const LAYOUT_V_GAP = 80;
|
const LAYOUT_V_GAP = 80;
|
||||||
|
|
@ -285,6 +387,13 @@ interface FlowCanvasProps {
|
||||||
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
||||||
* Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
|
* Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
|
||||||
onExternalDrop?: (mime: string, payload: unknown) => Promise<boolean> | boolean;
|
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> = {
|
const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||||
|
|
@ -294,21 +403,36 @@ const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||||
skipped: '#6c757d',
|
skipped: '#6c757d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function FlowCanvas(
|
||||||
connections,
|
{
|
||||||
nodeTypes,
|
nodes,
|
||||||
onNodesChange,
|
connections,
|
||||||
onConnectionsChange,
|
nodeTypes,
|
||||||
onDropNodeType,
|
onNodesChange,
|
||||||
getLabel,
|
onConnectionsChange,
|
||||||
getCategoryIcon,
|
onDropNodeType,
|
||||||
onSelectionChange,
|
getLabel,
|
||||||
highlightedNodeIds,
|
getCategoryIcon,
|
||||||
nodeErrors,
|
onSelectionChange,
|
||||||
onExternalDrop,
|
highlightedNodeIds,
|
||||||
}) => {
|
nodeErrors,
|
||||||
|
onExternalDrop,
|
||||||
|
onViewportEditState,
|
||||||
|
onHistoryCheckpoint,
|
||||||
|
onConnectionToolActiveChange,
|
||||||
|
stickyNotes = [],
|
||||||
|
onStickyNotesChange,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||||
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
||||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(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 [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
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<{
|
const [panning, setPanning] = useState<{
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
|
@ -343,6 +474,30 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
startPanY: number;
|
startPanY: number;
|
||||||
} | null>(null);
|
} | 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 nodeTypeMap = useMemo(() => {
|
||||||
const m: Record<string, NodeType> = {};
|
const m: Record<string, NodeType> = {};
|
||||||
nodeTypes.forEach((nt) => {
|
nodeTypes.forEach((nt) => {
|
||||||
|
|
@ -351,6 +506,166 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
return m;
|
return m;
|
||||||
}, [nodeTypes]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (onSelectionChange) {
|
if (onSelectionChange) {
|
||||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||||
|
|
@ -362,13 +677,15 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedConnectionId(connId);
|
setSelectedConnectionId(connId);
|
||||||
setSelectedNodeIds(new Set());
|
setSelectedNodeIds(new Set());
|
||||||
|
setSelectedStickyId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteConnection = useCallback(() => {
|
const handleDeleteConnection = useCallback(() => {
|
||||||
if (!selectedConnectionId) return;
|
if (!selectedConnectionId) return;
|
||||||
onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
|
onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
}, [selectedConnectionId, connections, onConnectionsChange]);
|
emitHistoryCheckpoint();
|
||||||
|
}, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]);
|
||||||
|
|
||||||
const getHandlePosition = useCallback(
|
const getHandlePosition = useCallback(
|
||||||
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
|
(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 x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
|
||||||
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
||||||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||||
|
emitHistoryCheckpoint();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
[onDropNodeType, onExternalDrop, panOffset, zoom]
|
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHandleMouseDown = useCallback(
|
const handleHandleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => {
|
(e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isOutput) return;
|
if (!isOutput) return;
|
||||||
|
if (connectionToolActive) return;
|
||||||
const node = nodes.find((n) => n.id === nodeId);
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const pos = getHandlePosition(node, handleIndex);
|
const pos = getHandlePosition(node, handleIndex);
|
||||||
setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y });
|
setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y });
|
||||||
setDragPos({ x: e.clientX, y: e.clientY });
|
setDragPos({ x: e.clientX, y: e.clientY });
|
||||||
},
|
},
|
||||||
[nodes, getHandlePosition]
|
[nodes, getHandlePosition, connectionToolActive]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHandleMouseUp = useCallback(
|
const handleHandleMouseUp = useCallback(
|
||||||
|
|
@ -498,42 +817,63 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
|
emitHistoryCheckpoint();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveSource =
|
||||||
|
connectionToolActive && pendingConnClickSource
|
||||||
|
? pendingConnClickSource
|
||||||
|
: connectingFrom
|
||||||
|
? { nodeId: connectingFrom.nodeId, handleIndex: connectingFrom.handleIndex }
|
||||||
|
: null;
|
||||||
|
|
||||||
const allowLoopSelfFeedback =
|
const allowLoopSelfFeedback =
|
||||||
|
!!effectiveSource &&
|
||||||
targetNode.type === 'flow.loop' &&
|
targetNode.type === 'flow.loop' &&
|
||||||
targetHandleIndex === 0 &&
|
targetHandleIndex === 0 &&
|
||||||
connectingFrom.handleIndex >= targetNode.inputs;
|
effectiveSource.handleIndex >= targetNode.inputs;
|
||||||
if (
|
if (
|
||||||
!connectingFrom ||
|
!effectiveSource ||
|
||||||
(connectingFrom.nodeId === targetNodeId && !allowLoopSelfFeedback)
|
(effectiveSource.nodeId === targetNodeId && !allowLoopSelfFeedback)
|
||||||
) {
|
) {
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = `${targetNodeId}-${targetHandleIndex}`;
|
const key = `${targetNodeId}-${targetHandleIndex}`;
|
||||||
if (getUsedTargetHandles.has(key) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)) {
|
if (
|
||||||
|
getUsedTargetHandles.has(key) &&
|
||||||
|
!allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
|
||||||
|
) {
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
|
setPendingConnClickSource(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newConn: CanvasConnection = {
|
const newConn: CanvasConnection = {
|
||||||
id: `c_${Date.now()}`,
|
id: `c_${Date.now()}`,
|
||||||
sourceId: connectingFrom.nodeId,
|
sourceId: effectiveSource.nodeId,
|
||||||
sourceHandle: connectingFrom.handleIndex,
|
sourceHandle: effectiveSource.handleIndex,
|
||||||
targetId: targetNodeId,
|
targetId: targetNodeId,
|
||||||
targetHandle: targetHandleIndex,
|
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);
|
const tgtNode = nodes.find((n) => n.id === targetNodeId);
|
||||||
if (srcNode && tgtNode) {
|
if (srcNode && tgtNode) {
|
||||||
const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs
|
const sourceOutputIdx =
|
||||||
? connectingFrom.handleIndex - srcNode.inputs : 0;
|
effectiveSource.handleIndex >= srcNode.inputs
|
||||||
const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes);
|
? effectiveSource.handleIndex - srcNode.inputs
|
||||||
|
: 0;
|
||||||
|
const compat = _checkConnectionCompatibility(
|
||||||
|
srcNode,
|
||||||
|
sourceOutputIdx,
|
||||||
|
tgtNode,
|
||||||
|
targetHandleIndex,
|
||||||
|
nodeTypes
|
||||||
|
);
|
||||||
if (compat === 'warning') {
|
if (compat === 'warning') {
|
||||||
setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
|
setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
|
||||||
}
|
}
|
||||||
|
|
@ -542,8 +882,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
onConnectionsChange([...connections, newConn]);
|
onConnectionsChange([...connections, newConn]);
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
|
setPendingConnClickSource(null);
|
||||||
|
emitHistoryCheckpoint();
|
||||||
},
|
},
|
||||||
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId]
|
[
|
||||||
|
connectingFrom,
|
||||||
|
connectionToolActive,
|
||||||
|
pendingConnClickSource,
|
||||||
|
connections,
|
||||||
|
nodes,
|
||||||
|
getUsedTargetHandles,
|
||||||
|
onConnectionsChange,
|
||||||
|
selectedConnectionId,
|
||||||
|
nodeTypes,
|
||||||
|
emitHistoryCheckpoint,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', onUp);
|
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 });
|
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -641,9 +1077,11 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
const handleCanvasMouseDown = useCallback(
|
const handleCanvasMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
|
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) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setSelectedStickyId(null);
|
||||||
const pt = clientToCanvas(e.clientX, e.clientY);
|
const pt = clientToCanvas(e.clientX, e.clientY);
|
||||||
setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y });
|
setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y });
|
||||||
setSelectedNodeIds(new Set());
|
setSelectedNodeIds(new Set());
|
||||||
|
|
@ -663,7 +1101,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
const handleWheel = useCallback((e: WheelEvent) => {
|
const handleWheel = useCallback((e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
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(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -722,6 +1162,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
if (overlaps) ids.add(n.id);
|
if (overlaps) ids.add(n.id);
|
||||||
});
|
});
|
||||||
setSelectedNodeIds(ids);
|
setSelectedNodeIds(ids);
|
||||||
|
setSelectedStickyId(null);
|
||||||
};
|
};
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
|
|
@ -755,7 +1196,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
setSelectedNodeIds(new Set());
|
setSelectedNodeIds(new Set());
|
||||||
setEditingNodeId(null);
|
setEditingNodeId(null);
|
||||||
setEditingField(null);
|
setEditingField(null);
|
||||||
}, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]);
|
emitHistoryCheckpoint();
|
||||||
|
}, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange, emitHistoryCheckpoint]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -765,6 +1207,11 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
|
setPendingConnClickSource(null);
|
||||||
|
setEditingStickyId(null);
|
||||||
|
setStickyDragState(null);
|
||||||
|
setStickyResizeState(null);
|
||||||
|
setSelectedStickyId(null);
|
||||||
}
|
}
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (selectedConnectionId) {
|
if (selectedConnectionId) {
|
||||||
|
|
@ -789,10 +1236,31 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
[nodes, onNodesChange]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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={{
|
style={{
|
||||||
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
||||||
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
||||||
|
|
@ -810,6 +1278,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
|
setPendingConnClickSource(null);
|
||||||
|
setEditingStickyId(null);
|
||||||
|
setSelectedStickyId(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
|
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
|
||||||
|
|
@ -840,6 +1311,24 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
{t('Anderen Eingang anklicken zum Umleiten')}
|
{t('Anderen Eingang anklicken zum Umleiten')}
|
||||||
</div>
|
</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
|
<div
|
||||||
className={styles.canvasContent}
|
className={styles.canvasContent}
|
||||||
style={{
|
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 });
|
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
||||||
|
|
||||||
const wireSourceNode =
|
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 isSelected = selectedNodeIds.has(node.id);
|
||||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||||
|
|
@ -990,6 +1486,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
|
setSelectedStickyId(null);
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
setSelectedNodeIds((prev) => {
|
setSelectedNodeIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -1051,17 +1548,20 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
const isCurrentTargetOfSelection =
|
const isCurrentTargetOfSelection =
|
||||||
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
|
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
|
||||||
let wireTargetOk = true;
|
let wireTargetOk = true;
|
||||||
if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) {
|
if (!isOutput && wireSourceNode && wireSourceHandleIdx >= 0) {
|
||||||
const sourceOutputIdx =
|
const sourceOutputIdx =
|
||||||
connectingFrom.handleIndex >= wireSourceNode.inputs
|
wireSourceHandleIdx >= wireSourceNode.inputs
|
||||||
? connectingFrom.handleIndex - wireSourceNode.inputs
|
? wireSourceHandleIdx - wireSourceNode.inputs
|
||||||
: 0;
|
: 0;
|
||||||
wireTargetOk =
|
wireTargetOk =
|
||||||
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
|
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
|
||||||
}
|
}
|
||||||
const canConnect =
|
const canConnect =
|
||||||
isOutput ||
|
isOutput ||
|
||||||
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
|
(!used &&
|
||||||
|
!!wireSourceNode &&
|
||||||
|
wireSourceHandleIdx >= 0 &&
|
||||||
|
(!selectedConnectionId ? wireTargetOk : true)) ||
|
||||||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
||||||
const nt = nodeTypeMap[node.type];
|
const nt = nodeTypeMap[node.type];
|
||||||
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
|
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 }}
|
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||||
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
||||||
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
|
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}
|
title={outputLabel}
|
||||||
/>
|
/>
|
||||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||||
|
|
@ -1093,6 +1600,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||||
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
||||||
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
|
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={
|
title={
|
||||||
outputLabel ??
|
outputLabel ??
|
||||||
(selectedConnectionId && !isOutput
|
(selectedConnectionId && !isOutput
|
||||||
|
|
@ -1166,6 +1680,126 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
</div>
|
</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 && (
|
{selectionBox && (
|
||||||
<div
|
<div
|
||||||
className={styles.selectionBox}
|
className={styles.selectionBox}
|
||||||
|
|
@ -1185,4 +1819,4 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||||
export { FlowCanvas } 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 } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
export { NodeSidebar } from './editor/NodeSidebar';
|
export { NodeSidebar } from './editor/NodeSidebar';
|
||||||
export { NodeListItem } from './editor/NodeListItem';
|
export { NodeListItem } from './editor/NodeListItem';
|
||||||
export { CanvasHeader } from './editor/CanvasHeader';
|
export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
|
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
|
||||||
export * from './nodes/shared/utils';
|
export * from './nodes/shared/utils';
|
||||||
export * from './nodes/shared/constants';
|
export * from './nodes/shared/constants';
|
||||||
export * from './nodes/shared/graphUtils';
|
export * from './nodes/shared/graphUtils';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue