From f35e22c7f4efd8c4007030e4a9dbd3899d6e51de Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 23 May 2026 23:54:30 +0200 Subject: [PATCH] Sync: full codebase from GitHub frontend_nyla main Co-authored-by: Cursor --- ...implement_rbac_roles_page_8dd9fac6.plan.md | 144 -- .forgejo/workflows/deploy-int.yml | 50 - .forgejo/workflows/deploy.yml | 51 - config/env-porta-int.env | 6 - config/env-porta.env | 6 - src/App.tsx | 4 +- src/api/connectionApi.ts | 2 +- src/api/workflowApi.ts | 122 +- .../AddConnectionWizard.tsx | 56 +- .../context/Automation2DataFlowContext.tsx | 9 +- .../editor/Automation2FlowEditor.module.css | 466 +++++- .../editor/Automation2FlowEditor.tsx | 382 +++-- .../FlowEditor/editor/CanvasHeader.test.tsx | 99 -- .../FlowEditor/editor/CanvasHeader.tsx | 699 ++++---- .../FlowEditor/editor/FlowCanvas.tsx | 1443 +++++++++++++++-- .../FlowEditor/editor/NodeConfigPanel.tsx | 412 ++++- .../FlowEditor/editor/NodeSidebar.tsx | 4 +- .../editor/WorkflowConfigurationModal.tsx | 123 -- src/components/FlowEditor/index.ts | 5 +- .../nodes/form/FormFieldOptionsEditor.tsx | 104 ++ .../FlowEditor/nodes/form/FormNodeConfig.tsx | 48 +- .../nodes/form/formFieldOptionsUtils.ts | 40 + src/components/FlowEditor/nodes/form/index.ts | 7 + .../frontendTypeRenderers/CaseListEditor.tsx | 274 ++++ .../frontendTypeRenderers/ConditionEditor.tsx | 223 +++ .../ContextAssignmentsEditor.tsx | 372 +++++ .../UserFileFolderPicker.tsx | 267 +++ .../nodes/frontendTypeRenderers/index.tsx | 359 ++-- .../nodes/ifElse/IfElseNodeConfig.tsx | 154 -- .../FlowEditor/nodes/ifElse/index.ts | 3 +- .../FlowEditor/nodes/runtime/scheduleCron.ts | 296 ---- .../nodes/runtime/workflowStartSync.ts | 222 --- .../nodes/shared/DataPicker.test.tsx | 194 --- .../FlowEditor/nodes/shared/DataPicker.tsx | 234 ++- .../shared/RequiredAttributePicker.test.tsx | 243 --- .../FlowEditor/nodes/shared/categoryIcons.tsx | 2 +- .../FlowEditor/nodes/shared/constants.ts | 2 +- .../FlowEditor/nodes/shared/graphUtils.ts | 35 +- .../nodes/shared/paramValidation.test.ts | 318 ---- .../FlowEditor/nodes/shared/scopeHelpers.ts | 55 - .../nodes/start/FormStartNodeConfig.tsx | 54 +- .../nodes/start/ScheduleStartNodeConfig.tsx | 441 +---- .../nodes/switch/SwitchNodeConfig.tsx | 250 --- .../FlowEditor/nodes/switch/index.ts | 3 +- .../WorkflowRuntimeFormFields.tsx | 236 +++ .../FormGeneratorTree.module.css | 16 + .../FormGeneratorTree/FormGeneratorTree.tsx | 412 +++-- .../providers/FolderFileProvider.tsx | 90 +- .../FormGenerator/FormGeneratorTree/types.ts | 21 + .../SchedulePlanner.module.css | 145 ++ .../SchedulePlanner/SchedulePlanner.tsx | 526 ++++++ src/components/SchedulePlanner/index.ts | 1 + .../AccordionList/AccordionList.module.css | 114 ++ .../AccordionList/AccordionList.tsx | 98 ++ .../UiComponents/AccordionList/index.ts | 2 + src/components/UiComponents/Button/Button.tsx | 7 +- src/components/UiComponents/index.ts | 1 + .../DataSourceSettingsModal.tsx | 4 +- src/config/keepAliveRoutes.tsx | 45 + src/config/pageRegistry.tsx | 1 - src/core/PageManager/SidebarProvider.tsx | 473 ------ src/core/PageManager/data/index.ts | 27 - src/core/PageManager/data/pages/index.ts | 15 - .../data/pages/realestate/index.ts | 3 - .../data/pages/trustee/position-documents.ts | 241 --- src/core/PageManager/pageInterface.ts | 44 - src/layouts/MainLayout.tsx | 139 +- src/pages/AutomationsDashboardPage.tsx | 23 +- src/pages/FeatureView.tsx | 15 +- src/pages/admin/AdminDatabaseHealthPage.tsx | 697 +++++++- src/pages/admin/AdminLanguagesKeepAlive.tsx | 35 - .../views/commcoach/CommcoachKeepAlive.tsx | 55 - .../Automation2WorkflowsTasks.module.css | 47 + .../GraphicalEditorKeepAlive.test.tsx | 96 -- .../GraphicalEditorKeepAlive.tsx | 70 - .../GraphicalEditorWorkflowsPage.tsx | 441 ----- .../GraphicalEditorWorkflowsTasksPage.tsx | 357 ++-- .../views/workspace/WorkspaceKeepAlive.tsx | 57 - src/test/setup.ts | 45 +- src/test/smoke.test.ts | 23 - src/types/keepAlive.types.ts | 34 + src/types/mandate.ts | 1 - src/utils/scheduleCron.ts | 531 ++++++ 83 files changed, 7852 insertions(+), 5619 deletions(-) delete mode 100644 .cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md delete mode 100644 .forgejo/workflows/deploy-int.yml delete mode 100644 .forgejo/workflows/deploy.yml delete mode 100644 config/env-porta-int.env delete mode 100644 config/env-porta.env delete mode 100644 src/components/FlowEditor/editor/CanvasHeader.test.tsx delete mode 100644 src/components/FlowEditor/editor/WorkflowConfigurationModal.tsx create mode 100644 src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx create mode 100644 src/components/FlowEditor/nodes/form/formFieldOptionsUtils.ts create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/ContextAssignmentsEditor.tsx create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/UserFileFolderPicker.tsx delete mode 100644 src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx delete mode 100644 src/components/FlowEditor/nodes/runtime/scheduleCron.ts delete mode 100644 src/components/FlowEditor/nodes/runtime/workflowStartSync.ts delete mode 100644 src/components/FlowEditor/nodes/shared/DataPicker.test.tsx delete mode 100644 src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx delete mode 100644 src/components/FlowEditor/nodes/shared/paramValidation.test.ts delete mode 100644 src/components/FlowEditor/nodes/shared/scopeHelpers.ts delete mode 100644 src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx create mode 100644 src/components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields.tsx create mode 100644 src/components/SchedulePlanner/SchedulePlanner.module.css create mode 100644 src/components/SchedulePlanner/SchedulePlanner.tsx create mode 100644 src/components/SchedulePlanner/index.ts create mode 100644 src/components/UiComponents/AccordionList/AccordionList.module.css create mode 100644 src/components/UiComponents/AccordionList/AccordionList.tsx create mode 100644 src/components/UiComponents/AccordionList/index.ts create mode 100644 src/config/keepAliveRoutes.tsx delete mode 100644 src/core/PageManager/SidebarProvider.tsx delete mode 100644 src/core/PageManager/data/index.ts delete mode 100644 src/core/PageManager/data/pages/index.ts delete mode 100644 src/core/PageManager/data/pages/realestate/index.ts delete mode 100644 src/core/PageManager/data/pages/trustee/position-documents.ts delete mode 100644 src/core/PageManager/pageInterface.ts delete mode 100644 src/pages/admin/AdminLanguagesKeepAlive.tsx delete mode 100644 src/pages/views/commcoach/CommcoachKeepAlive.tsx delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx delete mode 100644 src/pages/views/workspace/WorkspaceKeepAlive.tsx delete mode 100644 src/test/smoke.test.ts create mode 100644 src/types/keepAlive.types.ts create mode 100644 src/utils/scheduleCron.ts diff --git a/.cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md b/.cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md deleted file mode 100644 index 4828679..0000000 --- a/.cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md +++ /dev/null @@ -1,144 +0,0 @@ -# Implement RBAC Roles Page - -## Overview -Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French). - -## Files to Create/Modify - -### 1. Create API File: `frontend_nyla/src/api/roleApi.ts` - - Follow the pattern from `mandateApi.ts` - - Implement all required endpoints: - - `fetchRoles()` - GET /api/rbac/roles (with pagination support) - - `fetchRoleById()` - GET /api/rbac/roles/{roleId} - - `fetchRoleOptions()` - GET /api/rbac/roles/options - - `createRole()` - POST /api/rbac/roles - - `updateRole()` - PUT /api/rbac/roles/{roleId} - - `deleteRole()` - DELETE /api/rbac/roles/{roleId} - - Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse` - -### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts` - - Follow the exact pattern from `useAdminMandates.ts` - - Create two hooks: - - `useRbacRoles()` - Main hook for data fetching and state management - - Fetch roles with pagination support - - Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')` - - Fetch permissions using `checkPermission('DATA', 'Role')` - - Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities - - Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities - - Implement `ensureAttributesLoaded()` for EditActionButton - - Implement optimistic updates (`removeOptimistically`, `updateOptimistically`) - - Return pagination info, attributes, permissions, and all required functions - - `useRbacRoleOperations()` - Operations hook for CRUD - - `handleRoleDelete()` - Delete with loading state tracking - - `handleRoleCreate()` - Create with error handling - - `handleRoleUpdate()` - Update with error handling - - Track loading states in Sets (deletingRoles, editingRoles, creatingRole) - - Return error states (deleteError, createError, updateError) - -### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts` - - Follow the exact structure from `mandates.ts` - - Import `FaPlus` from `react-icons/fa` for the create button icon - - Create `createRbacRolesHook()` factory function that: - - Uses `useRbacRoles()` and `useRbacRoleOperations()` - - Converts attributes to columns using `attributesToColumns()` helper - - Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks - - Returns all required data for FormGeneratorTable - - Update `rbacRolePageData`: - - Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern): - ```typescript - headerButtons: [ - { - id: 'add-role', - label: 'admin.rbac-role.new_button', - variant: 'primary', - size: 'md', - icon: FaPlus, - formConfig: { - fields: [], // Empty array - fields will be generated dynamically from attributes - popupTitle: 'admin.rbac-role.modal.create.title', - popupSize: 'medium', - createOperationName: 'handleRoleCreate', - successMessage: 'admin.rbac-role.create.success', - errorMessage: 'admin.rbac-role.create.error' - } - } - ] - ``` - - Add table content section with: - - `hookFactory: createRbacRolesHook` - - Action buttons: edit and delete (following mandates pattern) - - Configure edit button with `fetchItemFunctionName: 'fetchRoleById'` - - Configure delete button with proper operation names - - Add permission-based disabled logic - - Keep existing privilege checker (sysadmin only) - -### 4. Update Translations: All three locale files - - **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756: - - `'admin.rbac-role.new_button': 'Rolle hinzufügen'` - - `'admin.rbac-role.action.edit': 'Bearbeiten'` - - `'admin.rbac-role.action.delete': 'Löschen'` - - `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'` - - `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'` - - `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'` - - - **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756: - - `'admin.rbac-role.new_button': 'Add Role'` - - `'admin.rbac-role.action.edit': 'Edit'` - - `'admin.rbac-role.action.delete': 'Delete'` - - `'admin.rbac-role.modal.create.title': 'Create New Role'` - - `'admin.rbac-role.create.success': 'Role created successfully'` - - `'admin.rbac-role.create.error': 'Error creating role'` - - - **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756: - - `'admin.rbac-role.new_button': 'Ajouter un rôle'` - - `'admin.rbac-role.action.edit': 'Modifier'` - - `'admin.rbac-role.action.delete': 'Supprimer'` - - `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'` - - `'admin.rbac-role.create.success': 'Rôle créé avec succès'` - - `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'` - -## Implementation Details - -### API File Structure -- Use `ApiRequestFunction` type from `useApi` -- Support pagination parameters (page, pageSize, sort, filters, search) -- Handle both paginated and non-paginated responses -- Use `/api/rbac/roles` as base URL -- Use `/api/attributes/Role` for attributes endpoint - -### Hook Pattern -- Use `useApiRequest` hook for API calls -- Use `usePermissions` hook for permission checking -- Use `getUserDataCache()` to check authentication before fetching -- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`: - - `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()` -- Filter out non-editable fields (id, readonly fields, etc.) -- Handle options arrays and option references - -### Page Configuration Pattern -- Use `attributesToColumns()` helper to convert attributes to column config -- Disable filtering for date/timestamp fields using `isDateTimeType()` -- Configure action buttons with proper field mappings and operation names -- Use permission-based disabled logic for buttons -- Set `entityType: 'Role'` for EditActionButton -- Add header button using CreateButton component pattern (via formConfig in headerButtons) - -## Key Dependencies -- `useApiRequest` from `hooks/useApi` -- `usePermissions` from `hooks/usePermissions` -- `fetchAttributes` from `api/attributesApi` -- `attributeTypeMapper` utilities from `utils/attributeTypeMapper` -- `FormGeneratorTable` component -- `EditActionButton` and `DeleteActionButton` components -- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig) -- `FaPlus` icon from `react-icons/fa` - -## Testing Considerations -- Verify all API endpoints are called correctly -- Ensure attributes are fetched from `/api/attributes/Role` -- Verify permission checks work correctly -- Test create, edit, delete operations -- Verify optimistic updates work -- Check that date/timestamp fields are not filterable -- Verify CreateButton appears in header and opens create modal -- Verify translations work in all three languages diff --git a/.forgejo/workflows/deploy-int.yml b/.forgejo/workflows/deploy-int.yml deleted file mode 100644 index 2a74160..0000000 --- a/.forgejo/workflows/deploy-int.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Deploy Nyla Frontend INT - -on: - push: - branches: - - int - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Copy environment file - run: cp config/env-poweron-nyla-int.env .env - - - name: Install dependencies - run: npm ci - - - name: Build React app - run: npm run build - - - name: Deploy to Infomaniak VM - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - run: | - apt-get update && apt-get install -y rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - echo "StrictHostKeyChecking=no" >> ~/.ssh/config - echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config - rsync -az --delete \ - -e "ssh -i ~/.ssh/deploy_key" \ - ./dist/ \ - ubuntu@porta-int.poweron.swiss:/srv/nyla/current/dist/ - ssh -i ~/.ssh/deploy_key ubuntu@porta-int.poweron.swiss \ - "sudo systemctl reload nginx" \ No newline at end of file diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml deleted file mode 100644 index 1f932c8..0000000 --- a/.forgejo/workflows/deploy.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Deploy Nyla Frontend -on: - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Copy environment file - run: cp config/env-porta.env .env - - - name: Install dependencies - run: npm ci - - - name: Build React app - run: npm run build - - - name: Deploy to Infomaniak VM - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - run: | - apt-get update && apt-get install -y rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - echo "StrictHostKeyChecking=no" >> ~/.ssh/config - echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config - - rsync -az --delete \ - -e "ssh -i ~/.ssh/deploy_key" \ - ./dist/ \ - ubuntu@porta.poweron.swiss:/srv/nyla/current/dist/ - - ssh -i ~/.ssh/deploy_key ubuntu@porta.poweron.swiss \ - "sudo systemctl reload nginx" \ No newline at end of file diff --git a/config/env-porta-int.env b/config/env-porta-int.env deleted file mode 100644 index 655b024..0000000 --- a/config/env-porta-int.env +++ /dev/null @@ -1,6 +0,0 @@ -# Environment: porta (Forgejo deploy → porta.poweron.swiss) -# Consumed by: Vite build + SPA runtime (getApiBaseUrl / getAppName) -# Auth and secrets live on the gateway — never in frontend env. - -VITE_API_BASE_URL=https://api-int.poweron.swiss -VITE_APP_NAME=PORTA diff --git a/config/env-porta.env b/config/env-porta.env deleted file mode 100644 index c472bba..0000000 --- a/config/env-porta.env +++ /dev/null @@ -1,6 +0,0 @@ -# Environment: porta (Forgejo deploy → porta.poweron.swiss) -# Consumed by: Vite build + SPA runtime (getApiBaseUrl / getAppName) -# Auth and secrets live on the gateway — never in frontend env. - -VITE_API_BASE_URL=https://api.poweron.swiss -VITE_APP_NAME=PORTA diff --git a/src/App.tsx b/src/App.tsx index 4c1c1ee..6b55e81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -174,8 +174,8 @@ function App() { {/* Workspace + Automation2 Editor */} } /> - {/* Automation2 Workflows & Tasks */} - } /> + {/* Automation2: legacy workflows URL → editor */} + } /> } /> {/* Teams Bot Feature Views */} diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index e5db443..d482948 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -341,7 +341,7 @@ export interface DataSourceSettings { export interface CostEstimate { estimatedTokens: number; - estimatedUsd: number; + estimatedChf: number; basis: { kind: string; limits: Record; diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7a03b4e..c4144d4 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -32,6 +32,10 @@ export interface PortField { enumValues?: string[] | null; /** When true, surface at the top of the DataPicker as the most common/recommended pick. */ recommended?: boolean; + /** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */ + pickerLabel?: string | null; + /** Backend: segment for one list element (between List field and nested field). */ + pickerItemLabel?: string | null; } export interface PortSchema { @@ -39,6 +43,20 @@ export interface PortSchema { fields: PortField[]; } +/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */ +export interface DataPickOption { + path: (string | number)[]; + pickerLabel: string; + detail?: string; + recommended?: boolean; + iterable?: boolean; + /** For display and optional strict compatibility (e.g. str, Any). */ + type?: string; +} + +/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */ +export type OutputPickHint = DataPickOption; + export interface InputPortDef { accepts: string[]; } @@ -53,6 +71,11 @@ export interface OutputPortDef { schema: string | GraphDefinedSchemaRef; dynamic?: boolean; deriveFrom?: string; + /** + * When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion). + * Authoritative, like `parameters` for node configuration. + */ + dataPickOptions?: DataPickOption[]; } export interface NodeType { @@ -76,7 +99,6 @@ export interface NodeType { action?: string; }; } - export interface NodeTypeCategory { id: string; label: Record | string; @@ -94,10 +116,19 @@ export interface FormFieldType { portType: string; } +export interface ConditionOperatorDef { + id: string; + label: string; + labelKey?: string; + needsValue: boolean; + valueInput?: { kind: string; options?: string[] }; +} + export interface NodeTypesResponse { nodeTypes: NodeType[]; categories: NodeTypeCategory[]; portTypeCatalog?: Record; + conditionOperatorCatalog?: Record; systemVariables?: Record; formFieldTypes?: FormFieldType[]; } @@ -288,15 +319,17 @@ export async function fetchNodeTypes( const nodeTypes = data?.nodeTypes ?? []; const categories = data?.categories ?? []; const portTypeCatalog = data?.portTypeCatalog ?? undefined; + const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined; const systemVariables = data?.systemVariables ?? undefined; const formFieldTypes = data?.formFieldTypes ?? undefined; console.log( `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + + `${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` + `${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes` ); - return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes }; + return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes }; } export interface UpstreamPathEntry { @@ -306,6 +339,39 @@ export interface UpstreamPathEntry { type: string; label: string; scopeOrigin: 'data' | 'loop' | 'system'; + valueKind?: string; +} + +export interface ConditionMetaResponse { + valueKind: string; + operators: ConditionOperatorDef[]; +} + +export interface ConditionMetaRequest { + graph: Automation2Graph; + nodeId?: string; + ref: { type: 'ref'; nodeId: string; path: (string | number)[] }; +} + +/** + * POST /api/workflows/{instanceId}/condition-meta — operators for a DataRef (If/Else). + */ +export async function fetchConditionMeta( + request: ApiRequestFunction, + instanceId: string, + body: ConditionMetaRequest, + language = 'de' +): Promise { + const data = await request({ + url: `/api/workflows/${instanceId}/condition-meta`, + method: 'post', + params: { language }, + data: body, + }); + return { + valueKind: String(data?.valueKind ?? 'unknown'), + operators: (data?.operators ?? []) as ConditionOperatorDef[], + }; } /** @@ -325,6 +391,41 @@ export async function postUpstreamPaths( return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; } +/** Scope-aware data sources for the DataPicker — all loop-scope logic lives on the backend. */ +export interface GraphDataSources { + /** Ancestor node IDs that are valid sources (loop body nodes excluded when on Done branch). */ + availableSourceIds: string[]; + /** Maps nodeId → output port index to use instead of 0 (e.g. loop node on Done branch → 1). */ + portIndexOverrides: Record; + /** IDs of flow.loop nodes whose body the current node is inside (show currentItem etc.). */ + loopBodyContextIds: string[]; +} + +/** + * POST /api/workflows/{instanceId}/graph-data-sources + * + * Returns scope-aware source list so the DataPicker needs zero graph-traversal logic. + * The graph connections must use { source, target, sourceOutput?, targetInput? } format. + */ +export async function fetchGraphDataSources( + request: ApiRequestFunction, + instanceId: string, + nodeId: string, + nodes: Array<{ id: string; type?: string }>, + connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>, +): Promise { + const data = await request({ + url: `/api/workflows/${instanceId}/graph-data-sources`, + method: 'post', + data: { nodeId, graph: { nodes, connections } }, + }); + return { + availableSourceIds: data?.availableSourceIds ?? [], + portIndexOverrides: data?.portIndexOverrides ?? {}, + loopBodyContextIds: data?.loopBodyContextIds ?? [], + }; +} + /** GET saved workflow graph variant of upstream-paths (requires workflowId). */ export async function getUpstreamPathsSaved( request: ApiRequestFunction, @@ -670,6 +771,23 @@ export async function completeTask( }); } +/** Cancel a pending human task and stop its workflow run (Graphical Editor). */ +export async function cancelPendingTaskStopRun( + request: ApiRequestFunction, + instanceId: string, + taskId: string +): Promise<{ success: boolean; runId?: string | null; taskId: string }> { + const data = await request({ + url: `/api/workflows/${instanceId}/tasks/${taskId}/cancel`, + method: 'post', + }); + return { + success: Boolean(data?.success), + runId: data?.runId, + taskId: data?.taskId ?? taskId, + }; +} + // ------------------------------------------------------------------------- // Versions (AutoVersion Lifecycle) // ------------------------------------------------------------------------- diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx index 34c5cb0..92ec51a 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.tsx +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -11,6 +11,7 @@ import React, { useState } from 'react'; import { Modal } from '../UiComponents/Modal/Modal'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa'; +import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './AddConnectionWizard.module.css'; // --------------------------------------------------------------------------- @@ -74,6 +75,8 @@ export const AddConnectionWizard: React.FC = ({ onMsftAdminConsent, isConnecting = false, }) => { + const { t } = useLanguage(); + const [state, setState] = useState({ currentStep: 'connector', connector: null, @@ -125,7 +128,7 @@ export const AddConnectionWizard: React.FC = ({ }; return ( - + {/* Stepper */}
{steps.map((s, i) => ( @@ -146,8 +149,8 @@ export const AddConnectionWizard: React.FC = ({ {/* ---- Step: Connector ---- */} {state.currentStep === 'connector' && (
-

Anbieter wählen

-

Welchen Dienst möchtest du verbinden?

+

{t('Anbieter wählen')}

+

{t('Welchen Dienst möchtest du verbinden?')}

{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
- +
)} @@ -196,13 +197,12 @@ export const AddConnectionWizard: React.FC = ({
-

Organisations-Zustimmung (optional)

+

{t('Organisations-Zustimmung (optional)')}

- Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. - So müssen andere Benutzer nicht einzeln bestätigen. + {t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}

- Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt. + {t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}

- +
)} @@ -225,9 +225,9 @@ export const AddConnectionWizard: React.FC = ({ {/* ---- Step: Infomaniak PAT ---- */} {state.currentStep === 'infomaniakPat' && (
-

Infomaniak Personal Access Token

+

{t('Infomaniak Personal Access Token')}

- Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein. + {t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}

= ({ autoFocus />
- +
@@ -255,31 +255,31 @@ export const AddConnectionWizard: React.FC = ({ {/* ---- Step: Connect ---- */} {state.currentStep === 'connect' && (
-

Verbindung herstellen

+

{t('Verbindung herstellen')}

- Anbieter + {t('Anbieter')} {state.connector && CONNECTOR_ICONS[state.connector]}  {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
- Wissensdatenbank + {t('Wissensdatenbank')} - {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} + {state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
- +
diff --git a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx index f36f87c..1284ff9 100644 --- a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx +++ b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; -import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; +import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; export interface Automation2DataFlowContextValue { currentNodeId: string; @@ -19,6 +19,8 @@ export interface Automation2DataFlowContextValue { systemVariables: Record; /** Canonical form field types from the API — maps UI type id to portType primitive. */ formFieldTypes: FormFieldType[]; + /** Backend-driven condition operators per valueKind (flow.ifElse). */ + conditionOperatorCatalog: Record; getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string; getAvailableSourceIds: () => string[]; /** Present when rendered inside the flow editor (ConnectionPicker / tools). */ @@ -44,6 +46,7 @@ interface Automation2DataFlowProviderProps { portTypeCatalog?: Record; systemVariables?: Record; formFieldTypes?: FormFieldType[]; + conditionOperatorCatalog?: Record; instanceId?: string; request?: ApiRequestFunction; children: React.ReactNode; @@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC n.title ?? n.label ?? n.type ?? n.id, getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections), @@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index f754050..80de85b 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -246,6 +246,7 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; background: var(--canvas-bg, #fafafa); } @@ -257,21 +258,133 @@ overflow: visible; } -/* Toolbar: context (load + name) is fluid with ellipsis; actions wrap below on narrow viewports. */ -.canvasHeaderRow { +.canvasHeaderToolbar { display: flex; flex-wrap: wrap; - gap: 0.5rem 0.75rem; align-items: center; + gap: 0.4rem; width: 100%; + padding: 0; + border-radius: 8px; + border: none; + background: none; + box-sizing: border-box; } -.canvasHeaderContext { +/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */ +.canvasHeaderToolbar :global(button), +.canvasHeaderToolbar label { + margin-top: 0; +} + +.canvasHeaderEditRow { display: flex; + flex-wrap: wrap; align-items: center; - gap: 0.5rem; - min-width: 0; + 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 = ({ workflows, ))} -
- {currentWorkflowId && currentWorkflow ? ( - editingName ? ( - setNameValue(e.target.value)} - onBlur={_commitNameEdit} - onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }} - /> - ) : ( -

- {currentWorkflow.label} -

- ) - ) : ( -

- {t('Neuer Workflow')} -

- )} -
- {onWorkflowSettings && ( - - )} - {targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && ( - - )} -
- -
-
-
- - -
- {newMenuOpen && ( -
- - {onNewFromTemplate && ( - - )} -
- )} -
- - - - {onAutoLayout && ( - - )} - + onClick={onSave} + title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')} + aria-label={t('Speichern')} + /> + + {t('Als Vorlage')} + {templateMenuOpen && (
{(['user', 'instance', 'mandate'] as const).map((s) => ( @@ -325,7 +349,10 @@ export const CanvasHeader: React.FC = ({ workflows, key={s} type="button" className={styles.canvasHeaderMenuItem} - onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }} + onClick={() => { + onSaveAsTemplate(s); + setTemplateMenuOpen(false); + }} role="menuitem" > {scopeLabels[s]} @@ -336,53 +363,6 @@ export const CanvasHeader: React.FC = ({ workflows,
)} - - {onToggleChat && ( - - )} {_isSysAdmin && onVerboseSchemaChange && ( )} -
+ {canvasEdit && ( +
+
+
+ setZoomInputDraft(e.target.value)} + onBlur={_commitZoomDraft} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + _commitZoomDraft(); + } + }} + aria-label={t('Zoomstufe (Prozent)')} + title={t('Zoomstufe (Prozent)')} + /> + + % + +
+ + {zoomMenuOpen && ( +
+ + + {ZOOM_PRESET_PERCENTS.map((pct) => ( + + ))} +
+ )} +
+ + + + + + + + +
+ )} + {currentWorkflowId && versions && versions.length > 0 && (
{t('Version:')} @@ -418,108 +557,94 @@ export const CanvasHeader: React.FC = ({ workflows, ))} {badge.label} {currentVersion && currentStatus === 'draft' && onPublishVersion && ( - + )} {currentVersion && currentStatus === 'published' && onUnpublishVersion && ( - + )} {currentVersion && currentStatus !== 'archived' && onArchiveVersion && ( - + {t('Archiv')} + )} {onCreateDraft && ( - + {t('+ Entwurf')} + )} - {versionLoading && } + {versionLoading && }
)} {executeResult && (
{executeResult.success ? ( executeResult.warning ? ( - <>⚠ {executeResult.warning} + <>{executeResult.warning} ) : ( <>{t('Ausführung abgeschlossen')} ) - ) : (executeResult as { paused?: boolean }).paused ? ( + ) : executeResult.paused ? ( <> - ⏸ Workflow pausiert. Öffne {t('Workflows/Tasks')} in der Sidebar, um den - Task zu bearbeiten. + {t('Workflow pausiert. Öffne ')} + {t('Workflows/Tasks')} + {t(' in der Sidebar, um den Task zu bearbeiten.')} ) : ( - <>✗ {executeResult.error ?? t('Unbekannter Fehler')} + <>{executeResult.error ?? t('Unbekannter Fehler')} )}
)} diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index c4f8147..5a8010b 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -3,12 +3,22 @@ * 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'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { AiBadge } from '../nodes/shared/AiBadge'; +import { switchOutputLabel } from '../nodes/shared/graphUtils'; export interface CanvasNode { id: string; @@ -34,22 +44,352 @@ 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; +/** Must match `.canvasNode { border: … solid }` — handles sit in the padding box. */ +const NODE_BORDER = 2; + +export const FLOW_CANVAS_MIN_ZOOM = 0.25; +export const FLOW_CANVAS_MAX_ZOOM = 4; + +function deepCloneCanvasNode(node: CanvasNode): CanvasNode { + return { + ...node, + parameters: node.parameters ? { ...node.parameters } : {}, + inputPorts: node.inputPorts?.map((p) => ({ ...p })), + outputPorts: node.outputPorts?.map((p) => ({ ...p })), + }; +} + +/** Konfig-/Sidebar-/Header blenden Knoten-Duplizieren per Strg+C aus (normales Kopieren). */ +const FLOW_HOTKEY_SHIELD_SELECTOR = '[data-suppress-flow-node-hotkeys]'; + +function isDuplicateNodeHotkeyShielded(el: HTMLElement): boolean { + return el.closest(FLOW_HOTKEY_SHIELD_SELECTOR) != null; +} + +function isKeyboardTypingTarget(el: HTMLElement): boolean { + if (el.isContentEditable) return true; + const t = el.tagName; + return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT'; +} + +export interface FlowCanvasViewportEditState { + zoom: number; + selectedNodeCount: number; + connectionSelected: boolean; + /** Canvas-Sticky-/Kommentarnote ausgewählt (nicht Workflow-Knoten). */ + stickyNoteSelected: 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; + /** Raster-Anordnung: verschachtelte Rangpfade (4.1 / 4.2 …); Haftnotizen unberührt. */ + arrangeNodes: () => void; +}; const HANDLE_SIZE = 12; const HANDLE_OFFSET = HANDLE_SIZE / 2; const LAYOUT_V_GAP = 80; const LAYOUT_H_GAP = 60; +/** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */ +function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean { + if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false; + if (c.sourceId === c.targetId) return true; + return srcNode.y > tgtNode.y + 4; +} + +/** Für Layout-Schichtung: Graph ohne Loop-Rückkopplung (Sonst Pflicht-Schichtung senkrecht). */ +function stripLoopFeedbackConnections(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasConnection[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + return connections.filter((c) => { + const src = byId.get(c.sourceId); + const tgt = byId.get(c.targetId); + if (!src || !tgt) return false; + return !isLoopFeedbackEdge(c, src, tgt); + }); +} + +/** Reihenfolge links→rechts bei Kanten nur zwischen Knoten **dieser** Zeile (DAG/Kahn). */ +function orderRowByIntraRowTopo(row: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] { + const idSet = new Set(row.map((n) => n.id)); + const nodeById = new Map(row.map((n) => [n.id, n])); + const inDeg = new Map(); + const outs = new Map(); + for (const id of idSet) { + inDeg.set(id, 0); + outs.set(id, []); + } + for (const c of connections) { + if (!idSet.has(c.sourceId) || !idSet.has(c.targetId)) continue; + inDeg.set(c.targetId, (inDeg.get(c.targetId) ?? 0) + 1); + outs.get(c.sourceId)!.push(c.targetId); + } + const ready = row + .filter((n) => (inDeg.get(n.id) ?? 0) === 0) + .sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); + const result: CanvasNode[] = []; + const q = [...ready]; + while (q.length > 0) { + q.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); + const n = q.shift()!; + result.push(n); + for (const t of outs.get(n.id) ?? []) { + if (!idSet.has(t)) continue; + inDeg.set(t, (inDeg.get(t) ?? 1) - 1); + if (inDeg.get(t) === 0) q.push(nodeById.get(t)!); + } + } + const placed = new Set(result.map((r) => r.id)); + const rest = row.filter((n) => !placed.has(n.id)).sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); + return [...result, ...rest]; +} + +/** Verschachtelte Rasterposition ``[4,1]``, ``[4,2]``, ``[4,1,1]`` … für Zeilen vs. Spalten. */ +function linearNestedChildPath(p: number[]): number[] { + if (p.length === 1) return [p[0] + 1]; + return [Math.max(...p) + 1]; +} + +/** Zweig unter einem linearen Knoten ``[n]`` → ``[n+1,k]``, unter verschachteltem Pfad → ``[…p,k]``. */ +function branchNestedChildPath(p: number[], branchIndex: number): number[] { + if (p.length === 1) return [p[0] + 1, branchIndex]; + return [...p, branchIndex]; +} + +function longestPathDepthDown( + nodeId: string, + outgoingTargets: Map, + memo: Map, + visiting: Set, +): number { + if (memo.has(nodeId)) return memo.get(nodeId)!; + if (visiting.has(nodeId)) return 0; + visiting.add(nodeId); + const outs = outgoingTargets.get(nodeId) ?? []; + let best = 0; + for (const t of outs) { + best = Math.max(best, longestPathDepthDown(t, outgoingTargets, memo, visiting)); + } + visiting.delete(nodeId); + const d = outs.length === 0 ? 0 : 1 + best; + memo.set(nodeId, d); + return d; +} + +function reachableForwardTargets(start: string, outgoingTargets: Map): Set { + const seen = new Set(); + const stack = [start]; + while (stack.length > 0) { + const v = stack.pop()!; + if (seen.has(v)) continue; + seen.add(v); + for (const t of outgoingTargets.get(v) ?? []) stack.push(t); + } + return seen; +} + +/** Primärbaum: längster Pfad zuerst; Kurzschlüsse (z.B. Trigger→Schleife wenn Trigger→Upload→Schleife) werden nicht verdoppelt. */ +function layoutTreeChildren( + nodeId: string, + outgoingTargets: Map, + depthMemo: Map, +): string[] { + const outs = outgoingTargets.get(nodeId) ?? []; + if (outs.length <= 1) return outs; + const scored = outs.map((t) => ({ + t, + d: longestPathDepthDown(t, outgoingTargets, depthMemo, new Set()), + })); + scored.sort((a, b) => b.d - a.d || a.t.localeCompare(b.t)); + const primary = scored[0].t; + const reach = reachableForwardTargets(primary, outgoingTargets); + const branchOnly = outs.filter((t) => t !== primary && !reach.has(t)); + branchOnly.sort((a, b) => a.localeCompare(b)); + return [primary, ...branchOnly]; +} + +function assignNestedRankPaths(nodes: CanvasNode[], stripped: CanvasConnection[]): Map { + const nodeIds = new Set(nodes.map((n) => n.id)); + const outgoingTargets = new Map(); + for (const n of nodes) outgoingTargets.set(n.id, []); + const sortedStripped = [...stripped].sort( + (a, b) => + a.sourceId.localeCompare(b.sourceId) || + a.sourceHandle - b.sourceHandle || + a.targetId.localeCompare(b.targetId), + ); + for (const c of sortedStripped) { + if (!nodeIds.has(c.sourceId) || !nodeIds.has(c.targetId)) continue; + const arr = outgoingTargets.get(c.sourceId)!; + if (!arr.includes(c.targetId)) arr.push(c.targetId); + } + + const ranks = new Map(); + const depthMemo = new Map(); + let nextFallback = 1; + + function dfs(nodeId: string, path: number[]): void { + if (ranks.has(nodeId)) return; + ranks.set(nodeId, path); + const children = layoutTreeChildren(nodeId, outgoingTargets, depthMemo); + if (children.length === 0) return; + if (children.length === 1) { + dfs(children[0], linearNestedChildPath(path)); + return; + } + children.forEach((ch, idx) => { + dfs(ch, branchNestedChildPath(path, idx + 1)); + }); + } + + const roots = nodes.filter((n) => !stripped.some((c) => c.targetId === n.id)); + roots.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id))); + + if (roots.length === 0) { + const first = [...nodes].sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)))[0]; + if (first) dfs(first.id, [1]); + } else { + let seq = 1; + for (const r of roots) dfs(r.id, [seq++]); + } + + for (const n of nodes) { + if (!ranks.has(n.id)) dfs(n.id, [nextFallback++]); + } + + return ranks; +} + +function nestedRankRowGroupKey(path: number[]): string { + if (path.length <= 1) return `|L|${path.join('.')}`; + return `|B|${path.slice(0, -1).join('.')}`; +} + +function compareNestedRankPathLex(a: number[], b: number[]): number { + const n = Math.max(a.length, b.length); + for (let i = 0; i < n; i++) { + const av = a[i]; + const bv = b[i]; + if (av === undefined && bv === undefined) return 0; + if (av === undefined) return -1; + if (bv === undefined) return 1; + if (av !== bv) return av - bv; + } + return 0; +} + +function minNestedRankPath(paths: number[][]): number[] { + return paths.reduce((m, p) => (compareNestedRankPathLex(p, m) < 0 ? p : m)); +} + +/** Join-Knoten mit mehreren Vorgängern: einheitliche Zeilen-Stufe ``[max(pred)+1]``. */ +function refineConvergenceNestedRanks(nodes: CanvasNode[], stripped: CanvasConnection[], ranks: Map): void { + const preds = new Map(); + for (const n of nodes) preds.set(n.id, []); + for (const c of stripped) { + preds.get(c.targetId)!.push(c.sourceId); + } + const order = topologicalLayersIds(nodes, stripped).flat(); + for (const id of order) { + const ps = preds.get(id) ?? []; + if (ps.length <= 1) continue; + let best = 0; + for (const p of ps) { + const rp = ranks.get(p); + if (!rp || rp.length === 0) continue; + best = Math.max(best, Math.max(...rp)); + } + ranks.set(id, [best + 1]); + } +} + /** - * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers. - * Disconnected nodes are appended as extra roots. + * Topologische Schichten (Kahn): chronologisch von Quellen zu Senken. + * Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup. */ -export function computeAutoLayout( - nodes: CanvasNode[], - connections: CanvasConnection[], -): CanvasNode[] { - if (nodes.length === 0) return nodes; +function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] { + if (nodes.length === 0) return []; const inDegree = new Map(); const children = new Map(); @@ -58,6 +398,7 @@ export function computeAutoLayout( children.set(n.id, []); } for (const c of connections) { + if (!inDegree.has(c.sourceId) || !inDegree.has(c.targetId)) continue; inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1); children.get(c.sourceId)?.push(c.targetId); } @@ -87,12 +428,32 @@ export function computeAutoLayout( const placed = new Set(layerOf.keys()); for (const n of nodes) { if (!placed.has(n.id)) { - const layerIdx = layers.length; layers.push([n.id]); - layerOf.set(n.id, layerIdx); + layerOf.set(n.id, layers.length - 1); } } + return layers; +} + +/** + * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers. + * Disconnected nodes are appended as extra roots. + */ +export function computeAutoLayout( + nodes: CanvasNode[], + connections: CanvasConnection[], +): CanvasNode[] { + if (nodes.length === 0) return nodes; + + const layers = topologicalLayersIds(nodes, connections); + for (const layer of layers) { + layer.sort((a, b) => a.localeCompare(b)); + } + + const layerOf = new Map(); + layers.forEach((layer, li) => layer.forEach((id) => layerOf.set(id, li))); + const startX = 40; const startY = 40; @@ -108,6 +469,84 @@ export function computeAutoLayout( }); } +/** + * Raster-Anordnung über **verschachtelte Rangpfade** (z.B. ``4.1`` und ``4.2`` dieselbe Zeile): + * DFS auf einem Primärbaum (längster Pfad zuerst, ohne Loop-Rückkopplung für die Schichtung). + * Ein-Stufen-Pfade ``[1],[2],[3]`` jeweils eigene Zeile; gemeinsamer Präfix bei Zweigen → gemeinsame Rasterzeile. + * Zeilen untereinander unter der Mitte der darüberliegenden Zeile zentriert. + */ +export function computeGridTidyLayout(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] { + if (nodes.length === 0) return nodes; + + const stripped = stripLoopFeedbackConnections(nodes, connections); + const ranks = assignNestedRankPaths(nodes, stripped); + refineConvergenceNestedRanks(nodes, stripped, ranks); + + const rowBuckets = new Map(); + for (const n of nodes) { + const path = ranks.get(n.id); + if (!path) continue; + const key = nestedRankRowGroupKey(path); + if (!rowBuckets.has(key)) rowBuckets.set(key, []); + rowBuckets.get(key)!.push(n); + } + + const rowEntries = [...rowBuckets.values()].map((members) => { + const paths = members.map((m) => ranks.get(m.id)!); + return { + members, + rowOrderKey: minNestedRankPath(paths), + }; + }); + + rowEntries.sort((a, b) => compareNestedRankPathLex(a.rowOrderKey, b.rowOrderKey)); + + const rows = rowEntries.map((e) => + orderRowByIntraRowTopo( + [...e.members].sort((na, nb) => + compareNestedRankPathLex(ranks.get(na.id)!, ranks.get(nb.id)!), + ), + connections, + ), + ); + + const rowSpanX = (count: number) => + count <= 0 ? 0 : count * NODE_WIDTH + (count - 1) * LAYOUT_H_GAP; + + const startX = 40; + const startY = 40; + const out = new Map(); + + let prevLeft = startX; + let prevCount = rows[0]?.length ?? 0; + + rows.forEach((r, ri) => { + const nInRow = r.length; + let left: number; + if (ri === 0) { + left = startX; + } else { + const centerAbove = prevLeft + rowSpanX(prevCount) / 2; + left = centerAbove - rowSpanX(nInRow) / 2; + if (left < 8) left = 8; + } + + const y = startY + ri * (NODE_HEIGHT + LAYOUT_V_GAP); + r.forEach((n, ci) => { + out.set(n.id, { + ...n, + x: left + ci * (NODE_WIDTH + LAYOUT_H_GAP), + y, + }); + }); + + prevLeft = left; + prevCount = nInRow; + }); + + return nodes.map((n) => out.get(n.id) ?? n); +} + function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string { if (typeof schema === 'string') return schema; if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload'; @@ -139,6 +578,130 @@ function _checkConnectionCompatibility( return 'warning'; } +/** flow.loop Eingang 0: Hauptfluss + Schleifen-Rücklauf — mehrere Kanten pro Port. */ +function allowsMultipleInboundOnInputPort(targetNode: CanvasNode, targetHandleIndex: number): boolean { + return targetNode.type === 'flow.loop' && targetHandleIndex === 0; +} + +const NODE_OBSTACLE_PAD = 12; + +type Obstacle = { left: number; top: number; right: number; bottom: number }; + +function obstacleRects(allNodes: CanvasNode[], skipIds: Set, pad: number): Obstacle[] { + return allNodes + .filter((n) => !skipIds.has(n.id)) + .map((n) => ({ + left: n.x - pad, + top: n.y - pad, + right: n.x + NODE_WIDTH + pad, + bottom: n.y + NODE_HEIGHT + pad, + })); +} + +function pointInObstacle(x: number, y: number, o: Obstacle): boolean { + return x >= o.left && x <= o.right && y >= o.top && y <= o.bottom; +} + +function cubicCrossesObstacles( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + obstacles: Obstacle[], + tMargin = 0.08, +): boolean { + const steps = 40; + for (let i = 1; i < steps; i++) { + const t = i / steps; + if (t < tMargin || t > 1 - tMargin) continue; + const u = 1 - t; + const x = u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3; + const y = u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3; + for (const o of obstacles) { + if (pointInObstacle(x, y, o)) return true; + } + } + return false; +} + +/** + * Schleifen-Rücklauf — zwei Kubiken, C¹-stetig: + * + * C1: M sx sy C sx (sy+k) laneX (sy+k) laneX jY + * C2: C laneX (tyIn-k) tx (tyIn-k) tx tyIn + * + * Tangente am Start = (0,+k) → senkrecht RUNTER aus dem Quell-Port ✓ + * Tangente am Ende = (0,+k) → senkrecht RUNTER in den Ziel-Port ✓ + * Tangente an der Verbindungsstelle (laneX, jY): beide Seiten = (0, (tyIn-sy)/2-k) — gleich → kein Knick ✓ + * laneX wird per Sampling solange nach links verschoben, bis keine Kollision vorliegt. + */ +function feedbackConnectionPathD( + src: { x: number; y: number }, + tgt: { x: number; y: number }, + srcNode: CanvasNode, + tgtNode: CanvasNode, + allNodes: CanvasNode[], +): string { + const sx = src.x; + const sy = src.y; + const tx = tgt.x; + const tyIn = tgt.y; + + const minNx = allNodes.length + ? Math.min(...allNodes.map((n) => n.x)) + : Math.min(srcNode.x, tgtNode.x); + + const vert = Math.max(60, sy - tyIn); + const k = Math.min(vert * 0.38, 130); + const jY = (sy + tyIn) / 2; + + const skipIds = srcNode.id === tgtNode.id ? new Set([srcNode.id]) : new Set(); + const obstacles = obstacleRects(allNodes, skipIds, NODE_OBSTACLE_PAD); + + for (let margin = 72; margin <= 640; margin += 24) { + const laneX = Math.min(minNx - margin, Math.min(sx, tx) - margin); + const ok = + !cubicCrossesObstacles(sx, sy, sx, sy + k, laneX, sy + k, laneX, jY, obstacles) && + !cubicCrossesObstacles(laneX, jY, laneX, tyIn - k, tx, tyIn - k, tx, tyIn, obstacles); + if (ok) { + return ( + `M ${sx} ${sy}` + + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` + ); + } + } + const laneX = Math.min(minNx - 640, Math.min(sx, tx) - 640); + return ( + `M ${sx} ${sy}` + + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` + ); +} + +function connectionPathD( + src: { x: number; y: number }, + tgt: { x: number; y: number }, + srcNode: CanvasNode, + tgtNode: CanvasNode, + feedback: boolean, + allNodes: CanvasNode[], + /** Trennt überlagernde Kanten in der Kurvenmitte — Endpunkt bleibt am Handle-Mittelpunkt. */ + lateralBias = 0, +): string { + if (!feedback) { + const dy = tgt.y - src.y; + const mx = Math.abs(dy) / 2; + const b = lateralBias; + return `M ${src.x} ${src.y} C ${src.x + b} ${src.y + mx}, ${tgt.x + b} ${tgt.y - mx}, ${tgt.x} ${tgt.y}`; + } + return feedbackConnectionPathD(src, tgt, srcNode, tgtNode, allNodes); +} + interface FlowCanvasProps { nodes: CanvasNode[]; connections: CanvasConnection[]; @@ -158,6 +721,13 @@ interface FlowCanvasProps { * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; + onViewportEditState?: (state: FlowCanvasViewportEditState) => void; + /** Nach diskreten Canvas-Aktionen (Drop, Drag-Ende, Kante, Löschen …) für Undo. */ + onHistoryCheckpoint?: () => void; + onConnectionToolActiveChange?: (active: boolean) => void; + /** Nur Anzeige: Benutzer-Kommentare auf der Fläche (ohne Workflow-Daten). */ + stickyNotes?: CanvasStickyNote[]; + onStickyNotesChange?: (notes: CanvasStickyNote[]) => void; } const HIGHLIGHT_COLORS: Record = { @@ -167,21 +737,36 @@ const HIGHLIGHT_COLORS: Record = { skipped: '#6c757d', }; -export const FlowCanvas: React.FC = ({ nodes, - connections, - nodeTypes, - onNodesChange, - onConnectionsChange, - onDropNodeType, - getLabel, - getCategoryIcon, - onSelectionChange, - highlightedNodeIds, - nodeErrors, - onExternalDrop, -}) => { +export const FlowCanvas = forwardRef(function FlowCanvas( + { + nodes, + connections, + nodeTypes, + onNodesChange, + onConnectionsChange, + onDropNodeType, + getLabel, + getCategoryIcon, + onSelectionChange, + highlightedNodeIds, + nodeErrors, + onExternalDrop, + onViewportEditState, + onHistoryCheckpoint, + onConnectionToolActiveChange, + stickyNotes = [], + onStickyNotesChange, + }, + ref +) { const { t } = useLanguage(); + const tRef = useRef(t); + tRef.current = t; const containerRef = useRef(null); + const stickyNotesRef = useRef(stickyNotes); + stickyNotesRef.current = stickyNotes; + const onStickyNotesChangeRef = useRef(onStickyNotesChange); + onStickyNotesChangeRef.current = onStickyNotesChange; const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const [selectedConnectionId, setSelectedConnectionId] = useState(null); @@ -209,6 +794,13 @@ export const FlowCanvas: React.FC = ({ nodes, }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); + const panZoomRef = useRef({ x: 0, y: 0, zoom: 1 }); + panZoomRef.current = { x: panOffset.x, y: panOffset.y, zoom }; + const [connectionToolActive, setConnectionToolActive] = useState(false); + const [pendingConnClickSource, setPendingConnClickSource] = useState<{ + nodeId: string; + handleIndex: number; + } | null>(null); const [panning, setPanning] = useState<{ startX: number; startY: number; @@ -216,6 +808,30 @@ export const FlowCanvas: React.FC = ({ nodes, startPanY: number; } | null>(null); + const [editingStickyId, setEditingStickyId] = useState(null); + const [stickyFocusSelectAll, setStickyFocusSelectAll] = useState(false); + const stickyTextareaRef = useRef(null); + const [stickyDragState, setStickyDragState] = useState<{ + id: string; + startClientX: number; + startClientY: number; + noteInitial: { x: number; y: number }; + } | null>(null); + const [stickyResizeState, setStickyResizeState] = useState<{ + id: string; + startClientX: number; + startClientY: number; + startWidth: number; + startHeight: number; + } | null>(null); + const [selectedStickyId, setSelectedStickyId] = useState(null); + + useEffect(() => { + if (selectedStickyId && !stickyNotes.some((s) => s.id === selectedStickyId)) { + setSelectedStickyId(null); + } + }, [stickyNotes, selectedStickyId]); + const nodeTypeMap = useMemo(() => { const m: Record = {}; nodeTypes.forEach((nt) => { @@ -224,6 +840,185 @@ export const FlowCanvas: React.FC = ({ nodes, return m; }, [nodeTypes]); + const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); + onHistoryCheckpointRef.current = onHistoryCheckpoint; + + const emitHistoryCheckpoint = useCallback(() => { + onHistoryCheckpointRef.current?.(); + }, []); + + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + + useEffect(() => { + onViewportEditState?.({ + zoom, + selectedNodeCount: selectedNodeIds.size, + connectionSelected: !!selectedConnectionId, + stickyNoteSelected: !!selectedStickyId, + }); + }, [zoom, selectedNodeIds, selectedConnectionId, selectedStickyId, 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) { + 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(); + return; + } + const changeSticky = onStickyNotesChangeRef.current; + const sid = selectedStickyId; + if (sid && changeSticky) { + changeSticky(stickyNotesRef.current.filter((s) => s.id !== sid)); + setSelectedStickyId(null); + setEditingStickyId(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 = { + ...deepCloneCanvasNode(node), + id: newId, + x: node.x + 40, + y: node.y + 40, + }; + 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); + }, + arrangeNodes: () => { + if (nodes.length === 0) return; + onNodesChange(computeGridTidyLayout(nodes, connections)); + emitHistoryCheckpoint(); + }, + toggleConnectionTool: () => { + setConnectionToolActive((p) => !p); + setPendingConnClickSource(null); + setConnectingFrom(null); + setDragPos(null); + }, + }), + [ + connections, + emitHistoryCheckpoint, + nodes, + onConnectionsChange, + onNodesChange, + selectedConnectionId, + selectedNodeIds, + selectedStickyId, + ] + ); + useEffect(() => { if (onSelectionChange) { const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; @@ -235,13 +1030,15 @@ export const FlowCanvas: React.FC = ({ nodes, e.stopPropagation(); setSelectedConnectionId(connId); setSelectedNodeIds(new Set()); + setSelectedStickyId(null); }, []); const handleDeleteConnection = useCallback(() => { if (!selectedConnectionId) return; onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId)); setSelectedConnectionId(null); - }, [selectedConnectionId, connections, onConnectionsChange]); + emitHistoryCheckpoint(); + }, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]); const getHandlePosition = useCallback( (node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => { @@ -249,19 +1046,20 @@ export const FlowCanvas: React.FC = ({ nodes, const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex; const ioCount = isOutput ? node.outputs : node.inputs; - const w = NODE_WIDTH; - const h = NODE_HEIGHT; - const centerX = node.x + w / 2; + const innerLeft = node.x + NODE_BORDER; + const innerTop = node.y + NODE_BORDER; + const innerBottom = node.y + NODE_HEIGHT - NODE_BORDER; + const innerWidth = NODE_WIDTH - 2 * NODE_BORDER; + const centerX = innerLeft + innerWidth / 2; if (isOutput) { - if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' }; - const step = w / (ioCount + 1); - return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' }; - } else { - if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; - const step = w / (ioCount + 1); - return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' }; + if (ioCount === 1) return { x: centerX, y: innerBottom, side: 'bottom' }; + const step = innerWidth / (ioCount + 1); + return { x: innerLeft + step * (ioIndex + 1), y: innerBottom, side: 'bottom' }; } + if (ioCount === 1) return { x: centerX, y: innerTop, side: 'top' }; + const step = innerWidth / (ioCount + 1); + return { x: innerLeft + step * (ioIndex + 1), y: innerTop, side: 'top' }; }, [] ); @@ -272,6 +1070,21 @@ export const FlowCanvas: React.FC = ({ nodes, return used; }, [connections]); + /** Mehrere Kanten auf denselben Eingang: Kurven seitlich versetzen (Endpunkt = Handle-Mitte). */ + const inboundStacksByTarget = useMemo(() => { + const m = new Map(); + for (const c of connections) { + const key = `${c.targetId}-${c.targetHandle}`; + const list = m.get(key); + if (list) list.push(c); + else m.set(key, [c]); + } + for (const list of m.values()) { + list.sort((a, b) => a.id.localeCompare(b.id)); + } + return m; + }, [connections]); + const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); @@ -308,22 +1121,24 @@ export const FlowCanvas: React.FC = ({ nodes, const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; onDropNodeType(type, Math.max(0, x), Math.max(0, y)); + emitHistoryCheckpoint(); } catch (_) {} }, - [onDropNodeType, onExternalDrop, panOffset, zoom] + [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] ); const handleHandleMouseDown = useCallback( (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => { e.stopPropagation(); if (!isOutput) return; + if (connectionToolActive) return; const node = nodes.find((n) => n.id === nodeId); if (!node) return; const pos = getHandlePosition(node, handleIndex); setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y }); setDragPos({ x: e.clientX, y: e.clientY }); }, - [nodes, getHandlePosition] + [nodes, getHandlePosition, connectionToolActive] ); const handleHandleMouseUp = useCallback( @@ -341,7 +1156,10 @@ export const FlowCanvas: React.FC = ({ nodes, setSelectedConnectionId(null); return; } - if (getUsedTargetHandles.has(key)) { + if ( + getUsedTargetHandles.has(key) && + !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex) + ) { setSelectedConnectionId(null); return; } @@ -353,35 +1171,63 @@ export const FlowCanvas: React.FC = ({ nodes, ) ); setSelectedConnectionId(null); + emitHistoryCheckpoint(); } return; } - if (!connectingFrom || connectingFrom.nodeId === targetNodeId) { + const effectiveSource = + connectionToolActive && pendingConnClickSource + ? pendingConnClickSource + : connectingFrom + ? { nodeId: connectingFrom.nodeId, handleIndex: connectingFrom.handleIndex } + : null; + + const allowLoopSelfFeedback = + !!effectiveSource && + targetNode.type === 'flow.loop' && + targetHandleIndex === 0 && + effectiveSource.handleIndex >= targetNode.inputs; + if ( + !effectiveSource || + (effectiveSource.nodeId === targetNodeId && !allowLoopSelfFeedback) + ) { setConnectingFrom(null); setDragPos(null); return; } const key = `${targetNodeId}-${targetHandleIndex}`; - if (getUsedTargetHandles.has(key)) { + 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 })); } @@ -390,12 +1236,25 @@ export const FlowCanvas: React.FC = ({ nodes, onConnectionsChange([...connections, newConn]); setConnectingFrom(null); setDragPos(null); + setPendingConnClickSource(null); + emitHistoryCheckpoint(); }, - [connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId] + [ + connectingFrom, + connectionToolActive, + pendingConnClickSource, + connections, + nodes, + getUsedTargetHandles, + onConnectionsChange, + selectedConnectionId, + nodeTypes, + emitHistoryCheckpoint, + ] ); React.useEffect(() => { - if (!connectingFrom || !dragPos) return; + if (!connectingFrom) return; const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY }); const onUp = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -414,7 +1273,7 @@ export const FlowCanvas: React.FC = ({ nodes, window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [connectingFrom, dragPos]); + }, [connectingFrom]); const handleNodeMouseDown = useCallback( (e: React.MouseEvent, nodeId: string) => { @@ -439,6 +1298,10 @@ export const FlowCanvas: React.FC = ({ nodes, startClientY: e.clientY, nodesInitial, }); + + queueMicrotask(() => { + containerRef.current?.focus({ preventScroll: true }); + }); }, [nodes, selectedNodeIds] ); @@ -456,42 +1319,117 @@ export const FlowCanvas: React.FC = ({ nodes, }) ); }; - const onUp = () => setDraggingNodeId(null); + const onUp = () => { + setDraggingNodeId(null); + emitHistoryCheckpoint(); + }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]); + }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom, emitHistoryCheckpoint]); - const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 }); React.useEffect(() => { - const el = containerRef.current; - if (!el) return; - const update = () => { - const r = el.getBoundingClientRect(); - setContainerBounds({ left: r.left, top: r.top }); + 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 + ) + ); }; - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + 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 clientToCanvas = useCallback( - (clientX: number, clientY: number) => ({ - x: (clientX - containerBounds.left - panOffset.x) / zoom, - y: (clientY - containerBounds.top - panOffset.y) / zoom, - }), - [containerBounds, panOffset, zoom] + 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 }, + }); + }, + [] ); + /** Immer aktuelle Viewport-Lage (Scroll, Resize, verschobene Panels) — sonst klebt die Verbindungs-Hilfslinie falsch. */ + const clientToCanvas = useCallback((clientX: number, clientY: number) => { + const el = containerRef.current; + if (!el) return { x: 0, y: 0 }; + const r = el.getBoundingClientRect(); + return { + x: (clientX - r.left - panOffset.x) / zoom, + y: (clientY - r.top - panOffset.y) / zoom, + }; + }, [panOffset, zoom]); + 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()); @@ -511,7 +1449,9 @@ export const FlowCanvas: React.FC = ({ nodes, const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; - setZoom((z) => Math.min(2, Math.max(0.25, z + delta))); + setZoom((z) => + Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, z + delta)) + ); }, []); React.useEffect(() => { @@ -570,6 +1510,7 @@ export const FlowCanvas: React.FC = ({ nodes, if (overlaps) ids.add(n.id); }); setSelectedNodeIds(ids); + setSelectedStickyId(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); @@ -582,10 +1523,11 @@ export const FlowCanvas: React.FC = ({ nodes, const CANVAS_SIZE = 8000; const svgBounds = useMemo(() => { if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE }; - let maxX = 0, maxY = 0; + let maxX = 0; + let maxY = 0; nodes.forEach((n) => { maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); - maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200); + maxY = Math.max(maxY, n.y + NODE_HEIGHT + 320); }); return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) }; }, [nodes]); @@ -602,16 +1544,58 @@ export const FlowCanvas: React.FC = ({ nodes, setSelectedNodeIds(new Set()); setEditingNodeId(null); setEditingField(null); - }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]); + emitHistoryCheckpoint(); + }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange, emitHistoryCheckpoint]); + + const handleDeleteSelectedStickyNote = useCallback(() => { + const change = onStickyNotesChangeRef.current; + if (!selectedStickyId || !change) return; + change(stickyNotesRef.current.filter((s) => s.id !== selectedStickyId)); + setSelectedStickyId(null); + setEditingStickyId(null); + emitHistoryCheckpoint(); + }, [selectedStickyId, emitHistoryCheckpoint]); React.useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + const target = e.target as HTMLElement | null; + if (!target) return; + + const mod = e.ctrlKey || e.metaKey; + if (mod && e.code === 'KeyC') { + if (selectedConnectionId || selectedStickyId || !selectedNodeId) return; + if (isDuplicateNodeHotkeyShielded(target)) return; + const node = nodesRef.current.find((n) => n.id === selectedNodeId); + if (!node) return; + e.preventDefault(); + e.stopPropagation(); + const newId = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const clone: CanvasNode = { + ...deepCloneCanvasNode(node), + id: newId, + x: node.x + 40, + y: node.y + 40, + }; + onNodesChange([...nodesRef.current, clone]); + setSelectedConnectionId(null); + setSelectedNodeIds(new Set([newId])); + setSelectedStickyId(null); + setEditingNodeId(null); + setEditingField(null); + emitHistoryCheckpoint(); + return; + } + + if (isKeyboardTypingTarget(target)) return; if (e.key === 'Escape') { 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) { @@ -620,12 +1604,25 @@ export const FlowCanvas: React.FC = ({ nodes, } else if (selectedNodeIds.size > 0) { e.preventDefault(); handleDeleteNode(); + } else if (selectedStickyId && onStickyNotesChangeRef.current) { + e.preventDefault(); + handleDeleteSelectedStickyNote(); } } }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [handleDeleteNode, handleDeleteConnection, selectedNodeIds.size, selectedConnectionId]); + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [ + handleDeleteNode, + handleDeleteConnection, + handleDeleteSelectedStickyNote, + emitHistoryCheckpoint, + onNodesChange, + selectedNodeIds.size, + selectedNodeId, + selectedConnectionId, + selectedStickyId, + ]); const handleNodeUpdate = useCallback( (nodeId: string, updates: Partial>) => { @@ -636,10 +1633,31 @@ export const FlowCanvas: React.FC = ({ nodes, [nodes, onNodesChange] ); + const patchStickyNote = useCallback( + ( + id: string, + patch: Partial> + ) => { + onStickyNotesChange?.( + stickyNotes.map((s) => (s.id === id ? { ...s, ...patch } : s)) + ); + }, + [stickyNotes, onStickyNotesChange] + ); + + useLayoutEffect(() => { + if (!stickyFocusSelectAll || !editingStickyId) return; + const ta = stickyTextareaRef.current; + if (!ta) return; + ta.focus(); + ta.select(); + setStickyFocusSelectAll(false); + }, [editingStickyId, stickyFocusSelectAll, stickyNotes]); + return (
= ({ nodes, setSelectedConnectionId(null); setConnectingFrom(null); setDragPos(null); + setPendingConnClickSource(null); + setEditingStickyId(null); + setSelectedStickyId(null); }} > {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && ( @@ -687,6 +1708,24 @@ export const FlowCanvas: React.FC = ({ nodes, {t('Anderen Eingang anklicken zum Umleiten')}
)} + {connectionToolActive && pendingConnClickSource && !selectedConnectionId && ( +
+ {t('Klicken Sie auf einen Eingang, um die Verbindung zu erstellen')} + {' · '} + Esc {t('zum Abbrechen')} +
+ )} + {connectionToolActive && + !pendingConnClickSource && + !connectingFrom && + !selectedConnectionId && + selectedNodeIds.size <= 1 && ( +
+ {t('Klicken Sie auf einen Ausgang, dann auf einen Eingang')} + {' · '} + Esc {t('zum Abbrechen')} +
+ )}
= ({ nodes, className={styles.connectionsLayer} width={svgBounds.width} height={svgBounds.height} - style={{ position: 'absolute', left: 0, top: 0 }} + style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }} > @@ -715,9 +1755,10 @@ export const FlowCanvas: React.FC = ({ nodes, @@ -725,9 +1766,10 @@ export const FlowCanvas: React.FC = ({ nodes, @@ -739,9 +1781,13 @@ export const FlowCanvas: React.FC = ({ nodes, const tgtNode = nodes.find((n) => n.id === c.targetId); if (!srcNode || !tgtNode) return null; const src = getHandlePosition(srcNode, c.sourceHandle); - const tgt = getHandlePosition(tgtNode, c.targetHandle); - const dy = tgt.y - src.y; - const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`; + const tgtBase = getHandlePosition(tgtNode, c.targetHandle); + const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c]; + const si = stack.findIndex((x) => x.id === c.id); + const lateralBias = + stack.length > 1 ? (si - (stack.length - 1) / 2) * 14 : 0; + const feedback = isLoopFeedbackEdge(c, srcNode, tgtNode); + const pathD = connectionPathD(src, tgtBase, srcNode, tgtNode, feedback, nodes, lateralBias); const isSelected = selectedConnectionId === c.id; const isWarning = connectionWarnings[c.id]; const strokeColor = isSelected @@ -770,6 +1816,8 @@ export const FlowCanvas: React.FC = ({ nodes, fill="none" stroke={strokeColor} strokeWidth={isSelected ? 3 : 2} + strokeLinecap="round" + strokeLinejoin="round" strokeDasharray={isWarning && !isSelected ? '6 3' : undefined} markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'} pointerEvents="none" @@ -803,7 +1851,14 @@ export const FlowCanvas: React.FC = ({ 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'; @@ -828,6 +1883,7 @@ export const FlowCanvas: React.FC = ({ nodes, onMouseDown={(e) => { e.stopPropagation(); setSelectedConnectionId(null); + setSelectedStickyId(null); if (e.shiftKey) { setSelectedNodeIds((prev) => { const next = new Set(prev); @@ -881,25 +1937,37 @@ export const FlowCanvas: React.FC = ({ nodes, ) : null} {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); - const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); + const used = + !isOutput && + getUsedTargetHandles.has(`${node.id}-${index}`) && + !allowsMultipleInboundOnInputPort(node, index); const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null; 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; + const outputIndex = index - node.inputs; + const outputLabel = + isOutput && node.type === 'flow.switch' + ? switchOutputLabel(node, outputIndex, t) + : isOutput && nt?.outputLabels + ? nt.outputLabels[outputIndex] + : undefined; return (
= ({ nodes, style={{ top: pos.side === 'top' ? -HANDLE_OFFSET : undefined, bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined, - left: pos.x - node.x - HANDLE_OFFSET, + left: pos.x - node.x - NODE_BORDER - HANDLE_OFFSET, }} > - {outputLabel && pos.side === 'bottom' && ( - {outputLabel} - )} -
handleHandleMouseDown(e, node.id, index, isOutput)} - onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} - title={ - outputLabel ?? - (selectedConnectionId && !isOutput - ? used - ? t('Aktuelles Ziel klicken, um abzuwählen') - : t('Klicken zum Umleiten') - : undefined) - } - /> - {outputLabel && pos.side === 'top' && ( - {outputLabel} + {outputLabel && pos.side === 'bottom' && isOutput ? ( + <> +
handleHandleMouseDown(e, node.id, index, isOutput)} + onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + onClick={(e) => { + if (!connectionToolActive || !isOutput) return; + e.stopPropagation(); + setPendingConnClickSource({ nodeId: node.id, handleIndex: index }); + setConnectingFrom(null); + setDragPos(null); + }} + title={outputLabel} + /> + {outputLabel} + + ) : ( + <> +
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 + ? used + ? t('Aktuelles Ziel klicken, um abzuwählen') + : t('Klicken zum Umleiten') + : undefined) + } + /> + {outputLabel && pos.side === 'top' && ( + {outputLabel} + )} + )}
); @@ -945,6 +2039,7 @@ export const FlowCanvas: React.FC = ({ nodes, e.stopPropagation()} @@ -989,6 +2084,132 @@ export const FlowCanvas: React.FC = ({ nodes,
); })} + {stickyNotes.map((sn) => { + const pal = getStickyNotePaletteEntry(sn.colorId); + const activeColorId = sn.colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID; + const contentH = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT; + return ( +
{ + e.stopPropagation(); + setSelectedStickyId(sn.id); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + }} + onClick={(e) => e.stopPropagation()} + > +
handleStickyToolbarMouseDown(e, sn)} + > + + ⋮⋮ + + {selectedStickyId === sn.id ? ( +
+ {STICKY_NOTE_PALETTE.map((p) => ( +
+ ) : null} +
+ {editingStickyId === sn.id ? ( +