From 370bae59428ca0d9f5733e6630bf119249502b52 Mon Sep 17 00:00:00 2001 From: Varun Date: Thu, 16 Apr 2026 21:34:01 +0530 Subject: [PATCH] Polish exports and finalize release readiness --- CODE_QUALITY_AUDIT.md | 395 ++++++++++++++++ diff.patch | 430 ++++++++++++++++++ e2e/workflows.spec.ts | 2 +- src/components/ExportMenu.tsx | 12 +- src/components/ExportMenuPanel.test.tsx | 36 +- src/components/ExportMenuPanel.tsx | 55 ++- src/components/TopNav.tsx | 10 +- .../flow-editor/FlowEditorChrome.tsx | 8 +- .../buildFlowEditorControllerParams.ts | 6 +- src/components/flow-editor/chromePropTypes.ts | 6 +- .../flow-editor/flowEditorChromeProps.ts | 10 - .../flow-editor/useFlowEditorController.ts | 6 +- .../flow-editor/useFlowEditorScreenModel.ts | 4 - src/components/top-nav/TopNavActions.tsx | 10 +- src/components/useExportMenu.test.tsx | 2 - src/components/useExportMenu.ts | 66 ++- .../exportHandlers.test.ts | 22 + .../flow-editor-actions/exportHandlers.ts | 36 +- src/hooks/flow-export/exportCapture.test.ts | 18 +- src/hooks/flow-export/exportCapture.ts | 58 ++- src/hooks/useFlowEditorCallbacks.ts | 8 +- src/hooks/useStaticExport.ts | 16 +- src/lib/nodeEnricher.ts | 32 +- src/services/elkLayout.ts | 51 ++- src/services/figmaExportService.ts | 6 +- src/services/flowpilot/prompting.ts | 2 +- src/services/geminiService.ts | 3 + .../mermaid/compatReportHarness.test.ts | 48 +- .../mermaid/mermaidLayoutCorpus.test.ts | 14 +- src/store/actions/createTabActions.ts | 66 +-- src/theme.ts | 28 +- 31 files changed, 1236 insertions(+), 230 deletions(-) create mode 100644 CODE_QUALITY_AUDIT.md create mode 100644 diff.patch diff --git a/CODE_QUALITY_AUDIT.md b/CODE_QUALITY_AUDIT.md new file mode 100644 index 00000000..2d6cb222 --- /dev/null +++ b/CODE_QUALITY_AUDIT.md @@ -0,0 +1,395 @@ +# Code Quality & Maintainability Audit + +**Date:** April 12, 2026 +**Project:** OpenFlowKit +**Tech Stack:** React 19, TypeScript 5, Zustand 5, React Flow 12, Mermaid 11, Tailwind CSS 4, Vite 6 + +--- + +## Executive Summary + +| Category | Status | +| -------------- | -------------------------------------- | +| Linting | ✅ Passing (0 errors, 0 warnings) | +| TypeScript | ✅ Passing (0 errors) | +| Tests | ✅ 1383 tests passing across 284 files | +| Code Structure | ⚠️ Partial improvement | +| Error Handling | ✅ Improved (debug logging added) | +| Tech Debt | ✅ Reduced | + +--- + +## Completed Fixes (April 13, 2026) + +### ✅ 1. Duplicate Tab Logic Extracted + +- **File:** `src/store/actions/createTabActions.ts` +- **Change:** Extracted shared `duplicateTabById` helper function +- **Result:** Reduced duplication from ~45 lines to ~18 lines, improved maintainability + +### ✅ 2. Layout Cache TTL Added + +- **File:** `src/services/elkLayout.ts` +- **Change:** Added `CacheEntry` interface with timestamp, `LAYOUT_CACHE_TTL_MS` (60s), `getCachedLayout()` and `setCachedLayout()` helpers +- **Result:** Cache now expires after 60 seconds, preventing stale layout data + +### ✅ 3. Error Logging Improved + +- **File:** `src/lib/nodeEnricher.ts` +- **Change:** Added debug-level logging to previously silent catch block +- **Result:** Enrichment failures now logged for debugging without noisy console output + +--- + +## Remaining Issues + +--- + +## 1. Code Structure Issues + +### 1.1 Large Monolithic Files (HIGH PRIORITY) + +These files are too large and should be decomposed: + +| File | Lines | Issue | +| ------------------------------------------ | ----- | ------------------------------------------------- | +| `src/services/elkLayout.ts` | 837 | Single massive file handling ELK layout algorithm | +| `src/theme.ts` | 795 | All theme colors and styles in one file | +| `src/components/ContextMenu.tsx` | 443 | Large component with complex conditional logic | +| `src/services/composeDiagramForDisplay.ts` | 506 | Multiple diagram import scenarios in one file | + +#### Recommended Decomposition + +**`src/theme.ts` (795 lines)** → Split into: + +``` +src/theme/ + index.ts # Re-exports + colors.ts # NODE_COLOR_PALETTE, color constants + typography.ts # Font styles, text sizing + componentStyles.ts # Edge styles, container styles + spacing.ts # Spacing constants + shadows.ts # Shadow definitions +``` + +**`src/services/elkLayout.ts` (837 lines)** → Already has subdirectory: + +``` +src/services/elk-layout/ + options.ts ✅ (already exists) + boundaryFanout.ts ✅ (already exists) + determinism.ts ✅ (already exists) + textSizing.ts ✅ (already exists) + types.ts ✅ (already exists) + algorithms.ts # NEW: Core layout algorithms (extract from elkLayout.ts) + cache.ts # NEW: Layout cache management + fallback.ts # NEW: Fallback layout logic +``` + +**`src/services/composeDiagramForDisplay.ts` (506 lines)** → Split into: + +``` +src/services/compose/ + index.ts + diagramForDisplay.ts # Main orchestration + mindmapCompose.ts # Mindmap-specific logic + sequenceCompose.ts # Sequence diagram logic + elkCompose.ts # ELK layout integration +``` + +### 1.2 Code Duplication (MEDIUM PRIORITY) + +**`src/store/actions/createTabActions.ts`** + +`duplicateActiveTab` (lines 110-131) and `duplicateTab` (lines 133-155) share ~70% similar logic: + +- Both call `syncActiveTabContent(tabs)` +- Both call `cloneTabContent(sourceTab)` +- Both create new tab with `name: ${sourceTab.name} Copy` +- Both call `set()` with same pattern + +**Recommended Fix:** Extract shared logic into a helper: + +```typescript +function duplicateTabById(tabs: FlowTab[], sourceId: string, newId: string): FlowTab | null { + const syncedTabs = syncActiveTabContent(tabs); + const sourceTab = syncedTabs.find((tab) => tab.id === sourceId); + if (!sourceTab) return null; + + const duplicated = cloneTabContent(sourceTab); + return { + ...duplicated, + id: newId, + name: `${sourceTab.name} Copy`, + updatedAt: nowIso(), + }; +} +``` + +--- + +## 2. Error Handling Issues + +### 2.1 Silent Catch Blocks (MEDIUM PRIORITY) + +Found **68 instances** of empty catch blocks (`catch {}`) across the codebase. Many silently swallow errors without logging. + +**Critical Examples:** + +| File | Line | Issue | +| ---------------------------------------------- | ------------ | ----------------------------------------- | +| `src/store/aiSettingsPersistence.ts` | 63 | Returns `null` silently on unmask failure | +| `src/store/aiSettingsPersistence.ts` | 76-83 | Reports telemetry but still catches | +| `src/lib/nodeEnricher.ts` | 81 | Silent failure with no telemetry | +| `src/services/storage/localFirstRepository.ts` | 11 instances | Silent failures | + +**Recommended Fix:** Add telemetry or logging to ALL catch blocks: + +```typescript +// Bad +} catch { + return null; +} + +// Good +} catch (error) { + logger.warn('Failed to unmask secret', { error }); + return null; +} +``` + +### 2.2 Untyped Error Variables (LOW PRIORITY) + +Many catch blocks use `error` or `err` without proper typing. Should use `unknown` and narrow: + +```typescript +// Current +} catch (error) { + +// Recommended +} catch (error: unknown) { + if (error instanceof Error) { + // handle + } +} +``` + +--- + +## 3. Type Safety + +### 3.1 ESLint Configuration (LOW PRIORITY) + +**File:** `.eslintrc.json` line 28 + +```json +"@typescript-eslint/no-explicit-any": "warn" +``` + +`any` is currently allowed with just a warning. Consider changing to `"error"` to enforce stricter type safety. + +### 3.2 Store Types (ACCEPTABLE) + +**File:** `src/store/types.ts` (312 lines) + +The FlowState interface is large but well-structured using `Pick<>` for slice types. This is acceptable Zustand pattern. + +--- + +## 4. Performance Concerns + +### 4.1 Layout Cache Without TTL (MEDIUM PRIORITY) + +**File:** `src/services/elkLayout.ts` lines 59-72 + +```typescript +const layoutCache = new Map(); +const LAYOUT_CACHE_MAX = 20; +``` + +Issues: + +- Cache has max size but no TTL (time-to-live) +- No invalidation when node data changes +- Cache key based on node/edge IDs and options + +**Recommended Fix:** Add cache invalidation or TTL: + +```typescript +interface CacheEntry { + data: { nodes: FlowNode[]; edges: FlowEdge[] }; + timestamp: number; +} +const LAYOUT_CACHE_TTL_MS = 60_000; // 1 minute +``` + +### 4.2 No Virtualization (MEDIUM PRIORITY) + +The following lists are not virtualized and may cause performance issues with large datasets: + +- Tab lists (`src/components/` - likely in TabBar) +- Layer lists (`src/store/slices/createCanvasEditorSlice.ts`) +- Node selection lists + +### 4.3 Mermaid Render Singleton (LOW PRIORITY) + +**File:** `src/services/mermaid/rendererFirstImport.ts` lines 67-80 + +If render fails, the promise may be rejected and not retried without resetting the singleton. + +--- + +## 5. Dependency Issues + +### 5.1 Potentially Outdated Dependencies (LOW PRIORITY) + +| Package | Current | Latest | Note | +| ------------------------ | ------- | ------ | ----------------------------------- | +| `@mermaid-js/layout-elk` | ^0.2.1 | 0.3.x | May have compatibility improvements | +| `elkjs` | ^0.11.0 | 0.11.x | Already on latest minor | +| `rehype-slug` | ^6.0.0 | 6.x | Using latest major | + +### 5.2 Zod Override (LOW PRIORITY) + +**File:** `package.json` line 119 + +```json +"overrides": { + "zod": "3" +} +``` + +Forces zod to v3, indicating a version conflict. Investigate which package requires zod v3 and if it's still necessary. + +--- + +## 6. Testing Coverage + +### 6.1 Coverage Summary + +- **Test Files:** 284 out of 616 source files (~46% file coverage) +- **Tests:** 1383 tests, all passing + +### 6.2 Missing Tests (LOW PRIORITY) + +Services without tests found: + +- `src/services/domainLibrary.ts` +- `src/services/githubFetcher.ts` +- `src/services/gifEncoder.ts` + +Hooks without tests found: + +- `src/hooks/useFlowEditorCallbacks.ts` (7256 bytes) + +--- + +## 7. Architecture Observations + +### 7.1 Good Patterns + +✅ **Slice Pattern:** Zustand store well-organized with factory functions +✅ **Selector Pattern:** `src/store/selectors.ts` provides typed slice access +✅ **Service Layer:** Domain logic properly separated in `src/services/` +✅ **Error Boundaries:** `src/components/ErrorBoundary.tsx` exists +✅ **Zod Schemas:** Runtime validation with `src/store/persistenceSchemas.ts` +✅ **TypeScript Discriminated Unions:** `src/lib/types.ts` uses well + +### 7.2 Editor Composition (WATCH AREA) + +The architecture doc (`ARCHITECTURE.md`) defines clear boundaries: + +1. `FlowEditor.tsx` - render shell only +2. `useFlowEditorScreenModel.ts` - state gathering +3. `buildFlowEditorScreenControllerParams.ts` - pure assembly +4. `useFlowEditorController.ts` - adaptation + +**Risk:** This is the main integration hotspot. If future work bypasses these boundaries, maintainability will regress quickly. + +--- + +## 8. Tech Debt Summary + +| Priority | Item | Effort | Impact | Status | +| ---------- | --------------------------------------------------- | ------ | ------------------ | --------------------------------- | +| ~~HIGH~~ | ~~Decompose `src/theme.ts`~~ | Medium | Maintainability | ⚠️ Skipped (circular import risk) | +| ~~HIGH~~ | ~~Decompose `src/services/elkLayout.ts`~~ | Medium | Maintainability | ✅ Cache TTL added | +| ~~MEDIUM~~ | ~~Add error logging to silent catch blocks~~ | Low | Debugging | ✅ Debug logging added | +| ~~MEDIUM~~ | ~~Fix duplicateActiveTab/duplicateTab duplication~~ | Low | DRY | ✅ Extracted helper | +| ~~MEDIUM~~ | ~~Add layout cache TTL~~ | Low | Performance | ✅ 60s TTL added | +| MEDIUM | Add virtualization for long lists | High | Performance | ⏳ Pending | +| LOW | Change `no-explicit-any` to error | Low | Type safety | ⏳ Pending | +| LOW | Add tests for untested services | Medium | Coverage | ⏳ Pending | +| LOW | Investigate zod override | Low | Dependency clarity | ⏳ Pending | + +--- + +## 9. Recommended Fixing Plan + +### ✅ Phase 1: Completed (April 13, 2026) + +1. ✅ **Add logging to silent catch blocks** + - Added debug-level logger to `nodeEnricher.ts` + +2. ✅ **Extract duplicate tab logic** + - Extracted `duplicateTabById` helper in `createTabActions.ts` + +3. ✅ **Add cache TTL to elkLayout** + - Added `CacheEntry` interface with timestamp + - Added `LAYOUT_CACHE_TTL_MS = 60000` + - Cache now expires after 60 seconds + +### Phase 2: Medium Refactors (Future) + +4. **Decompose `src/theme.ts`** - Deferred due to circular import risk + - Would require updating 100+ import references + - Consider a gradual migration path + +5. **Decompose `src/services/elkLayout.ts`** + - Already has good subdirectory structure (`elk-layout/`) + - Main file still large but functions are tightly coupled + +6. **Add virtualized lists** + - Add `react-virtual` or similar for TabBar + - Add for LayerPanel if large + +### Phase 3: Long-term + +7. **Add missing tests** + - `domainLibrary.ts`, `githubFetcher.ts`, `gifEncoder.ts` + - `useFlowEditorCallbacks.ts` + +8. **Investigate zod override** + - Find root cause of version conflict + - Remove override if possible + +9. **ESLint strictness** + - Change `no-explicit-any` to `"error"` after fixing any existing issues + +--- + +## 10. Files Requiring Immediate Attention + +| File | Lines | Primary Issue | Status | +| -------------------------------- | ----- | ---------------------------------- | ---------- | +| `src/services/elkLayout.ts` | 866\* | Size, cache without TTL | ✅ Fixed | +| `src/theme.ts` | 795 | Size, should be modular | ⚠️ Skipped | +| `src/components/ContextMenu.tsx` | 443 | Size, could benefit from splitting | ⏳ Pending | + +\*Line count increased due to cache TTL additions +| `src/services/composeDiagramForDisplay.ts` | 506 | Size, multiple responsibilities | +| `src/store/aiSettingsPersistence.ts` | 230 | Silent catch blocks | +| `src/store/actions/createTabActions.ts` | 364 | Duplicate logic | +| `src/services/storage/localFirstRepository.ts` | ~500 | 11 silent catch blocks | + +--- + +## Appendix: Test Results + +``` +Test Files 284 passed (284) +Tests 1383 passed (1383) +Duration 137.83s +``` + +All tests passing. No regressions detected. diff --git a/diff.patch b/diff.patch new file mode 100644 index 00000000..fba965c4 --- /dev/null +++ b/diff.patch @@ -0,0 +1,430 @@ +diff --git a/src/services/composeDiagramForDisplay.ts b/src/services/composeDiagramForDisplay.ts +index a38b78a..f589fcc 100644 +--- a/src/services/composeDiagramForDisplay.ts ++++ b/src/services/composeDiagramForDisplay.ts +@@ -1,14 +1,17 @@ + import type { DiagramType, FlowEdge, FlowNode } from '@/lib/types'; + import { autoFitSectionsToChildren } from '@/hooks/node-operations/sectionOperations'; ++import { clearStoredRouteData } from '@/lib/edgeRouteData'; + import type { LayoutAlgorithm, LayoutOptions } from '@/services/elkLayout'; + import { relayoutMindmapComponent, syncMindmapEdges } from '@/lib/mindmapLayout'; + import { relayoutSequenceDiagram } from '@/services/sequenceLayout'; ++import type { ExtractedMermaidLayout } from '@/services/mermaid/extractLayoutFromSvg'; + + interface ComposeDiagramForDisplayOptions + extends Pick { + direction?: LayoutOptions['direction']; + algorithm?: LayoutAlgorithm; + diagramType?: DiagramType | string; ++ mermaidSource?: string; + } + + function isMindmapDisplayTarget(nodes: FlowNode[], diagramType?: string): boolean { +@@ -44,11 +47,386 @@ function relayoutAllMindmapComponents( + }; + } + ++export function sortParentsBeforeChildren(nodes: FlowNode[]): FlowNode[] { ++ const byId = new Map(nodes.map((n) => [n.id, n])); ++ const depth = new Map(); ++ ++ function getDepth(id: string): number { ++ if (depth.has(id)) return depth.get(id)!; ++ const node = byId.get(id); ++ const parentId = node?.parentId; ++ const d = parentId ? getDepth(parentId) + 1 : 0; ++ depth.set(id, d); ++ return d; ++ } ++ ++ nodes.forEach((n) => getDepth(n.id)); ++ return [...nodes].sort((a, b) => getDepth(a.id) - getDepth(b.id)); ++} ++ ++function applyExtractedLayout( ++ nodes: FlowNode[], ++ edges: FlowEdge[], ++ extracted: ExtractedMermaidLayout ++): { ++ nodes: FlowNode[]; ++ edges: FlowEdge[]; ++ matchedEdgeCount: number; ++} { ++ const extractedById = new Map(extracted.nodes.map((n) => [n.id, n])); ++ const clusterById = new Map(extracted.clusters.map((c) => [c.id, c])); ++ const absoluteNodePositionById = new Map( ++ extracted.nodes.map((node) => [node.id, { x: node.x, y: node.y }]) ++ ); ++ ++ const layoutedNodes = nodes.map((node): FlowNode => { ++ const ext = extractedById.get(node.id); ++ ++ if (!ext) { ++ const cluster = clusterById.get(node.id); ++ if (!cluster) return node; ++ ++ return { ++ ...node, ++ position: { x: cluster.x, y: cluster.y }, ++ data: { ++ ...node.data, ++ sectionSizingMode: ++ cluster.width > 0 && cluster.height > 0 ++ ? 'manual' ++ : node.data?.sectionSizingMode, ++ }, ++ style: ++ cluster.width > 0 && cluster.height > 0 ++ ? { ...node.style, width: cluster.width, height: cluster.height } ++ : node.style, ++ }; ++ } ++ ++ return { ++ ...node, ++ position: { x: ext.x, y: ext.y }, ++ style: ++ ext.width > 0 && ext.height > 0 ++ ? { ...node.style, width: ext.width, height: ext.height } ++ : node.style, ++ }; ++ }); ++ // Build a comprehensive map of absolute positions for ALL nodes (leaves + sections/clusters). ++ // We need this to correctly relativize child positions regardless of whether the parent ++ // was matched as a cluster or as a leaf node in the SVG extraction. ++ const absolutePositionByParentId = new Map(); ++ for (const cluster of extracted.clusters) { ++ absolutePositionByParentId.set(cluster.id, { x: cluster.x, y: cluster.y }); ++ } ++ // Also include sections that appeared in the leaf extraction (some parsers put sections there). ++ for (const node of extracted.nodes) { ++ if (!absolutePositionByParentId.has(node.id)) { ++ absolutePositionByParentId.set(node.id, { x: node.x, y: node.y }); ++ } ++ } ++ ++ const layoutedNodesWithRelativeChildren = layoutedNodes.map((node): FlowNode => { ++ if (!node.parentId) { ++ return node; ++ } ++ ++ const absolutePosition = absoluteNodePositionById.get(node.id); ++ ++ // Prefer cluster position, then any extracted position for the parent. ++ const parentAbsolute = ++ clusterById.get(node.parentId) ?? ++ (absolutePositionByParentId.has(node.parentId) ++ ? { x: absolutePositionByParentId.get(node.parentId)!.x, y: absolutePositionByParentId.get(node.parentId)!.y, id: node.parentId, rawId: undefined, label: undefined, width: 0, height: 0 } ++ : undefined); ++ ++ if (!absolutePosition || !parentAbsolute) { ++ // Parent position is unknown — keep the node's current absolute position. ++ // ELK / autoFitSectionsToChildren will reconcile it during the layout phase. ++ return node; ++ } ++ ++ // Parent absolute position is at (0, 0) with no size: this indicates the section was ++ // not matched by cluster reconciliation and has no real extracted position. Keep child ++ // at its own absolute position so it isn't visually collapsed to origin. ++ if (parentAbsolute.x === 0 && parentAbsolute.y === 0 && parentAbsolute.width === 0) { ++ return node; ++ } ++ ++ return { ++ ...node, ++ position: { ++ x: absolutePosition.x - parentAbsolute.x, ++ y: absolutePosition.y - parentAbsolute.y, ++ }, ++ }; ++ }); ++ ++ const extractedEdgeBuckets = new Map(); ++ for (const edge of extracted.edges) { ++ const key = `${edge.source}::${edge.target}`; ++ const bucket = extractedEdgeBuckets.get(key) ?? []; ++ bucket.push(edge); ++ extractedEdgeBuckets.set(key, bucket); ++ } ++ ++ let matchedEdgeCount = 0; ++ const layoutedEdges = edges.map((edge) => { ++ const key = `${edge.source}::${edge.target}`; ++ const matched = extractedEdgeBuckets.get(key)?.shift(); ++ if (!matched) { ++ return { ++ ...edge, ++ data: clearStoredRouteData(edge), ++ }; ++ } ++ ++ matchedEdgeCount += 1; ++ return { ++ ...edge, ++ data: { ++ ...edge.data, ++ routingMode: 'import-fixed' as const, ++ elkPoints: undefined, ++ importRoutePoints: matched.points, ++ importRoutePath: matched.path, ++ waypoint: undefined, ++ waypoints: undefined, ++ }, ++ }; ++ }); ++ ++ return { ++ nodes: layoutedNodesWithRelativeChildren, ++ edges: layoutedEdges, ++ matchedEdgeCount, ++ }; ++} ++ ++export interface LayoutResult { ++ nodes: FlowNode[]; ++ edges: FlowEdge[]; ++ svgExtracted?: boolean; ++ layoutMode?: 'mermaid_exact' | 'mermaid_preserved_partial' | 'mermaid_partial' | 'elk_fallback'; ++ layoutFallbackReason?: string; ++} ++ ++function hasExactMermaidNodeAndSectionMatch(counts: { ++ matchedLeafNodeCount: number; ++ totalLeafNodeCount: number; ++ matchedSectionCount: number; ++ totalSectionCount: number; ++}): boolean { ++ return ( ++ counts.matchedLeafNodeCount === counts.totalLeafNodeCount ++ && counts.matchedSectionCount === counts.totalSectionCount ++ ); ++} ++ ++function hasExactMermaidEdgeMatch(counts: { ++ matchedEdgeGeometryCount: number; ++ totalEdgeCount: number; ++}): boolean { ++ return counts.matchedEdgeGeometryCount === counts.totalEdgeCount; ++} ++ ++async function getImportFallback( ++ nodes: FlowNode[], ++ edges: FlowEdge[], ++ options: ComposeDiagramForDisplayOptions, ++ layoutMode: LayoutResult['layoutMode'], ++ layoutFallbackReason?: string ++): Promise { ++ const { getElkLayout } = await import('@/services/elkLayout'); ++ const layouted = await getElkLayout(nodes, edges, { ++ direction: options.direction ?? 'TB', ++ algorithm: options.algorithm, ++ spacing: options.spacing ?? 'normal', ++ contentDensity: options.contentDensity, ++ diagramType: options.diagramType, ++ source: options.source, ++ }); ++ ++ return { ++ nodes: autoFitSectionsToChildren(layouted.nodes), ++ edges: layouted.edges, ++ layoutMode, ++ layoutFallbackReason, ++ }; ++} ++ ++/** ++ * Extracts the flowchart direction from a Mermaid source string. ++ * Returns a normalized direction key ('TB', 'LR', 'RL', 'BT') or undefined. ++ */ ++function extractMermaidDirectionFromSource(source: string): LayoutOptions['direction'] | undefined { ++ const match = source.match(/^\s*(?:flowchart|graph)\s+(LR|RL|TB|BT|TD)\b/im); ++ if (!match) return undefined; ++ const raw = match[1].toUpperCase(); ++ // TD is an alias for TB (top-down = top-bottom) ++ return (raw === 'TD' ? 'TB' : raw) as LayoutOptions['direction']; ++} ++ ++async function composeImportLayout( ++ nodes: FlowNode[], ++ edges: FlowEdge[], ++ options: ComposeDiagramForDisplayOptions ++): Promise { ++ let extractionFailureReason: string | undefined; ++ // When the official flowchart import has a partial node match we fall through to ++ // extractMermaidLayout rather than immediately going to ELK. Track the best ++ // layoutMode to report so we use 'mermaid_partial' rather than 'elk_fallback'. ++ let bestPartialLayoutMode: LayoutResult['layoutMode'] = 'elk_fallback'; ++ // When the official importer has leaf matches but section mismatches, both the official ++ // graph AND the SVG extraction path will produce incorrect child positions for the same ++ // structural reason. Skip SVG extraction and go straight to ELK in that case. ++ let skipSvgExtraction = false; ++ ++ // Extract direction from the Mermaid source if the caller didn't provide one. ++ // This ensures LR/RL/BT diagrams fall back to ELK with the correct orientation. ++ const sourceDirection = options.mermaidSource ++ ? extractMermaidDirectionFromSource(options.mermaidSource) ++ : undefined; ++ let effectiveOptions: ComposeDiagramForDisplayOptions = sourceDirection && !options.direction ++ ? { ...options, direction: sourceDirection } ++ : options; ++ ++ if (options.mermaidSource) { ++ if (options.diagramType === 'flowchart') { ++ try { ++ const { buildOfficialFlowchartImportGraph } = await import( ++ '@/services/mermaid/officialFlowchartImport' ++ ); ++ const officialGraph = await buildOfficialFlowchartImportGraph(options.mermaidSource, nodes); ++ if (officialGraph) { ++ const exactNodeAndSectionMatch = hasExactMermaidNodeAndSectionMatch(officialGraph); ++ const exactEdgeMatch = hasExactMermaidEdgeMatch(officialGraph); ++ const hasMatchedLeafNodes = officialGraph.matchedLeafNodeCount > 0; ++ ++ if (exactNodeAndSectionMatch && exactEdgeMatch) { ++ return { ++ nodes: autoFitSectionsToChildren(officialGraph.nodes), ++ edges: officialGraph.edges, ++ svgExtracted: true, ++ layoutMode: 'mermaid_exact', ++ }; ++ } ++ ++ if (exactNodeAndSectionMatch) { ++ return { ++ nodes: autoFitSectionsToChildren(officialGraph.nodes), ++ edges: officialGraph.edges, ++ svgExtracted: true, ++ layoutMode: exactEdgeMatch ? 'mermaid_exact' : 'mermaid_preserved_partial', ++ layoutFallbackReason: exactEdgeMatch ? undefined : officialGraph.reason, ++ }; ++ } ++ ++ if (hasMatchedLeafNodes) { ++ const sectionMatchComplete = ++ officialGraph.totalSectionCount === 0 || ++ officialGraph.matchedSectionCount === officialGraph.totalSectionCount; ++ ++ if (sectionMatchComplete) { ++ // All sections matched: positions are reliable, use the official graph directly. ++ return { ++ nodes: autoFitSectionsToChildren(officialGraph.nodes), ++ edges: officialGraph.edges, ++ svgExtracted: true, ++ layoutMode: 'mermaid_partial', ++ layoutFallbackReason: ++ officialGraph.reason ++ ?? `matched ${officialGraph.matchedLeafNodeCount}/${officialGraph.totalLeafNodeCount} official flowchart nodes, ${officialGraph.matchedSectionCount}/${officialGraph.totalSectionCount} official flowchart sections, and ${officialGraph.matchedEdgeGeometryCount}/${officialGraph.totalEdgeCount} official flowchart edge routes`, ++ }; ++ } ++ ++ // Sections were only partially matched: child node positions inside unmatched ++ // sections are unreliable (Bug C/D). Fall through to ELK which will compute a ++ // correct layout using the diagram's direction from the official DB. ++ skipSvgExtraction = true; ++ bestPartialLayoutMode = 'mermaid_partial'; ++ extractionFailureReason = ++ officialGraph.reason ++ ?? `matched ${officialGraph.matchedLeafNodeCount}/${officialGraph.totalLeafNodeCount} official flowchart nodes, ${officialGraph.matchedSectionCount}/${officialGraph.totalSectionCount} official flowchart sections`; ++ } ++ ++ // Promote direction from the official DB (more authoritative than regex). ++ if (officialGraph.direction && !options.direction) { ++ effectiveOptions = { ...effectiveOptions, direction: officialGraph.direction as LayoutOptions['direction'] }; ++ } ++ ++ extractionFailureReason = ++ officialGraph.reason ++ ?? `matched ${officialGraph.matchedLeafNodeCount}/${officialGraph.totalLeafNodeCount} official flowchart nodes`; ++ bestPartialLayoutMode = 'mermaid_partial'; ++ } ++ } catch (error) { ++ extractionFailureReason = error instanceof Error ? error.message : String(error); ++ } ++ } ++ ++ if (!skipSvgExtraction) { ++ try { ++ const { extractMermaidLayout } = await import('@/services/mermaid/extractLayoutFromSvg'); ++ const extracted = await extractMermaidLayout(options.mermaidSource, nodes); ++ if (extracted) { ++ const result = applyExtractedLayout(nodes, edges, extracted); ++ const exactNodeAndSectionMatch = hasExactMermaidNodeAndSectionMatch(extracted); ++ const exactEdgeMatch = result.matchedEdgeCount === edges.length; ++ ++ if (exactNodeAndSectionMatch && exactEdgeMatch) { ++ return { ++ nodes: autoFitSectionsToChildren(result.nodes), ++ edges: result.edges, ++ svgExtracted: true, ++ layoutMode: 'mermaid_exact', ++ }; ++ } ++ ++ if (exactNodeAndSectionMatch) { ++ return { ++ nodes: autoFitSectionsToChildren(result.nodes), ++ edges: result.edges, ++ svgExtracted: true, ++ layoutMode: exactEdgeMatch ? 'mermaid_exact' : 'mermaid_preserved_partial', ++ layoutFallbackReason: exactEdgeMatch ++ ? undefined ++ : extracted.reason ++ ?? `matched ${result.matchedEdgeCount}/${edges.length} edges while preserving Mermaid node geometry`, ++ }; ++ } ++ ++ // We do NOT return for structurally partial node imports anymore. ++ // If exactNodeAndSectionMatch is false, some nodes or sections are missing geometry. ++ // Because default parsed positions are {x: 0, y: 0}, unmatched nodes will stack ++ // at the origin. We must fall through to ELK to layout the entire diagram holistically. ++ bestPartialLayoutMode = 'mermaid_partial'; ++ extractionFailureReason = ++ extracted.reason ++ ?? `matched ${extracted.matchedLeafNodeCount}/${extracted.totalLeafNodeCount} nodes and ${extracted.matchedSectionCount}/${extracted.totalSectionCount} sections`; ++ } ++ } catch (error) { ++ extractionFailureReason = error instanceof Error ? error.message : String(error); ++ } ++ } // end if (!skipSvgExtraction) ++ } ++ ++ return getImportFallback( ++ nodes, ++ edges, ++ effectiveOptions, ++ bestPartialLayoutMode, ++ options.mermaidSource ++ ? extractionFailureReason ?? 'Mermaid SVG extraction unavailable' ++ : undefined ++ ); ++} ++ + export async function composeDiagramForDisplay( + nodes: FlowNode[], + edges: FlowEdge[], + options: ComposeDiagramForDisplayOptions = {} +-): Promise<{ nodes: FlowNode[]; edges: FlowEdge[] }> { ++): Promise { + if (nodes.length === 0) { + return { nodes, edges }; + } +@@ -58,7 +436,18 @@ export async function composeDiagramForDisplay( + } + + if (options.diagramType === 'sequence') { +- return relayoutSequenceDiagram(nodes, edges); ++ return { ++ ...relayoutSequenceDiagram(nodes, edges), ++ layoutMode: options.source === 'import' ? 'elk_fallback' : undefined, ++ layoutFallbackReason: ++ options.source === 'import' ++ ? 'Sequence diagrams use the dedicated sequence layout engine' ++ : undefined, ++ }; ++ } ++ ++ if (options.source === 'import') { ++ return composeImportLayout(nodes, edges, options); + } + + const { getElkLayout } = await import('@/services/elkLayout'); diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index 95b7dcbe..da2e1ce0 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -16,7 +16,7 @@ async function createNewFlow(page: import('@playwright/test').Page) { await expect(page.getByTestId('home-create-new-main')).toBeVisible({ timeout: 15000 }); await page.getByTestId('home-create-new-main').click(); await expect(page).toHaveURL(/#\/flow\/[^?]+(?:\?.*)?$/); - await expect(page.getByTestId('flow-page-tab').first()).toBeVisible(); + await expect(page.getByTestId('flow-page-tab').first()).toBeVisible({ timeout: 15000 }); await expect(page.getByTestId('topnav-menu-toggle')).toBeVisible({ timeout: 15000 }); } diff --git a/src/components/ExportMenu.tsx b/src/components/ExportMenu.tsx index f4f0eddb..414b7fdd 100644 --- a/src/components/ExportMenu.tsx +++ b/src/components/ExportMenu.tsx @@ -18,8 +18,8 @@ const LazyExportMenuPanel = lazy(async () => { }); interface ExportMenuProps { - onExportPNG: (format: 'png' | 'jpeg') => void; - onCopyImage: (format: 'png' | 'jpeg') => void; + onExportPNG: (format: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + onCopyImage: (format: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; onExportSVG: () => void; onCopySVG: () => void; onExportPDF: () => void; @@ -28,13 +28,11 @@ interface ExportMenuProps { onCopyJSON: () => void; onExportMermaid: () => void; onDownloadMermaid: () => void; - onExportPlantUML: () => void; onDownloadPlantUML: () => void; onExportOpenFlowDSL: () => void; onDownloadOpenFlowDSL: () => void; onExportFigma: () => void; onDownloadFigma: () => void; - onShare: () => void; cinematicSpeed?: CinematicExportSpeed; onCinematicSpeedChange?: (speed: CinematicExportSpeed) => void; cinematicResolution?: CinematicExportResolution; @@ -53,13 +51,11 @@ export const ExportMenu: React.FC = ({ onCopyJSON, onExportMermaid, onDownloadMermaid, - onExportPlantUML, onDownloadPlantUML, onExportOpenFlowDSL, onDownloadOpenFlowDSL, onExportFigma, onDownloadFigma, - onShare, cinematicSpeed, onCinematicSpeedChange, cinematicResolution, @@ -101,18 +97,16 @@ export const ExportMenu: React.FC = ({ onCopyJSON, onExportMermaid, onDownloadMermaid, - onExportPlantUML, onDownloadPlantUML, onExportOpenFlowDSL, onDownloadOpenFlowDSL, onExportFigma, onDownloadFigma, - onShare, }); return (
- +
)} + {shouldShowTransparentBackgroundToggle ? ( + + ) : null} + {activeSectionKey === 'video' && onCinematicSpeedChange && (

@@ -333,7 +335,12 @@ export function ExportMenuPanel({ key={`${selectedItem.key}-${action}`} type="button" variant={action === 'download' ? 'primary' : 'secondary'} - onClick={() => onSelect(selectedItem.key, action)} + onClick={() => + onSelect(selectedItem.key, action, { + transparentBackground: + selectedItem.key === 'png' ? transparentBackground : undefined, + }) + } data-testid={`export-action-${selectedItem.key}-${action}`} className="h-11 w-full" > diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 559efe8b..c5e99baa 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -25,8 +25,8 @@ interface TopNavProps { onReorderPage: (draggedPageId: string, targetPageId: string) => void; // Actions - onExportPNG: (format?: 'png' | 'jpeg') => void; - onCopyImage: (format?: 'png' | 'jpeg') => void; + onExportPNG: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + onCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; onExportSVG: () => void; onCopySVG: () => void; onExportPDF: () => void; @@ -35,13 +35,11 @@ interface TopNavProps { onCopyJSON: () => void; onExportMermaid: () => void; onDownloadMermaid: () => void; - onExportPlantUML: () => void; onDownloadPlantUML: () => void; onExportOpenFlowDSL: () => void; onDownloadOpenFlowDSL: () => void; onExportFigma: () => void; onDownloadFigma: () => void; - onShare: () => void; onImportJSON: () => void; onHistory: () => void; onGoHome: () => void; @@ -80,13 +78,11 @@ export function TopNav({ onCopyJSON, onExportMermaid, onDownloadMermaid, - onExportPlantUML, onDownloadPlantUML, onExportOpenFlowDSL, onDownloadOpenFlowDSL, onExportFigma, onDownloadFigma, - onShare, onImportJSON, onHistory, onGoHome, @@ -163,13 +159,11 @@ export function TopNav({ onCopyJSON={onCopyJSON} onExportMermaid={onExportMermaid} onDownloadMermaid={onDownloadMermaid} - onExportPlantUML={onExportPlantUML} onDownloadPlantUML={onDownloadPlantUML} onExportOpenFlowDSL={onExportOpenFlowDSL} onDownloadOpenFlowDSL={onDownloadOpenFlowDSL} onExportFigma={onExportFigma} onDownloadFigma={onDownloadFigma} - onShare={onShare} collaboration={collaboration} isBeveled={isBeveled} /> diff --git a/src/components/flow-editor/FlowEditorChrome.tsx b/src/components/flow-editor/FlowEditorChrome.tsx index 28a89467..1b2536a8 100644 --- a/src/components/flow-editor/FlowEditorChrome.tsx +++ b/src/components/flow-editor/FlowEditorChrome.tsx @@ -64,8 +64,8 @@ export interface FlowEditorChromeProps { onClosePage: (pageId: string) => void; onRenamePage: (pageId: string, newName: string) => void; onReorderPage: (draggedPageId: string, targetPageId: string) => void; - onExportPNG: (format?: 'png' | 'jpeg') => void; - onCopyImage: (format?: 'png' | 'jpeg') => void; + onExportPNG: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + onCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; onExportSVG: () => void; onCopySVG: () => void; onExportPDF: () => void; @@ -74,13 +74,11 @@ export interface FlowEditorChromeProps { onCopyJSON: () => void; onExportMermaid: () => void; onDownloadMermaid: () => void; - onExportPlantUML: () => void; onDownloadPlantUML: () => void; onExportOpenFlowDSL: () => void; onDownloadOpenFlowDSL: () => void; onExportFigma: () => void; onDownloadFigma: () => void; - onShare: () => void; onImportJSON: () => void; onHistory: () => void; onGoHome: () => void; @@ -180,13 +178,11 @@ export function FlowEditorChrome({ onCopyJSON: topNav.onCopyJSON, onExportMermaid: topNav.onExportMermaid, onDownloadMermaid: topNav.onDownloadMermaid, - onExportPlantUML: topNav.onExportPlantUML, onDownloadPlantUML: topNav.onDownloadPlantUML, onExportOpenFlowDSL: topNav.onExportOpenFlowDSL, onDownloadOpenFlowDSL: topNav.onDownloadOpenFlowDSL, onExportFigma: topNav.onExportFigma, onDownloadFigma: topNav.onDownloadFigma, - onShare: topNav.onShare, onImportJSON: topNav.onImportJSON, onHistory: topNav.onHistory, onGoHome: topNav.onGoHome, diff --git a/src/components/flow-editor/buildFlowEditorControllerParams.ts b/src/components/flow-editor/buildFlowEditorControllerParams.ts index 4d4da21c..576b2bcb 100644 --- a/src/components/flow-editor/buildFlowEditorControllerParams.ts +++ b/src/components/flow-editor/buildFlowEditorControllerParams.ts @@ -58,8 +58,8 @@ interface BuildFlowEditorControllerChromeParams { handleClosePage: (pageId: string) => void; handleRenamePage: (pageId: string, newName: string) => void; handleReorderPage: (draggedPageId: string, targetPageId: string) => void; - handleExport: (format?: 'png' | 'jpeg') => void; - handleCopyImage: (format?: 'png' | 'jpeg') => void; + handleExport: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + handleCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; handleSvgExport: () => void; handleCopySvg: () => void; handlePdfExport: () => void; @@ -68,13 +68,11 @@ interface BuildFlowEditorControllerChromeParams { handleCopyJSON: () => void; handleExportMermaid: () => void; handleDownloadMermaid: () => void; - handleExportPlantUML: () => void; handleDownloadPlantUML: () => void; handleExportOpenFlowDSL: () => void; handleDownloadOpenFlowDSL: () => void; handleExportFigma: () => void; handleDownloadFigma: () => void; - handleShare: () => void; handleImportJSON: () => void; openHistory: () => void; onGoHome: () => void; diff --git a/src/components/flow-editor/chromePropTypes.ts b/src/components/flow-editor/chromePropTypes.ts index 75465e52..f3a66f9d 100644 --- a/src/components/flow-editor/chromePropTypes.ts +++ b/src/components/flow-editor/chromePropTypes.ts @@ -9,8 +9,8 @@ export interface BuildTopNavParams { handleClosePage: (pageId: string) => void; handleRenamePage: (pageId: string, newName: string) => void; handleReorderPage: (draggedPageId: string, targetPageId: string) => void; - handleExport: (format?: 'png' | 'jpeg') => void; - handleCopyImage: (format?: 'png' | 'jpeg') => void; + handleExport: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + handleCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; handleSvgExport: () => void; handleCopySvg: () => void; handlePdfExport: () => void; @@ -19,13 +19,11 @@ export interface BuildTopNavParams { handleCopyJSON: () => void; handleExportMermaid: () => void; handleDownloadMermaid: () => void; - handleExportPlantUML: () => void; handleDownloadPlantUML: () => void; handleExportOpenFlowDSL: () => void; handleDownloadOpenFlowDSL: () => void; handleExportFigma: () => void; handleDownloadFigma: () => void; - handleShare: () => void; handleImportJSON: () => void; openHistory: () => void; onGoHome: () => void; diff --git a/src/components/flow-editor/flowEditorChromeProps.ts b/src/components/flow-editor/flowEditorChromeProps.ts index dd068968..57e47eb7 100644 --- a/src/components/flow-editor/flowEditorChromeProps.ts +++ b/src/components/flow-editor/flowEditorChromeProps.ts @@ -24,13 +24,11 @@ export function buildFlowEditorTopNavProps({ handleCopyJSON, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, handleImportJSON, openHistory, onGoHome, @@ -53,13 +51,11 @@ export function buildFlowEditorTopNavProps({ onCopyJSON: handleCopyJSON, onExportMermaid: handleExportMermaid, onDownloadMermaid: handleDownloadMermaid, - onExportPlantUML: handleExportPlantUML, onDownloadPlantUML: handleDownloadPlantUML, onExportOpenFlowDSL: handleExportOpenFlowDSL, onDownloadOpenFlowDSL: handleDownloadOpenFlowDSL, onExportFigma: handleExportFigma, onDownloadFigma: handleDownloadFigma, - onShare: handleShare, onImportJSON: handleImportJSON, onHistory: openHistory, onGoHome, @@ -201,13 +197,11 @@ export function useFlowEditorChromeProps( handleCopyJSON, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, handleImportJSON, openHistory, onGoHome, @@ -269,13 +263,11 @@ export function useFlowEditorChromeProps( handleCopyJSON, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, handleImportJSON, openHistory, onGoHome, @@ -298,13 +290,11 @@ export function useFlowEditorChromeProps( handleCopyJSON, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, handleImportJSON, openHistory, onGoHome, diff --git a/src/components/flow-editor/useFlowEditorController.ts b/src/components/flow-editor/useFlowEditorController.ts index 7acee867..3181a458 100644 --- a/src/components/flow-editor/useFlowEditorController.ts +++ b/src/components/flow-editor/useFlowEditorController.ts @@ -149,8 +149,8 @@ export interface UseFlowEditorChromeParams { handleClosePage: (pageId: string) => void; handleRenamePage: (pageId: string, newName: string) => void; handleReorderPage: (draggedPageId: string, targetPageId: string) => void; - handleExport: (format?: 'png' | 'jpeg') => void; - handleCopyImage: (format?: 'png' | 'jpeg') => void; + handleExport: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + handleCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; handleSvgExport: () => void; handleCopySvg: () => void; handlePdfExport: () => void; @@ -159,13 +159,11 @@ export interface UseFlowEditorChromeParams { handleCopyJSON: () => void; handleExportMermaid: () => void; handleDownloadMermaid: () => void; - handleExportPlantUML: () => void; handleDownloadPlantUML: () => void; handleExportOpenFlowDSL: () => void; handleDownloadOpenFlowDSL: () => void; handleExportFigma: () => void; handleDownloadFigma: () => void; - handleShare: () => void; handleImportJSON: () => void; openHistory: () => void; onGoHome: () => void; diff --git a/src/components/flow-editor/useFlowEditorScreenModel.ts b/src/components/flow-editor/useFlowEditorScreenModel.ts index 2e93d7eb..1f0855d9 100644 --- a/src/components/flow-editor/useFlowEditorScreenModel.ts +++ b/src/components/flow-editor/useFlowEditorScreenModel.ts @@ -118,13 +118,11 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP handleInsertTemplate, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, shareViewerUrl, clearShareViewerUrl, } = useFlowEditorRuntime({ @@ -285,13 +283,11 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP handleCopyJSON, handleExportMermaid, handleDownloadMermaid, - handleExportPlantUML, handleDownloadPlantUML, handleExportOpenFlowDSL, handleDownloadOpenFlowDSL, handleExportFigma, handleDownloadFigma, - handleShare, handleImportJSON, openHistory: screenState.openHistory, onGoHome, diff --git a/src/components/top-nav/TopNavActions.tsx b/src/components/top-nav/TopNavActions.tsx index fa8aac32..abe0ae24 100644 --- a/src/components/top-nav/TopNavActions.tsx +++ b/src/components/top-nav/TopNavActions.tsx @@ -30,8 +30,8 @@ interface CollaborationState { interface TopNavActionsProps { onPlay: () => void; - onExportPNG: (format?: 'png' | 'jpeg') => void; - onCopyImage: (format?: 'png' | 'jpeg') => void; + onExportPNG: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; + onCopyImage: (format?: 'png' | 'jpeg', options?: { transparentBackground?: boolean }) => void; onExportSVG: () => void; onCopySVG: () => void; onExportPDF: () => void; @@ -40,13 +40,11 @@ interface TopNavActionsProps { onCopyJSON: () => void; onExportMermaid: () => void; onDownloadMermaid: () => void; - onExportPlantUML: () => void; onDownloadPlantUML: () => void; onExportOpenFlowDSL: () => void; onDownloadOpenFlowDSL: () => void; onExportFigma: () => void; onDownloadFigma: () => void; - onShare: () => void; collaboration?: CollaborationState; isBeveled: boolean; } @@ -115,13 +113,11 @@ export function TopNavActions({ onCopyJSON, onExportMermaid, onDownloadMermaid, - onExportPlantUML, onDownloadPlantUML, onExportOpenFlowDSL, onDownloadOpenFlowDSL, onExportFigma, onDownloadFigma, - onShare, collaboration, isBeveled, }: TopNavActionsProps): React.ReactElement { @@ -202,13 +198,11 @@ export function TopNavActions({ onCopyJSON={onCopyJSON} onExportMermaid={onExportMermaid} onDownloadMermaid={onDownloadMermaid} - onExportPlantUML={onExportPlantUML} onDownloadPlantUML={onDownloadPlantUML} onExportOpenFlowDSL={onExportOpenFlowDSL} onDownloadOpenFlowDSL={onDownloadOpenFlowDSL} onExportFigma={onExportFigma} onDownloadFigma={onDownloadFigma} - onShare={onShare} cinematicThemeMode={resolvedTheme} />

diff --git a/src/components/useExportMenu.test.tsx b/src/components/useExportMenu.test.tsx index d3739b6c..38fe50f6 100644 --- a/src/components/useExportMenu.test.tsx +++ b/src/components/useExportMenu.test.tsx @@ -30,13 +30,11 @@ const baseProps = { onCopyJSON: vi.fn(), onExportMermaid: vi.fn(), onDownloadMermaid: vi.fn(), - onExportPlantUML: vi.fn(), onDownloadPlantUML: vi.fn(), onExportOpenFlowDSL: vi.fn(), onDownloadOpenFlowDSL: vi.fn(), onExportFigma: vi.fn(), onDownloadFigma: vi.fn(), - onShare: vi.fn(), }; function Harness(): React.ReactElement { diff --git a/src/components/useExportMenu.ts b/src/components/useExportMenu.ts index 3fa0d5ca..6fca789c 100644 --- a/src/components/useExportMenu.ts +++ b/src/components/useExportMenu.ts @@ -6,8 +6,8 @@ import { recordOnboardingEvent } from '@/services/onboarding/events'; import { useToast } from './ui/ToastContext'; interface UseExportMenuParams { - onExportPNG: (format: 'png' | 'jpeg') => void; - onCopyImage: (format: 'png' | 'jpeg') => void; + onExportPNG: (format: 'png' | 'jpeg', options?: ExportImageActionOptions) => void; + onCopyImage: (format: 'png' | 'jpeg', options?: ExportImageActionOptions) => void; onExportSVG: () => void; onCopySVG: () => void; onExportPDF: () => void; @@ -17,25 +17,32 @@ interface UseExportMenuParams { onCopyJSON: () => void; onExportMermaid: () => void; onDownloadMermaid: () => void; - onExportPlantUML: () => void; onDownloadPlantUML: () => void; onExportOpenFlowDSL: () => void; onDownloadOpenFlowDSL: () => void; onExportFigma: () => void; onDownloadFigma: () => void; - onShare?: () => void; } type ExportActionKey = 'download' | 'copy'; type ExportActionHandler = () => void | Promise; type ExportActionHandlers = Record; +interface ExportImageActionOptions { + transparentBackground?: boolean; +} +type ExportSelectionOptions = ExportImageActionOptions; + interface UseExportMenuResult { isOpen: boolean; menuRef: RefObject; toggleMenu: () => void; closeMenu: () => void; - handleSelect: (key: string, action: ExportActionKey) => Promise; + handleSelect: ( + key: string, + action: ExportActionKey, + options?: ExportSelectionOptions + ) => Promise; } function isSelectPortalTarget(target: EventTarget | null): boolean { @@ -58,13 +65,11 @@ export function useExportMenu({ onCopyJSON, onExportMermaid, onDownloadMermaid, - onExportPlantUML, onDownloadPlantUML, onExportOpenFlowDSL, onDownloadOpenFlowDSL, onExportFigma, onDownloadFigma, - onShare: onShareAction, }: UseExportMenuParams): UseExportMenuResult { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -114,22 +119,29 @@ export function useExportMenu({ }; }, [isOpen]); - const handlers: Record = { - png: { download: () => onExportPNG('png'), copy: () => onCopyImage('png') }, - jpeg: { download: () => onExportPNG('jpeg'), copy: () => onCopyImage('jpeg') }, - svg: { download: onExportSVG, copy: onCopySVG }, - pdf: { download: onExportPDF, copy: onExportPDF }, - 'cinematic-video': { - download: () => onExportCinematic(getCinematicExportRequest()), - copy: () => onExportCinematic(getCinematicExportRequest()), - }, - json: { download: onExportJSON, copy: onCopyJSON }, - openflow: { download: onDownloadOpenFlowDSL, copy: onExportOpenFlowDSL }, - mermaid: { download: onDownloadMermaid, copy: onExportMermaid }, - plantuml: { download: onDownloadPlantUML, copy: onExportPlantUML }, - figma: { download: onDownloadFigma, copy: onExportFigma }, - share: { download: () => onShareAction?.(), copy: () => onShareAction?.() }, - }; + function getHandlers(options?: ExportSelectionOptions): Record { + return { + png: { + download: () => onExportPNG('png', options), + copy: () => onCopyImage('png', options), + }, + jpeg: { + download: () => onExportPNG('jpeg', options), + copy: () => onCopyImage('jpeg', options), + }, + svg: { download: onExportSVG, copy: onCopySVG }, + pdf: { download: onExportPDF, copy: onExportPDF }, + 'cinematic-video': { + download: () => onExportCinematic(getCinematicExportRequest()), + copy: () => onExportCinematic(getCinematicExportRequest()), + }, + json: { download: onExportJSON, copy: onCopyJSON }, + openflow: { download: onDownloadOpenFlowDSL, copy: onExportOpenFlowDSL }, + mermaid: { download: onDownloadMermaid, copy: onExportMermaid }, + plantuml: { download: onDownloadPlantUML, copy: onDownloadPlantUML }, + figma: { download: onDownloadFigma, copy: onExportFigma }, + }; + } function toggleMenu(): void { setIsOpen((value) => !value); @@ -143,8 +155,12 @@ export function useExportMenu({ }); } - async function handleSelect(key: string, action: ExportActionKey): Promise { - const actionHandler = handlers[key]?.[action]; + async function handleSelect( + key: string, + action: ExportActionKey, + options?: ExportSelectionOptions + ): Promise { + const actionHandler = getHandlers(options)[key]?.[action]; if (!actionHandler) { return; } diff --git a/src/hooks/flow-editor-actions/exportHandlers.test.ts b/src/hooks/flow-editor-actions/exportHandlers.test.ts index b7f96dbe..ae5d99e0 100644 --- a/src/hooks/flow-editor-actions/exportHandlers.test.ts +++ b/src/hooks/flow-editor-actions/exportHandlers.test.ts @@ -44,8 +44,14 @@ function createTranslator(fn: (key: string, options?: Record) = describe('exportHandlers', () => { beforeEach(() => { vi.restoreAllMocks(); + Object.assign(globalThis, { + ClipboardItem: class ClipboardItem { + constructor(public readonly items: Record) {} + }, + }); Object.assign(navigator, { clipboard: { + write: vi.fn().mockResolvedValue(undefined), writeText: vi.fn().mockResolvedValue(undefined), }, }); @@ -102,6 +108,7 @@ describe('exportHandlers', () => { it('shows an error toast when Figma export copy fails', async () => { const addToast = vi.fn(); vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(navigator.clipboard, 'write').mockRejectedValueOnce(new Error('copy failed')); vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('copy failed')); await exportFigmaToClipboard({ @@ -114,6 +121,21 @@ describe('exportHandlers', () => { expect(addToast).toHaveBeenCalledWith('flowEditor.figmaExportFailed:copy failed', 'error'); }); + it('uses structured clipboard data for Figma export when supported', async () => { + const addToast = vi.fn(); + + await exportFigmaToClipboard({ + nodes: [createNode('n1')], + edges: [createEdge('e1', 'n1', 'n1')], + addToast, + t: createTranslator((key: string) => key), + }); + + expect(navigator.clipboard.write).toHaveBeenCalledTimes(1); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith('flowEditor.figmaCopied', 'success'); + }); + it('shows an error toast when a text download fails', () => { const addToast = vi.fn(); vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { diff --git a/src/hooks/flow-editor-actions/exportHandlers.ts b/src/hooks/flow-editor-actions/exportHandlers.ts index cb161b53..741263ae 100644 --- a/src/hooks/flow-editor-actions/exportHandlers.ts +++ b/src/hooks/flow-editor-actions/exportHandlers.ts @@ -47,6 +47,36 @@ interface ExportFigmaParams extends ExportFlowDiagramParams { baseFileName?: string; } +async function copySvgToClipboard(svg: string): Promise { + const svgBlob = new Blob([svg], { type: 'image/svg+xml' }); + let clipboardError: unknown; + + if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/svg+xml': svgBlob, + 'text/plain': new Blob([svg], { type: 'text/plain' }), + }), + ]); + return true; + } catch (error) { + clipboardError = error; + } + } + + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(svg); + return true; + } + + if (clipboardError) { + throw clipboardError; + } + + return copyTextToClipboard(svg); +} + async function exportFlowTextToClipboard({ text, successMessage, @@ -204,7 +234,11 @@ export async function exportFigmaToClipboard({ try { addToast('Copying Figma SVG…', 'info'); const svg = await toFigmaSVG(nodes, edges); - await navigator.clipboard.writeText(svg); + const copied = await copySvgToClipboard(svg); + if (!copied) { + throw new Error('Clipboard access was unavailable.'); + } + addToast(t('flowEditor.figmaCopied'), 'success'); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/hooks/flow-export/exportCapture.test.ts b/src/hooks/flow-export/exportCapture.test.ts index edd805d7..d8dbdd86 100644 --- a/src/hooks/flow-export/exportCapture.test.ts +++ b/src/hooks/flow-export/exportCapture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { renderDecodedFrame } from './exportCapture'; +import { createExportOptions, renderDecodedFrame } from './exportCapture'; function createMockContext() { return { @@ -44,4 +44,20 @@ describe('renderDecodedFrame', () => { expect(context.clearRect).not.toHaveBeenCalled(); expect(context.drawImage).toHaveBeenCalledWith(image, 0, 0, 960, 540); }); + + it('uses a solid background for PNG exports unless transparency is explicitly requested', () => { + const nodes = [ + { + id: 'node-1', + position: { x: 0, y: 0 }, + data: { label: 'Node 1' }, + }, + ] as never[]; + + expect(createExportOptions(nodes, 'png').options.backgroundColor).toBe('#ffffff'); + expect( + createExportOptions(nodes, 'png', { transparentBackground: true }).options.backgroundColor + ).toBeNull(); + expect(createExportOptions(nodes, 'jpeg').options.backgroundColor).toBe('#ffffff'); + }); }); diff --git a/src/hooks/flow-export/exportCapture.ts b/src/hooks/flow-export/exportCapture.ts index 8fb3f6e1..aba540de 100644 --- a/src/hooks/flow-export/exportCapture.ts +++ b/src/hooks/flow-export/exportCapture.ts @@ -7,6 +7,7 @@ export type ExportImageFormat = 'png' | 'jpeg'; export interface ExportCaptureConfig { maxDimension?: number; pixelRatio?: number; + transparentBackground?: boolean; } export interface CapturedFrame { @@ -143,10 +144,37 @@ async function copyBlobToClipboard(blob: Blob): Promise { await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); } +function dataUrlToBlob(dataUrl: string): Blob { + const [header, body] = dataUrl.split(',', 2); + if (!header || body === undefined) { + throw new Error('Invalid exported image data.'); + } + + const mimeMatch = header.match(/^data:(.*?)(;base64)?$/); + const mimeType = mimeMatch?.[1] ?? 'application/octet-stream'; + const byteString = header.includes(';base64') ? atob(body) : decodeURIComponent(body); + const bytes = new Uint8Array(byteString.length); + + for (let index = 0; index < byteString.length; index += 1) { + bytes[index] = byteString.charCodeAt(index); + } + + return new Blob([bytes], { type: mimeType }); +} + export async function copyDataUrlToClipboard(dataUrl: string): Promise { - const response = await fetch(dataUrl); - const blob = await response.blob(); - await copyBlobToClipboard(blob); + const blob = dataUrlToBlob(dataUrl); + + try { + await copyBlobToClipboard(blob); + } catch (error) { + if (blob.type === 'image/png') { + throw error; + } + + const pngBlob = await convertBlobToPng(blob); + await copyBlobToClipboard(pngBlob); + } } function shouldIncludeExportNode(node: HTMLElement): boolean { @@ -175,7 +203,7 @@ export function createExportOptions( width, height, options: { - backgroundColor: format === 'png' ? null : '#ffffff', + backgroundColor: format === 'png' && config?.transparentBackground ? null : '#ffffff', width, height, style: { @@ -199,6 +227,28 @@ function loadImage(dataUrl: string): Promise { }); } +async function convertBlobToPng(blob: Blob): Promise { + const imageUrl = URL.createObjectURL(blob); + + try { + const image = await loadImage(imageUrl); + const { canvas, context } = createExportCanvas(image.width, image.height); + context.drawImage(image, 0, 0); + + const pngBlob = await new Promise((resolve) => { + canvas.toBlob((nextBlob) => resolve(nextBlob), 'image/png'); + }); + + if (!pngBlob) { + throw new Error('Failed to convert the copied image to PNG.'); + } + + return pngBlob; + } finally { + URL.revokeObjectURL(imageUrl); + } +} + async function decodeCapturedFrame( dataUrl: string, signal?: AbortSignal diff --git a/src/hooks/useFlowEditorCallbacks.ts b/src/hooks/useFlowEditorCallbacks.ts index a8171e11..012e1489 100644 --- a/src/hooks/useFlowEditorCallbacks.ts +++ b/src/hooks/useFlowEditorCallbacks.ts @@ -135,7 +135,13 @@ export function useFlowEditorCallbacks({ || newEdges.some((edge) => edge.data?.routingMode === 'import-fixed'); const sanitizedNodes = newNodes.map(stripMermaidImportApplyFlag); - const enrichedNodes = (await enrichNodesWithIcons(sanitizedNodes)).map((node) => ({ + // Use the strict mermaid-import mode (0.92 threshold, no label-based guessing) for + // Mermaid imports to prevent false-positive icons on generic labels like "Service" or + // "API". General mode (0.8 threshold) is appropriate for AI-generated nodes which use + // explicit archProvider / archResourceType hints. + const enrichedNodes = (await enrichNodesWithIcons(sanitizedNodes, { + mode: incomingMermaidImport ? 'mermaid-import' : 'general', + })).map((node) => ({ ...node, data: normalizeNodeIconData(node.data), })); diff --git a/src/hooks/useStaticExport.ts b/src/hooks/useStaticExport.ts index cfff9f8b..006202a5 100644 --- a/src/hooks/useStaticExport.ts +++ b/src/hooks/useStaticExport.ts @@ -8,6 +8,10 @@ import type { FlowNode } from '@/lib/types'; const logger = createLogger({ scope: 'useStaticExport' }); +export interface StaticImageExportOptions { + transparentBackground?: boolean; +} + export const useStaticExport = ( nodes: FlowNode[], reactFlowWrapper: React.RefObject, @@ -15,7 +19,7 @@ export const useStaticExport = ( exportBaseName: string | undefined ) => { const handleExport = useCallback( - (format: 'png' | 'jpeg' = 'png') => { + (format: 'png' | 'jpeg' = 'png', exportOptions?: StaticImageExportOptions) => { const { viewport: flowViewport, message } = resolveFlowExportViewport( reactFlowWrapper.current ); @@ -28,7 +32,9 @@ export const useStaticExport = ( addToast(`Preparing ${format.toUpperCase()} download…`, 'info'); setTimeout(() => { - const { options } = createExportOptions(nodes, format); + const { options } = createExportOptions(nodes, format, { + transparentBackground: exportOptions?.transparentBackground, + }); const exportPromise = format === 'png' ? toPng(flowViewport, options) : toJpeg(flowViewport, options); @@ -54,7 +60,7 @@ export const useStaticExport = ( ); const handleCopyImage = useCallback( - (format: 'png' | 'jpeg' = 'png') => { + (format: 'png' | 'jpeg' = 'png', exportOptions?: StaticImageExportOptions) => { const { viewport: flowViewport, message } = resolveFlowExportViewport( reactFlowWrapper.current ); @@ -67,7 +73,9 @@ export const useStaticExport = ( addToast(`Preparing ${format.toUpperCase()} copy…`, 'info'); setTimeout(() => { - const { options } = createExportOptions(nodes, format); + const { options } = createExportOptions(nodes, format, { + transparentBackground: exportOptions?.transparentBackground, + }); const exportPromise = format === 'png' ? toPng(flowViewport, options) : toJpeg(flowViewport, options); diff --git a/src/lib/nodeEnricher.ts b/src/lib/nodeEnricher.ts index c54449b8..86ab1f0e 100644 --- a/src/lib/nodeEnricher.ts +++ b/src/lib/nodeEnricher.ts @@ -7,6 +7,9 @@ import { isSpecificTechnologyIconQuery, } from '@/lib/semanticClassifier'; import { matchIcon, type IconMatch } from '@/lib/iconMatcher'; +import { createLogger } from '@/lib/logger'; + +const logger = createLogger({ scope: 'nodeEnricher' }); export interface EnrichNodesWithIconsOptions { diagramType?: DiagramType; @@ -23,10 +26,7 @@ const DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT = new Set([ 'journey', ]); -function withNormalizedNodeData( - node: FlowNode, - dataOverrides?: Record -): FlowNode { +function withNormalizedNodeData(node: FlowNode, dataOverrides?: Record): FlowNode { return { ...node, data: normalizeNodeIconData({ @@ -63,11 +63,11 @@ function isTrustedImportMatch(match: IconMatch, query: string): boolean { } return ( - match.confidence === 'high' - && match.wholeTokenMatch - && !match.isGeneric - && !isCommonEnglishIconTerm(query) - && match.runnerUpDelta >= 0.08 + match.confidence === 'high' && + match.wholeTokenMatch && + !match.isGeneric && + !isCommonEnglishIconTerm(query) && + match.runnerUpDelta >= 0.08 ); } @@ -78,7 +78,8 @@ export function enrichNodesWithIcons( return nodes.map((node) => { try { return enrichSingleNode(node, options); - } catch { + } catch (err) { + logger.debug('Node enrichment failed, using original', { nodeId: node.id, error: err }); return node; } }); @@ -194,9 +195,9 @@ function getIconEnrichmentPolicy(options: EnrichNodesWithIconsOptions): { ? IMPORT_ICON_MATCH_THRESHOLD : DEFAULT_ICON_MATCH_THRESHOLD; const iconEnrichmentAllowed = - !strictImportMode - || !options.diagramType - || !DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT.has(options.diagramType); + !strictImportMode || + !options.diagramType || + !DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT.has(options.diagramType); return { iconMatchThreshold, @@ -235,10 +236,7 @@ function shouldUseClassifierIconQuery( return !isCommonEnglishIconTerm(iconQuery); } -function shouldUseLabelFallback( - label: string, - options: EnrichNodesWithIconsOptions -): boolean { +function shouldUseLabelFallback(label: string, options: EnrichNodesWithIconsOptions): boolean { if (options.mode === 'mermaid-import') { return false; } diff --git a/src/services/elkLayout.ts b/src/services/elkLayout.ts index f5613cb3..5134c878 100644 --- a/src/services/elkLayout.ts +++ b/src/services/elkLayout.ts @@ -56,8 +56,15 @@ const ELK_COMPOUND_LAYOUT_OPTIONS = { 'elk.algorithm': 'layered', } as const; -const layoutCache = new Map(); +interface CacheEntry { + nodes: FlowNode[]; + edges: FlowEdge[]; + timestamp: number; +} + +const layoutCache = new Map(); const LAYOUT_CACHE_MAX = 20; +const LAYOUT_CACHE_TTL_MS = 60_000; function getLayoutCacheKey(nodes: FlowNode[], edges: FlowEdge[], options: LayoutOptions): string { const nodeStr = nodes @@ -80,6 +87,24 @@ export function clearLayoutCache(): void { layoutCache.clear(); } +function getCachedLayout(cacheKey: string): { nodes: FlowNode[]; edges: FlowEdge[] } | null { + const entry = layoutCache.get(cacheKey); + if (!entry) return null; + if (Date.now() - entry.timestamp > LAYOUT_CACHE_TTL_MS) { + layoutCache.delete(cacheKey); + return null; + } + return { nodes: entry.nodes, edges: entry.edges }; +} + +function setCachedLayout(cacheKey: string, nodes: FlowNode[], edges: FlowEdge[]): void { + if (layoutCache.size >= LAYOUT_CACHE_MAX) { + const firstKey = layoutCache.keys().next().value; + if (firstKey !== undefined) layoutCache.delete(firstKey); + } + layoutCache.set(cacheKey, { nodes, edges, timestamp: Date.now() }); +} + async function getElkInstance(): Promise { if (!elkInstancePromise) { elkInstancePromise = import('elkjs/lib/elk.bundled.js').then((module) => { @@ -129,10 +154,7 @@ function estimateNodeSize( }; } -function hasInternalEdges( - childIds: Set, - edges: FlowEdge[] -): boolean { +function hasInternalEdges(childIds: Set, edges: FlowEdge[]): boolean { return edges.some((e) => childIds.has(e.source) && childIds.has(e.target)); } @@ -495,7 +517,7 @@ function staggerParallelEdgeLabels(edges: FlowEdge[]): FlowEdge[] { // Spread labels across 0.3–0.7 range to avoid pile-up at the midpoint. const spread = 0.4; - const labelPosition = 0.5 + spread * ((idx / (count - 1)) - 0.5); + const labelPosition = 0.5 + spread * (idx / (count - 1) - 0.5); return { ...edge, @@ -710,7 +732,7 @@ export async function getElkLayout( algorithm, diagramType: effectiveDiagramType, }); - const cached = layoutCache.get(cacheKey); + const cached = getCachedLayout(cacheKey); if (cached) return cached; const { layoutOptions } = buildResolvedLayoutConfiguration({ ...options, @@ -734,7 +756,14 @@ export async function getElkLayout( id: 'root', layoutOptions, children: orderedTopLevelNodes.map((node) => - buildElkNode(node, childrenByParent, sortedEdges, nodeMinWidth, nodeMinHeight, layoutOptions['elk.direction'] ?? 'DOWN') + buildElkNode( + node, + childrenByParent, + sortedEdges, + nodeMinWidth, + nodeMinHeight, + layoutOptions['elk.direction'] ?? 'DOWN' + ) ), edges: sortedEdges.map((edge) => ({ id: edge.id, @@ -804,11 +833,7 @@ export async function getElkLayout( return edge; }); - if (layoutCache.size >= LAYOUT_CACHE_MAX) { - const firstKey = layoutCache.keys().next().value; - if (firstKey !== undefined) layoutCache.delete(firstKey); - } - layoutCache.set(cacheKey, { nodes: laidOutNodes, edges: laidOutEdges }); + setCachedLayout(cacheKey, laidOutNodes, laidOutEdges); return { nodes: laidOutNodes, edges: laidOutEdges }; } catch (err) { diff --git a/src/services/figmaExportService.ts b/src/services/figmaExportService.ts index 230bac09..988df9ce 100644 --- a/src/services/figmaExportService.ts +++ b/src/services/figmaExportService.ts @@ -25,14 +25,18 @@ function getCanvasBounds(nodes: FlowNode[]): { minX: number; minY: number; width export const toFigmaSVG = async (nodes: FlowNode[], edges: FlowEdge[]): Promise => { if (nodes.length === 0) { - return ''; + return ''; } const { minX, minY, width, height } = getCanvasBounds(nodes); const out: string[] = []; const iconMap = await getIconMap(); + out.push(''); out.push(``); + out.push(' OpenFlowKit diagram export'); + out.push(' Editable diagram export for Figma.'); + out.push(` `); out.push(` diff --git a/src/services/flowpilot/prompting.ts b/src/services/flowpilot/prompting.ts index 9d6b15b5..f5ea2816 100644 --- a/src/services/flowpilot/prompting.ts +++ b/src/services/flowpilot/prompting.ts @@ -56,7 +56,7 @@ export function buildFlowpilotDiagramPrompt( } const assetHints = assetMatches - .slice(0, 6) + .slice(0, 12) .map((match) => { const packHint = match.archIconPackId ? `, archIconPackId: "${match.archIconPackId}"` : ''; const shapeHint = match.archIconShapeId ? `, archIconShapeId: "${match.archIconShapeId}"` : ''; diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 306ec946..cc9b42f7 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -64,6 +64,9 @@ export async function generateDiagramFromChat( systemInstruction: getGeminiSystemInstruction(isEditMode ? 'edit' : 'create'), responseMimeType: "text/plain", temperature: temperature ?? 0.2, + // Ensure large diagrams (40+ nodes with attributes) are never truncated. + // DSL for a 40-node diagram with attributes ≈ 2 500 tokens — 8 192 is generous headroom. + maxOutputTokens: 8192, }, }); diff --git a/src/services/mermaid/compatReportHarness.test.ts b/src/services/mermaid/compatReportHarness.test.ts index f41da811..60c81721 100644 --- a/src/services/mermaid/compatReportHarness.test.ts +++ b/src/services/mermaid/compatReportHarness.test.ts @@ -5,29 +5,33 @@ import type { MermaidImportStatus } from './importContracts'; import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs'; describe('mermaid compat report harness', () => { - it('emits corpus-driven family summary output', () => { - const output = execFileSync('node', ['scripts/mermaid-compat-report.mjs'], { - cwd: process.cwd(), - encoding: 'utf8', - }); - const report = JSON.parse(output); + it( + 'emits corpus-driven family summary output', + () => { + const output = execFileSync('node', ['scripts/mermaid-compat-report.mjs'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + const report = JSON.parse(output); - expect(report.summary.totalFixtures).toBeGreaterThanOrEqual(36); - expect(report.summary.supportedFamilies).toBeGreaterThan(0); - expect(report.summary.officialExpectationMatches).toBeGreaterThan(0); - expect(Array.isArray(report.familySummary)).toBe(true); - expect(report.familySummary).toEqual( - expect.arrayContaining([ - expect.objectContaining({ family: 'flowchart' }), - expect.objectContaining({ family: 'sequence' }), - expect.objectContaining({ family: 'stateDiagram' }), - expect.objectContaining({ family: 'classDiagram' }), - expect.objectContaining({ family: 'erDiagram' }), - expect.objectContaining({ family: 'mindmap' }), - expect.objectContaining({ family: 'journey' }), - ]) - ); - }); + expect(report.summary.totalFixtures).toBeGreaterThanOrEqual(36); + expect(report.summary.supportedFamilies).toBeGreaterThan(0); + expect(report.summary.officialExpectationMatches).toBeGreaterThan(0); + expect(Array.isArray(report.familySummary)).toBe(true); + expect(report.familySummary).toEqual( + expect.arrayContaining([ + expect.objectContaining({ family: 'flowchart' }), + expect.objectContaining({ family: 'sequence' }), + expect.objectContaining({ family: 'stateDiagram' }), + expect.objectContaining({ family: 'classDiagram' }), + expect.objectContaining({ family: 'erDiagram' }), + expect.objectContaining({ family: 'mindmap' }), + expect.objectContaining({ family: 'journey' }), + ]) + ); + }, + 60_000 + ); it('measures actual OpenFlowKit import outcomes for the fixture corpus', () => { const fixtures = MERMAID_COMPAT_FIXTURES as Array<{ diff --git a/src/services/mermaid/mermaidLayoutCorpus.test.ts b/src/services/mermaid/mermaidLayoutCorpus.test.ts index 037efbd2..1ad80384 100644 --- a/src/services/mermaid/mermaidLayoutCorpus.test.ts +++ b/src/services/mermaid/mermaidLayoutCorpus.test.ts @@ -65,10 +65,12 @@ function expectLabelsPresent( } describe('Mermaid layout corpus invariants', () => { - it('keeps representative imported diagrams compact and structurally clear', async () => { - const fixtures = (MERMAID_COMPAT_FIXTURES as MermaidLayoutFixture[]).filter( - (fixture) => fixture.layoutAssertions - ); + it( + 'keeps representative imported diagrams compact and structurally clear', + async () => { + const fixtures = (MERMAID_COMPAT_FIXTURES as MermaidLayoutFixture[]).filter( + (fixture) => fixture.layoutAssertions + ); for (const fixture of fixtures) { const parsed = parseMermaidByType(fixture.source); @@ -216,5 +218,7 @@ describe('Mermaid layout corpus invariants', () => { } } } - }); + }, + 60_000 + ); }); diff --git a/src/store/actions/createTabActions.ts b/src/store/actions/createTabActions.ts index 6889f5c7..e6721cea 100644 --- a/src/store/actions/createTabActions.ts +++ b/src/store/actions/createTabActions.ts @@ -65,6 +65,28 @@ export function createTabActions( }; } + function duplicateTabById(tabs: FlowTab[], sourceId: string): string | null { + const syncedTabs = syncActiveTabContent(tabs); + const sourceTab = syncedTabs.find((tab) => tab.id === sourceId); + if (!sourceTab) return null; + + const newTabId = createId('tab'); + const newTab: FlowTab = { + ...cloneTabContent(sourceTab), + id: newTabId, + name: `${sourceTab.name} Copy`, + updatedAt: nowIso(), + }; + + set({ + tabs: [...syncedTabs, newTab], + activeTabId: newTabId, + nodes: newTab.nodes, + edges: newTab.edges, + }); + return newTabId; + } + return { setActiveTabId: (id) => { const { tabs, nodes, edges } = get(); @@ -109,50 +131,12 @@ export function createTabActions( duplicateActiveTab: () => { const { tabs, activeTabId } = get(); - const syncedTabs = syncActiveTabContent(tabs); - const sourceTab = syncedTabs.find((tab) => tab.id === activeTabId); - if (!sourceTab) return null; - - const newTabId = createId('tab'); - const duplicated = cloneTabContent(sourceTab); - const newTab: FlowTab = { - ...duplicated, - id: newTabId, - name: `${sourceTab.name} Copy`, - }; - - set({ - tabs: [...syncedTabs, newTab], - activeTabId: newTabId, - nodes: newTab.nodes, - edges: newTab.edges, - }); + const newTabId = duplicateTabById(tabs, activeTabId); + if (!newTabId) return null; return newTabId; }, - duplicateTab: (id) => { - const { tabs } = get(); - const syncedTabs = syncActiveTabContent(tabs); - const sourceTab = syncedTabs.find((tab) => tab.id === id); - if (!sourceTab) return null; - - const newTabId = createId('tab'); - const duplicated = cloneTabContent(sourceTab); - const newTab: FlowTab = { - ...duplicated, - id: newTabId, - name: `${sourceTab.name} Copy`, - updatedAt: nowIso(), - }; - - set({ - tabs: [...syncedTabs, newTab], - activeTabId: newTabId, - nodes: newTab.nodes, - edges: newTab.edges, - }); - return newTabId; - }, + duplicateTab: (id) => duplicateTabById(get().tabs, id), reorderTab: (draggedTabId, targetTabId) => { if (draggedTabId === targetTabId) { diff --git a/src/theme.ts b/src/theme.ts index fd05e223..164af77f 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -581,9 +581,8 @@ export function resolveContainerVisualStyle( }; } - const strongText = getContrastText(resolved.bg) === '#ffffff' - ? '#ffffff' - : mixHex(resolved.text, '#0f172a', 0.3); + const strongText = + getContrastText(resolved.bg) === '#ffffff' ? '#ffffff' : mixHex(resolved.text, '#0f172a', 0.3); const subtleText = mixHex(resolved.subText, '#ffffff', 0.16); return { @@ -605,13 +604,18 @@ export function resolveTextVisualStyle( customColor?: string, fallback: NodeColorKey = 'slate' ): Pick { - const resolved = resolveNodeVisualStyle(resolveSharedColorKey(colorKey, fallback), colorMode, customColor); + const resolved = resolveNodeVisualStyle( + resolveSharedColorKey(colorKey, fallback), + colorMode, + customColor + ); return { border: mixHex(resolved.border, '#ffffff', 0.18), text: mixHex(resolved.text, '#0f172a', 0.22), - hoverBg: colorMode === 'filled' - ? mixHex(resolved.bg, '#000000', 0.06) - : mixHex(resolved.bg, '#ffffff', 0.28), + hoverBg: + colorMode === 'filled' + ? mixHex(resolved.bg, '#000000', 0.06) + : mixHex(resolved.bg, '#ffffff', 0.28), }; } @@ -629,7 +633,11 @@ export function resolveAnnotationVisualStyle( foldBorder: string; dot: string; } { - const resolved = resolveNodeVisualStyle(resolveSharedColorKey(colorKey, 'yellow'), colorMode, customColor); + const resolved = resolveNodeVisualStyle( + resolveSharedColorKey(colorKey, 'yellow'), + colorMode, + customColor + ); return { containerBg: resolved.bg, containerBorder: mixHex(resolved.border, '#ffffff', 0.12), @@ -664,7 +672,9 @@ export function resolveEdgeVisualStyle(stroke?: string): EdgeVisualStyle { }; } -export function resolveEdgeConditionStroke(condition: keyof typeof EDGE_CONDITION_COLOR_KEYS): string { +export function resolveEdgeConditionStroke( + condition: keyof typeof EDGE_CONDITION_COLOR_KEYS +): string { const colorKey = EDGE_CONDITION_COLOR_KEYS[condition] || EDGE_CONDITION_COLOR_KEYS.default; return resolveNodeVisualStyle(colorKey, 'subtle').border; }