From 5a35562d3db42ceddeb5908e770d0a1597ab3638 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 17 Aug 2025 21:24:08 -0700 Subject: [PATCH] [refactor] Migrate minimap to domain-driven renderer architecture (#5069) * move ref initialization to the component * remove redundant init * [refactor] Move minimap to domain-driven renderer structure - Create new src/renderer/extensions/minimap/ structure following domain-driven design - Add composables: useMinimapGraph, useMinimapViewport, useMinimapRenderer, useMinimapInteraction, useMinimapSettings - Add minimapCanvasRenderer with efficient batched rendering - Add comprehensive type definitions in types.ts - Remove old src/composables/useMinimap.ts composable - Implement proper separation of concerns with dedicated composables for each domain The new structure provides cleaner APIs, better performance through batched rendering, and improved maintainability through domain separation. * [test] Fix minimap tests for new renderer structure - Update all test imports to use new renderer paths - Fix mock implementations to match new composable APIs - Add proper RAF mocking for throttled functions - Fix type assertions to handle strict TypeScript checks - Update test expectations for new implementation behavior - Fix viewport transform calculations in tests - Handle async/throttled behavior correctly in tests All 28 minimap tests now passing with new architecture. * [fix] Remove unused init import in MiniMap component * [refactor] Move useWorkflowThumbnail to renderer/thumbnail structure - Moved useWorkflowThumbnail from src/composables to src/renderer/thumbnail/composables - Updated all imports in components, stores and services - Moved test file to match new structure - This ensures all rendering-related composables live in the renderer directory * [test] Fix minimap canvas renderer test for connections - Fixed mock setup for graph links to match LiteGraph's hybrid Map/Object structure - LiteGraph expects links to be accessible both as a Map and as an object - Test now properly verifies connection rendering functionality --- src/components/graph/MiniMap.vue | 6 +- src/components/graph/MiniMapPanel.vue | 4 +- src/components/topbar/WorkflowTab.vue | 2 +- src/composables/useMinimap.ts | 849 ------------------ src/renderer/core/spatial/boundsCalculator.ts | 100 +++ .../minimap/composables/useMinimap.ts | 251 ++++++ .../minimap/composables/useMinimapGraph.ts | 166 ++++ .../composables/useMinimapInteraction.ts | 107 +++ .../minimap/composables/useMinimapRenderer.ts | 110 +++ .../minimap/composables/useMinimapSettings.ts | 62 ++ .../minimap/composables/useMinimapViewport.ts | 145 +++ .../minimap/minimapCanvasRenderer.ts | 238 +++++ src/renderer/extensions/minimap/types.ts | 68 ++ .../composables/useWorkflowThumbnail.ts | 25 +- .../thumbnail/graphThumbnailRenderer.ts | 64 ++ src/services/workflowService.ts | 2 +- src/stores/workflowStore.ts | 2 +- tests-ui/tests/composables/useMinimap.test.ts | 72 +- .../composables/useMinimapGraph.test.ts | 299 ++++++ .../composables/useMinimapInteraction.test.ts | 328 +++++++ .../composables/useMinimapRenderer.test.ts | 267 ++++++ .../composables/useMinimapSettings.test.ts | 122 +++ .../composables/useMinimapViewport.test.ts | 289 ++++++ .../minimap/minimapCanvasRenderer.test.ts | 324 +++++++ .../composables/useWorkflowThumbnail.spec.ts | 36 +- 25 files changed, 3025 insertions(+), 913 deletions(-) delete mode 100644 src/composables/useMinimap.ts create mode 100644 src/renderer/core/spatial/boundsCalculator.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimap.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimapGraph.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimapInteraction.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimapRenderer.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimapSettings.ts create mode 100644 src/renderer/extensions/minimap/composables/useMinimapViewport.ts create mode 100644 src/renderer/extensions/minimap/minimapCanvasRenderer.ts create mode 100644 src/renderer/extensions/minimap/types.ts rename src/{ => renderer/thumbnail}/composables/useWorkflowThumbnail.ts (78%) create mode 100644 src/renderer/thumbnail/graphThumbnailRenderer.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/composables/useMinimapGraph.test.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/composables/useMinimapInteraction.test.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/composables/useMinimapRenderer.test.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/composables/useMinimapSettings.test.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/composables/useMinimapViewport.test.ts create mode 100644 tests-ui/tests/renderer/extensions/minimap/minimapCanvasRenderer.test.ts rename tests-ui/tests/{ => renderer/thumbnail}/composables/useWorkflowThumbnail.spec.ts (93%) diff --git a/src/components/graph/MiniMap.vue b/src/components/graph/MiniMap.vue index a1e9993e5..abc437a55 100644 --- a/src/components/graph/MiniMap.vue +++ b/src/components/graph/MiniMap.vue @@ -57,7 +57,7 @@ import Button from 'primevue/button' import { onMounted, onUnmounted, ref } from 'vue' -import { useMinimap } from '@/composables/useMinimap' +import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap' import MiniMapPanel from './MiniMapPanel.vue' @@ -94,7 +94,9 @@ const toggleOptionsPanel = () => { } onMounted(() => { - setMinimapRef(minimapRef.value) + if (minimapRef.value) { + setMinimapRef(minimapRef.value) + } }) onUnmounted(() => { diff --git a/src/components/graph/MiniMapPanel.vue b/src/components/graph/MiniMapPanel.vue index 9b45f295e..378e1f69d 100644 --- a/src/components/graph/MiniMapPanel.vue +++ b/src/components/graph/MiniMapPanel.vue @@ -80,7 +80,7 @@ diff --git a/src/components/topbar/WorkflowTab.vue b/src/components/topbar/WorkflowTab.vue index 8432a67f9..1c39832d9 100644 --- a/src/components/topbar/WorkflowTab.vue +++ b/src/components/topbar/WorkflowTab.vue @@ -40,7 +40,7 @@ import { usePragmaticDraggable, usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop' -import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail' +import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail' import { useWorkflowService } from '@/services/workflowService' import { useSettingStore } from '@/stores/settingStore' import { ComfyWorkflow } from '@/stores/workflowStore' diff --git a/src/composables/useMinimap.ts b/src/composables/useMinimap.ts deleted file mode 100644 index 05a4c0e97..000000000 --- a/src/composables/useMinimap.ts +++ /dev/null @@ -1,849 +0,0 @@ -import { useRafFn, useThrottleFn } from '@vueuse/core' -import { computed, nextTick, ref, watch } from 'vue' - -import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' -import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { NodeId } from '@/schemas/comfyWorkflowSchema' -import { api } from '@/scripts/api' -import { useCanvasStore } from '@/stores/graphStore' -import { useSettingStore } from '@/stores/settingStore' -import { useWorkflowStore } from '@/stores/workflowStore' -import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' -import { adjustColor } from '@/utils/colorUtil' - -interface GraphCallbacks { - onNodeAdded?: (node: LGraphNode) => void - onNodeRemoved?: (node: LGraphNode) => void - onConnectionChange?: (node: LGraphNode) => void -} - -export type MinimapOptionKey = - | 'Comfy.Minimap.NodeColors' - | 'Comfy.Minimap.ShowLinks' - | 'Comfy.Minimap.ShowGroups' - | 'Comfy.Minimap.RenderBypassState' - | 'Comfy.Minimap.RenderErrorState' - -export function useMinimap() { - const settingStore = useSettingStore() - const canvasStore = useCanvasStore() - const workflowStore = useWorkflowStore() - const colorPaletteStore = useColorPaletteStore() - - const containerRef = ref() - const canvasRef = ref() - const minimapRef = ref(null) - - const visible = ref(true) - - const nodeColors = computed(() => - settingStore.get('Comfy.Minimap.NodeColors') - ) - const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks')) - const showGroups = computed(() => - settingStore.get('Comfy.Minimap.ShowGroups') - ) - const renderBypass = computed(() => - settingStore.get('Comfy.Minimap.RenderBypassState') - ) - const renderError = computed(() => - settingStore.get('Comfy.Minimap.RenderErrorState') - ) - - const updateOption = async (key: MinimapOptionKey, value: boolean) => { - await settingStore.set(key, value) - - needsFullRedraw.value = true - updateMinimap() - } - - const initialized = ref(false) - const bounds = ref({ - minX: 0, - minY: 0, - maxX: 0, - maxY: 0, - width: 0, - height: 0 - }) - const scale = ref(1) - const isDragging = ref(false) - const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 }) - - const needsFullRedraw = ref(true) - const needsBoundsUpdate = ref(true) - const lastNodeCount = ref(0) - const nodeStatesCache = new Map() - const linksCache = ref('') - - const updateFlags = ref({ - bounds: false, - nodes: false, - connections: false, - viewport: false - }) - - const width = 250 - const height = 200 - - // Theme-aware colors for canvas drawing - const isLightTheme = computed( - () => colorPaletteStore.completedActivePalette.light_theme - ) - const nodeColor = computed( - () => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme - ) - const nodeColorDefault = computed( - () => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting - ) - const linkColor = computed( - () => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme - ) - const slotColor = computed(() => linkColor.value) - const groupColor = computed(() => - isLightTheme.value ? '#A2D3EC' : '#1F547A' - ) - const groupColorDefault = computed( - () => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting - ) - const bypassColor = computed(() => - isLightTheme.value ? '#DBDBDB' : '#4B184B' - ) - - const containerRect = ref({ - left: 0, - top: 0, - width: width, - height: height - }) - - const canvasDimensions = ref({ - width: 0, - height: 0 - }) - - const updateContainerRect = () => { - if (!containerRef.value) return - - const rect = containerRef.value.getBoundingClientRect() - containerRect.value = { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height - } - } - - const updateCanvasDimensions = () => { - const c = canvas.value - if (!c) return - - const canvasEl = c.canvas - const dpr = window.devicePixelRatio || 1 - - canvasDimensions.value = { - width: canvasEl.clientWidth || canvasEl.width / dpr, - height: canvasEl.clientHeight || canvasEl.height / dpr - } - } - - const canvas = computed(() => canvasStore.canvas) - const graph = computed(() => { - // If we're in a subgraph, use that; otherwise use the canvas graph - const activeSubgraph = workflowStore.activeSubgraph - return activeSubgraph || canvas.value?.graph - }) - - const containerStyles = computed(() => ({ - width: `${width}px`, - height: `${height}px`, - backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C', - border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`, - borderRadius: '8px' - })) - - const panelStyles = computed(() => ({ - width: `210px`, - height: `${height}px`, - backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C', - border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`, - borderRadius: '8px' - })) - - const viewportStyles = computed(() => ({ - transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`, - width: `${viewportTransform.value.width}px`, - height: `${viewportTransform.value.height}px`, - border: `2px solid ${isLightTheme.value ? '#E0E0E0' : '#FFF'}`, - backgroundColor: `#FFF33`, - willChange: 'transform', - backfaceVisibility: 'hidden' as const, - perspective: '1000px', - pointerEvents: 'none' as const - })) - - const calculateGraphBounds = () => { - const g = graph.value - if (!g || !g._nodes || g._nodes.length === 0) { - return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } - } - - let minX = Infinity - let minY = Infinity - let maxX = -Infinity - let maxY = -Infinity - - for (const node of g._nodes) { - minX = Math.min(minX, node.pos[0]) - minY = Math.min(minY, node.pos[1]) - maxX = Math.max(maxX, node.pos[0] + node.size[0]) - maxY = Math.max(maxY, node.pos[1] + node.size[1]) - } - - let currentWidth = maxX - minX - let currentHeight = maxY - minY - - // Enforce minimum viewport dimensions for better visualization - const minViewportWidth = 2500 - const minViewportHeight = 2000 - - if (currentWidth < minViewportWidth) { - const padding = (minViewportWidth - currentWidth) / 2 - minX -= padding - maxX += padding - currentWidth = minViewportWidth - } - - if (currentHeight < minViewportHeight) { - const padding = (minViewportHeight - currentHeight) / 2 - minY -= padding - maxY += padding - currentHeight = minViewportHeight - } - - return { - minX, - minY, - maxX, - maxY, - width: currentWidth, - height: currentHeight - } - } - - const calculateScale = () => { - if (bounds.value.width === 0 || bounds.value.height === 0) { - return 1 - } - - const scaleX = width / bounds.value.width - const scaleY = height / bounds.value.height - - // Apply 0.9 factor to provide padding/gap between nodes and minimap borders - return Math.min(scaleX, scaleY) * 0.9 - } - - const renderGroups = ( - ctx: CanvasRenderingContext2D, - offsetX: number, - offsetY: number - ) => { - const g = graph.value - if (!g || !g._groups || g._groups.length === 0) return - - for (const group of g._groups) { - const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX - const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY - const w = group.size[0] * scale.value - const h = group.size[1] * scale.value - - let color = groupColor.value - - if (nodeColors.value) { - color = group.color ?? groupColorDefault.value - - if (isLightTheme.value) { - color = adjustColor(color, { opacity: 0.5 }) - } - } - - ctx.fillStyle = color - ctx.fillRect(x, y, w, h) - } - } - - const renderNodes = ( - ctx: CanvasRenderingContext2D, - offsetX: number, - offsetY: number - ) => { - const g = graph.value - if (!g || !g._nodes || g._nodes.length === 0) return - - for (const node of g._nodes) { - const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX - const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY - const w = node.size[0] * scale.value - const h = node.size[1] * scale.value - - let color = nodeColor.value - - if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) { - color = bypassColor.value - } else if (nodeColors.value) { - color = nodeColorDefault.value - - if (node.bgcolor) { - color = isLightTheme.value - ? adjustColor(node.bgcolor, { lightness: 0.5 }) - : node.bgcolor - } - } - - // Render solid node blocks - ctx.fillStyle = color - ctx.fillRect(x, y, w, h) - - if (renderError.value && node.has_errors) { - ctx.strokeStyle = '#FF0000' - ctx.lineWidth = 0.3 - ctx.strokeRect(x, y, w, h) - } - } - } - - const renderConnections = ( - ctx: CanvasRenderingContext2D, - offsetX: number, - offsetY: number - ) => { - const g = graph.value - if (!g) return - - ctx.strokeStyle = linkColor.value - ctx.lineWidth = 0.3 - - const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale - const connections: Array<{ - x1: number - y1: number - x2: number - y2: number - }> = [] - - for (const node of g._nodes) { - if (!node.outputs) continue - - const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX - const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY - - for (const output of node.outputs) { - if (!output.links) continue - - for (const linkId of output.links) { - const link = g.links[linkId] - if (!link) continue - - const targetNode = g.getNodeById(link.target_id) - if (!targetNode) continue - - const x2 = - (targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX - const y2 = - (targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY - - const outputX = x1 + node.size[0] * scale.value - const outputY = y1 + node.size[1] * scale.value * 0.2 - const inputX = x2 - const inputY = y2 + targetNode.size[1] * scale.value * 0.2 - - // Draw connection line - ctx.beginPath() - ctx.moveTo(outputX, outputY) - ctx.lineTo(inputX, inputY) - ctx.stroke() - - connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) - } - } - } - - // Render connection slots on top - ctx.fillStyle = slotColor.value - for (const conn of connections) { - // Output slot - ctx.beginPath() - ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2) - ctx.fill() - - // Input slot - ctx.beginPath() - ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2) - ctx.fill() - } - } - - const renderMinimap = () => { - const g = graph.value - if (!canvasRef.value || !g) return - - const ctx = canvasRef.value.getContext('2d') - if (!ctx) return - - // Fast path for 0 nodes - just show background - if (!g._nodes || g._nodes.length === 0) { - ctx.clearRect(0, 0, width, height) - return - } - - const needsRedraw = - needsFullRedraw.value || - updateFlags.value.nodes || - updateFlags.value.connections - - if (needsRedraw) { - ctx.clearRect(0, 0, width, height) - - const offsetX = (width - bounds.value.width * scale.value) / 2 - const offsetY = (height - bounds.value.height * scale.value) / 2 - - if (showGroups.value) { - renderGroups(ctx, offsetX, offsetY) - } - - if (showLinks.value) { - renderConnections(ctx, offsetX, offsetY) - } - - renderNodes(ctx, offsetX, offsetY) - - needsFullRedraw.value = false - updateFlags.value.nodes = false - updateFlags.value.connections = false - } - } - - const updateViewport = () => { - const c = canvas.value - if (!c) return - - if ( - canvasDimensions.value.width === 0 || - canvasDimensions.value.height === 0 - ) { - updateCanvasDimensions() - } - - const ds = c.ds - - const viewportWidth = canvasDimensions.value.width / ds.scale - const viewportHeight = canvasDimensions.value.height / ds.scale - - const worldX = -ds.offset[0] - const worldY = -ds.offset[1] - - const centerOffsetX = (width - bounds.value.width * scale.value) / 2 - const centerOffsetY = (height - bounds.value.height * scale.value) / 2 - - viewportTransform.value = { - x: (worldX - bounds.value.minX) * scale.value + centerOffsetX, - y: (worldY - bounds.value.minY) * scale.value + centerOffsetY, - width: viewportWidth * scale.value, - height: viewportHeight * scale.value - } - - updateFlags.value.viewport = false - } - - const updateMinimap = () => { - if (needsBoundsUpdate.value || updateFlags.value.bounds) { - bounds.value = calculateGraphBounds() - scale.value = calculateScale() - needsBoundsUpdate.value = false - updateFlags.value.bounds = false - needsFullRedraw.value = true - // When bounds change, we need to update the viewport position - updateFlags.value.viewport = true - } - - if ( - needsFullRedraw.value || - updateFlags.value.nodes || - updateFlags.value.connections - ) { - renderMinimap() - } - - // Update viewport if needed (e.g., after bounds change) - if (updateFlags.value.viewport) { - updateViewport() - } - } - - const checkForChanges = useThrottleFn(() => { - const g = graph.value - if (!g) return - - let structureChanged = false - let positionChanged = false - let connectionChanged = false - - if (g._nodes.length !== lastNodeCount.value) { - structureChanged = true - lastNodeCount.value = g._nodes.length - } - - for (const node of g._nodes) { - const key = node.id - const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}` - - if (nodeStatesCache.get(key) !== currentState) { - positionChanged = true - nodeStatesCache.set(key, currentState) - } - } - - const currentLinks = JSON.stringify(g.links || {}) - if (currentLinks !== linksCache.value) { - connectionChanged = true - linksCache.value = currentLinks - } - - const currentNodeIds = new Set(g._nodes.map((n) => n.id)) - for (const [nodeId] of nodeStatesCache) { - if (!currentNodeIds.has(nodeId)) { - nodeStatesCache.delete(nodeId) - structureChanged = true - } - } - - if (structureChanged || positionChanged) { - updateFlags.value.bounds = true - updateFlags.value.nodes = true - } - - if (connectionChanged) { - updateFlags.value.connections = true - } - - if (structureChanged || positionChanged || connectionChanged) { - updateMinimap() - } - }, 500) - - const { pause: pauseChangeDetection, resume: resumeChangeDetection } = - useRafFn( - async () => { - if (visible.value) { - await checkForChanges() - } - }, - { immediate: false } - ) - - const { startSync: startViewportSync, stopSync: stopViewportSync } = - useCanvasTransformSync(updateViewport, { autoStart: false }) - - // Pointer event handlers for touch screen support - const handlePointerDown = (e: PointerEvent) => { - isDragging.value = true - updateContainerRect() - handlePointerMove(e) - } - - const handlePointerMove = (e: PointerEvent) => { - if (!isDragging.value || !canvasRef.value || !canvas.value) return - - const x = e.clientX - containerRect.value.left - const y = e.clientY - containerRect.value.top - - const offsetX = (width - bounds.value.width * scale.value) / 2 - const offsetY = (height - bounds.value.height * scale.value) / 2 - - const worldX = (x - offsetX) / scale.value + bounds.value.minX - const worldY = (y - offsetY) / scale.value + bounds.value.minY - - centerViewOn(worldX, worldY) - } - - const handlePointerUp = () => { - isDragging.value = false - } - - const handleWheel = (e: WheelEvent) => { - e.preventDefault() - - const c = canvas.value - if (!c) return - - if ( - containerRect.value.left === 0 && - containerRect.value.top === 0 && - containerRef.value - ) { - updateContainerRect() - } - - const ds = c.ds - const delta = e.deltaY > 0 ? 0.9 : 1.1 - - const newScale = ds.scale * delta - - const MIN_SCALE = 0.1 - const MAX_SCALE = 10 - - if (newScale < MIN_SCALE || newScale > MAX_SCALE) return - - const x = e.clientX - containerRect.value.left - const y = e.clientY - containerRect.value.top - - const offsetX = (width - bounds.value.width * scale.value) / 2 - const offsetY = (height - bounds.value.height * scale.value) / 2 - - const worldX = (x - offsetX) / scale.value + bounds.value.minX - const worldY = (y - offsetY) / scale.value + bounds.value.minY - - ds.scale = newScale - - centerViewOn(worldX, worldY) - } - - const centerViewOn = (worldX: number, worldY: number) => { - const c = canvas.value - if (!c) return - - if ( - canvasDimensions.value.width === 0 || - canvasDimensions.value.height === 0 - ) { - updateCanvasDimensions() - } - - const ds = c.ds - - const viewportWidth = canvasDimensions.value.width / ds.scale - const viewportHeight = canvasDimensions.value.height / ds.scale - - ds.offset[0] = -(worldX - viewportWidth / 2) - ds.offset[1] = -(worldY - viewportHeight / 2) - - updateFlags.value.viewport = true - - c.setDirty(true, true) - } - - // Map to store original callbacks per graph ID - const originalCallbacksMap = new Map() - - const handleGraphChanged = useThrottleFn(() => { - needsFullRedraw.value = true - updateFlags.value.bounds = true - updateFlags.value.nodes = true - updateFlags.value.connections = true - updateMinimap() - }, 500) - - const setupEventListeners = () => { - const g = graph.value - if (!g) return - - // Check if we've already wrapped this graph's callbacks - if (originalCallbacksMap.has(g.id)) { - return - } - - // Store the original callbacks for this graph - const originalCallbacks: GraphCallbacks = { - onNodeAdded: g.onNodeAdded, - onNodeRemoved: g.onNodeRemoved, - onConnectionChange: g.onConnectionChange - } - originalCallbacksMap.set(g.id, originalCallbacks) - - g.onNodeAdded = function (node) { - originalCallbacks.onNodeAdded?.call(this, node) - - void handleGraphChanged() - } - - g.onNodeRemoved = function (node) { - originalCallbacks.onNodeRemoved?.call(this, node) - nodeStatesCache.delete(node.id) - void handleGraphChanged() - } - - g.onConnectionChange = function (node) { - originalCallbacks.onConnectionChange?.call(this, node) - - void handleGraphChanged() - } - } - - const cleanupEventListeners = () => { - const g = graph.value - if (!g) return - - const originalCallbacks = originalCallbacksMap.get(g.id) - if (!originalCallbacks) { - console.error( - 'Attempted to cleanup event listeners for graph that was never set up' - ) - return - } - - g.onNodeAdded = originalCallbacks.onNodeAdded - g.onNodeRemoved = originalCallbacks.onNodeRemoved - g.onConnectionChange = originalCallbacks.onConnectionChange - - originalCallbacksMap.delete(g.id) - } - - const init = async () => { - if (initialized.value) return - - visible.value = settingStore.get('Comfy.Minimap.Visible') - - if (canvas.value && graph.value) { - setupEventListeners() - - api.addEventListener('graphChanged', handleGraphChanged) - - if (containerRef.value) { - updateContainerRect() - } - updateCanvasDimensions() - - window.addEventListener('resize', updateContainerRect) - window.addEventListener('scroll', updateContainerRect) - window.addEventListener('resize', updateCanvasDimensions) - - needsFullRedraw.value = true - updateFlags.value.bounds = true - updateFlags.value.nodes = true - updateFlags.value.connections = true - updateFlags.value.viewport = true - - updateMinimap() - updateViewport() - - if (visible.value) { - resumeChangeDetection() - startViewportSync() - } - initialized.value = true - } - } - - const destroy = () => { - pauseChangeDetection() - stopViewportSync() - cleanupEventListeners() - - api.removeEventListener('graphChanged', handleGraphChanged) - - window.removeEventListener('resize', updateContainerRect) - window.removeEventListener('scroll', updateContainerRect) - window.removeEventListener('resize', updateCanvasDimensions) - - nodeStatesCache.clear() - initialized.value = false - } - - watch( - canvas, - async (newCanvas, oldCanvas) => { - if (oldCanvas) { - cleanupEventListeners() - pauseChangeDetection() - stopViewportSync() - api.removeEventListener('graphChanged', handleGraphChanged) - window.removeEventListener('resize', updateContainerRect) - window.removeEventListener('scroll', updateContainerRect) - window.removeEventListener('resize', updateCanvasDimensions) - } - if (newCanvas && !initialized.value) { - await init() - } - }, - { immediate: true, flush: 'post' } - ) - - // Watch for graph changes (e.g., when navigating to/from subgraphs) - watch(graph, (newGraph, oldGraph) => { - if (newGraph && newGraph !== oldGraph) { - cleanupEventListeners() - setupEventListeners() - needsFullRedraw.value = true - updateFlags.value.bounds = true - updateFlags.value.nodes = true - updateFlags.value.connections = true - updateMinimap() - } - }) - - watch(visible, async (isVisible) => { - if (isVisible) { - if (containerRef.value) { - updateContainerRect() - } - updateCanvasDimensions() - - needsFullRedraw.value = true - updateFlags.value.bounds = true - updateFlags.value.nodes = true - updateFlags.value.connections = true - updateFlags.value.viewport = true - - await nextTick() - - await nextTick() - - updateMinimap() - updateViewport() - resumeChangeDetection() - startViewportSync() - } else { - pauseChangeDetection() - stopViewportSync() - } - }) - - const toggle = async () => { - visible.value = !visible.value - await settingStore.set('Comfy.Minimap.Visible', visible.value) - } - - const setMinimapRef = (ref: any) => { - minimapRef.value = ref - } - - return { - visible: computed(() => visible.value), - initialized: computed(() => initialized.value), - - containerRef, - canvasRef, - containerStyles, - viewportStyles, - panelStyles, - width, - height, - - nodeColors, - showLinks, - showGroups, - renderBypass, - renderError, - - init, - destroy, - toggle, - renderMinimap, - handlePointerDown, - handlePointerMove, - handlePointerUp, - handleWheel, - setMinimapRef, - updateOption - } -} diff --git a/src/renderer/core/spatial/boundsCalculator.ts b/src/renderer/core/spatial/boundsCalculator.ts new file mode 100644 index 000000000..50a4f708b --- /dev/null +++ b/src/renderer/core/spatial/boundsCalculator.ts @@ -0,0 +1,100 @@ +/** + * Spatial bounds calculations for node layouts + */ + +export interface SpatialBounds { + minX: number + minY: number + maxX: number + maxY: number + width: number + height: number +} + +export interface PositionedNode { + pos: ArrayLike + size: ArrayLike +} + +/** + * Calculate the spatial bounding box of positioned nodes + */ +export function calculateNodeBounds( + nodes: PositionedNode[] +): SpatialBounds | null { + if (!nodes || nodes.length === 0) { + return null + } + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const node of nodes) { + const x = node.pos[0] + const y = node.pos[1] + const width = node.size[0] + const height = node.size[1] + + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x + width) + maxY = Math.max(maxY, y + height) + } + + return { + minX, + minY, + maxX, + maxY, + width: maxX - minX, + height: maxY - minY + } +} + +/** + * Enforce minimum viewport dimensions for better visualization + */ +export function enforceMinimumBounds( + bounds: SpatialBounds, + minWidth: number = 2500, + minHeight: number = 2000 +): SpatialBounds { + let { minX, minY, maxX, maxY, width, height } = bounds + + if (width < minWidth) { + const padding = (minWidth - width) / 2 + minX -= padding + maxX += padding + width = minWidth + } + + if (height < minHeight) { + const padding = (minHeight - height) / 2 + minY -= padding + maxY += padding + height = minHeight + } + + return { minX, minY, maxX, maxY, width, height } +} + +/** + * Calculate the scale factor to fit bounds within a viewport + */ +export function calculateMinimapScale( + bounds: SpatialBounds, + viewportWidth: number, + viewportHeight: number, + padding: number = 0.9 +): number { + if (bounds.width === 0 || bounds.height === 0) { + return 1 + } + + const scaleX = viewportWidth / bounds.width + const scaleY = viewportHeight / bounds.height + + return Math.min(scaleX, scaleY) * padding +} diff --git a/src/renderer/extensions/minimap/composables/useMinimap.ts b/src/renderer/extensions/minimap/composables/useMinimap.ts new file mode 100644 index 000000000..e2bcea30e --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimap.ts @@ -0,0 +1,251 @@ +import { useRafFn } from '@vueuse/core' +import { computed, nextTick, ref, watch } from 'vue' + +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/stores/graphStore' +import { useSettingStore } from '@/stores/settingStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +import type { MinimapCanvas, MinimapSettingsKey } from '../types' +import { useMinimapGraph } from './useMinimapGraph' +import { useMinimapInteraction } from './useMinimapInteraction' +import { useMinimapRenderer } from './useMinimapRenderer' +import { useMinimapSettings } from './useMinimapSettings' +import { useMinimapViewport } from './useMinimapViewport' + +export function useMinimap() { + const canvasStore = useCanvasStore() + const workflowStore = useWorkflowStore() + const settingStore = useSettingStore() + + const containerRef = ref() + const canvasRef = ref() + const minimapRef = ref(null) + + const visible = ref(true) + const initialized = ref(false) + + const width = 250 + const height = 200 + + const canvas = computed(() => canvasStore.canvas as MinimapCanvas | null) + const graph = computed(() => { + // If we're in a subgraph, use that; otherwise use the canvas graph + const activeSubgraph = workflowStore.activeSubgraph + return (activeSubgraph || canvas.value?.graph) as LGraph | null + }) + + // Settings + const settings = useMinimapSettings() + const { + nodeColors, + showLinks, + showGroups, + renderBypass, + renderError, + containerStyles, + panelStyles + } = settings + + const updateOption = async (key: MinimapSettingsKey, value: boolean) => { + await settingStore.set(key, value) + renderer.forceFullRedraw() + renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport) + } + + // Viewport management + const viewport = useMinimapViewport(canvas, graph, width, height) + + // Interaction handling + const interaction = useMinimapInteraction( + containerRef, + viewport.bounds, + viewport.scale, + width, + height, + viewport.centerViewOn, + canvas + ) + + // Graph event management + const graphManager = useMinimapGraph(graph, () => { + renderer.forceFullRedraw() + renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport) + }) + + // Rendering + const renderer = useMinimapRenderer( + canvasRef, + graph, + viewport.bounds, + viewport.scale, + graphManager.updateFlags, + settings, + width, + height + ) + + // RAF loop for continuous updates + const { pause: pauseChangeDetection, resume: resumeChangeDetection } = + useRafFn( + async () => { + if (visible.value) { + const hasChanges = await graphManager.checkForChanges() + if (hasChanges) { + renderer.updateMinimap( + viewport.updateBounds, + viewport.updateViewport + ) + } + } + }, + { immediate: false } + ) + + const init = async () => { + if (initialized.value) return + + visible.value = settingStore.get('Comfy.Minimap.Visible') + + if (canvas.value && graph.value) { + graphManager.init() + + if (containerRef.value) { + interaction.updateContainerRect() + } + viewport.updateCanvasDimensions() + + window.addEventListener('resize', interaction.updateContainerRect) + window.addEventListener('scroll', interaction.updateContainerRect) + window.addEventListener('resize', viewport.updateCanvasDimensions) + + renderer.forceFullRedraw() + renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport) + viewport.updateViewport() + + if (visible.value) { + resumeChangeDetection() + viewport.startViewportSync() + } + initialized.value = true + } + } + + const destroy = () => { + pauseChangeDetection() + viewport.stopViewportSync() + graphManager.destroy() + + window.removeEventListener('resize', interaction.updateContainerRect) + window.removeEventListener('scroll', interaction.updateContainerRect) + window.removeEventListener('resize', viewport.updateCanvasDimensions) + + initialized.value = false + } + + watch( + canvas, + async (newCanvas, oldCanvas) => { + if (oldCanvas) { + graphManager.cleanupEventListeners() + pauseChangeDetection() + viewport.stopViewportSync() + graphManager.destroy() + window.removeEventListener('resize', interaction.updateContainerRect) + window.removeEventListener('scroll', interaction.updateContainerRect) + window.removeEventListener('resize', viewport.updateCanvasDimensions) + } + if (newCanvas && !initialized.value) { + await init() + } + }, + { immediate: true, flush: 'post' } + ) + + // Watch for graph changes (e.g., when navigating to/from subgraphs) + watch(graph, (newGraph, oldGraph) => { + if (newGraph && newGraph !== oldGraph) { + graphManager.cleanupEventListeners(oldGraph || undefined) + graphManager.setupEventListeners() + renderer.forceFullRedraw() + renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport) + } + }) + + watch(visible, async (isVisible) => { + if (isVisible) { + if (containerRef.value) { + interaction.updateContainerRect() + } + viewport.updateCanvasDimensions() + + renderer.forceFullRedraw() + + await nextTick() + await nextTick() + + renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport) + viewport.updateViewport() + resumeChangeDetection() + viewport.startViewportSync() + } else { + pauseChangeDetection() + viewport.stopViewportSync() + } + }) + + const toggle = async () => { + visible.value = !visible.value + await settingStore.set('Comfy.Minimap.Visible', visible.value) + } + + const setMinimapRef = (ref: HTMLElement | null) => { + minimapRef.value = ref + } + + // Dynamic viewport styles based on actual viewport transform + const viewportStyles = computed(() => { + const transform = viewport.viewportTransform.value + return { + transform: `translate(${transform.x}px, ${transform.y}px)`, + width: `${transform.width}px`, + height: `${transform.height}px`, + border: `2px solid ${settings.isLightTheme.value ? '#E0E0E0' : '#FFF'}`, + backgroundColor: `rgba(255, 255, 255, 0.2)`, + willChange: 'transform', + backfaceVisibility: 'hidden' as const, + perspective: '1000px', + pointerEvents: 'none' as const + } + }) + + return { + visible: computed(() => visible.value), + initialized: computed(() => initialized.value), + + containerRef, + canvasRef, + containerStyles, + viewportStyles, + panelStyles, + width, + height, + + nodeColors, + showLinks, + showGroups, + renderBypass, + renderError, + + init, + destroy, + toggle, + renderMinimap: renderer.renderMinimap, + handlePointerDown: interaction.handlePointerDown, + handlePointerMove: interaction.handlePointerMove, + handlePointerUp: interaction.handlePointerUp, + handleWheel: interaction.handleWheel, + setMinimapRef, + updateOption + } +} diff --git a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts new file mode 100644 index 000000000..9cf050e55 --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts @@ -0,0 +1,166 @@ +import { useThrottleFn } from '@vueuse/core' +import { ref } from 'vue' +import type { Ref } from 'vue' + +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { NodeId } from '@/schemas/comfyWorkflowSchema' +import { api } from '@/scripts/api' + +import type { UpdateFlags } from '../types' + +interface GraphCallbacks { + onNodeAdded?: (node: LGraphNode) => void + onNodeRemoved?: (node: LGraphNode) => void + onConnectionChange?: (node: LGraphNode) => void +} + +export function useMinimapGraph( + graph: Ref, + onGraphChanged: () => void +) { + const nodeStatesCache = new Map() + const linksCache = ref('') + const lastNodeCount = ref(0) + const updateFlags = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + + // Map to store original callbacks per graph ID + const originalCallbacksMap = new Map() + + const handleGraphChangedThrottled = useThrottleFn(() => { + onGraphChanged() + }, 500) + + const setupEventListeners = () => { + const g = graph.value + if (!g) return + + // Check if we've already wrapped this graph's callbacks + if (originalCallbacksMap.has(g.id)) { + return + } + + // Store the original callbacks for this graph + const originalCallbacks: GraphCallbacks = { + onNodeAdded: g.onNodeAdded, + onNodeRemoved: g.onNodeRemoved, + onConnectionChange: g.onConnectionChange + } + originalCallbacksMap.set(g.id, originalCallbacks) + + g.onNodeAdded = function (node: LGraphNode) { + originalCallbacks.onNodeAdded?.call(this, node) + void handleGraphChangedThrottled() + } + + g.onNodeRemoved = function (node: LGraphNode) { + originalCallbacks.onNodeRemoved?.call(this, node) + nodeStatesCache.delete(node.id) + void handleGraphChangedThrottled() + } + + g.onConnectionChange = function (node: LGraphNode) { + originalCallbacks.onConnectionChange?.call(this, node) + void handleGraphChangedThrottled() + } + } + + const cleanupEventListeners = (oldGraph?: LGraph) => { + const g = oldGraph || graph.value + if (!g) return + + const originalCallbacks = originalCallbacksMap.get(g.id) + if (!originalCallbacks) { + console.error( + 'Attempted to cleanup event listeners for graph that was never set up' + ) + return + } + + g.onNodeAdded = originalCallbacks.onNodeAdded + g.onNodeRemoved = originalCallbacks.onNodeRemoved + g.onConnectionChange = originalCallbacks.onConnectionChange + + originalCallbacksMap.delete(g.id) + } + + const checkForChangesInternal = () => { + const g = graph.value + if (!g) return false + + let structureChanged = false + let positionChanged = false + let connectionChanged = false + + if (g._nodes.length !== lastNodeCount.value) { + structureChanged = true + lastNodeCount.value = g._nodes.length + } + + for (const node of g._nodes) { + const key = node.id + const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}` + + if (nodeStatesCache.get(key) !== currentState) { + positionChanged = true + nodeStatesCache.set(key, currentState) + } + } + + const currentLinks = JSON.stringify(g.links || {}) + if (currentLinks !== linksCache.value) { + connectionChanged = true + linksCache.value = currentLinks + } + + const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id)) + for (const [nodeId] of nodeStatesCache) { + if (!currentNodeIds.has(nodeId)) { + nodeStatesCache.delete(nodeId) + structureChanged = true + } + } + + if (structureChanged || positionChanged) { + updateFlags.value.bounds = true + updateFlags.value.nodes = true + } + + if (connectionChanged) { + updateFlags.value.connections = true + } + + return structureChanged || positionChanged || connectionChanged + } + + const init = () => { + setupEventListeners() + api.addEventListener('graphChanged', handleGraphChangedThrottled) + } + + const destroy = () => { + cleanupEventListeners() + api.removeEventListener('graphChanged', handleGraphChangedThrottled) + nodeStatesCache.clear() + } + + const clearCache = () => { + nodeStatesCache.clear() + linksCache.value = '' + lastNodeCount.value = 0 + } + + return { + updateFlags, + setupEventListeners, + cleanupEventListeners, + checkForChanges: checkForChangesInternal, + init, + destroy, + clearCache + } +} diff --git a/src/renderer/extensions/minimap/composables/useMinimapInteraction.ts b/src/renderer/extensions/minimap/composables/useMinimapInteraction.ts new file mode 100644 index 000000000..f978cee94 --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimapInteraction.ts @@ -0,0 +1,107 @@ +import { ref } from 'vue' +import type { Ref } from 'vue' + +import type { MinimapCanvas } from '../types' + +export function useMinimapInteraction( + containerRef: Ref, + bounds: Ref<{ minX: number; minY: number; width: number; height: number }>, + scale: Ref, + width: number, + height: number, + centerViewOn: (worldX: number, worldY: number) => void, + canvas: Ref +) { + const isDragging = ref(false) + const containerRect = ref({ + left: 0, + top: 0, + width: width, + height: height + }) + + const updateContainerRect = () => { + if (!containerRef.value) return + + const rect = containerRef.value.getBoundingClientRect() + containerRect.value = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + } + } + + const handlePointerDown = (e: PointerEvent) => { + isDragging.value = true + updateContainerRect() + handlePointerMove(e) + } + + const handlePointerMove = (e: PointerEvent) => { + if (!isDragging.value || !canvas.value) return + + const x = e.clientX - containerRect.value.left + const y = e.clientY - containerRect.value.top + + const offsetX = (width - bounds.value.width * scale.value) / 2 + const offsetY = (height - bounds.value.height * scale.value) / 2 + + const worldX = (x - offsetX) / scale.value + bounds.value.minX + const worldY = (y - offsetY) / scale.value + bounds.value.minY + + centerViewOn(worldX, worldY) + } + + const handlePointerUp = () => { + isDragging.value = false + } + + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + + const c = canvas.value + if (!c) return + + if ( + containerRect.value.left === 0 && + containerRect.value.top === 0 && + containerRef.value + ) { + updateContainerRect() + } + + const ds = c.ds + const delta = e.deltaY > 0 ? 0.9 : 1.1 + + const newScale = ds.scale * delta + + const MIN_SCALE = 0.1 + const MAX_SCALE = 10 + + if (newScale < MIN_SCALE || newScale > MAX_SCALE) return + + const x = e.clientX - containerRect.value.left + const y = e.clientY - containerRect.value.top + + const offsetX = (width - bounds.value.width * scale.value) / 2 + const offsetY = (height - bounds.value.height * scale.value) / 2 + + const worldX = (x - offsetX) / scale.value + bounds.value.minX + const worldY = (y - offsetY) / scale.value + bounds.value.minY + + ds.scale = newScale + + centerViewOn(worldX, worldY) + } + + return { + isDragging, + containerRect, + updateContainerRect, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleWheel + } +} diff --git a/src/renderer/extensions/minimap/composables/useMinimapRenderer.ts b/src/renderer/extensions/minimap/composables/useMinimapRenderer.ts new file mode 100644 index 000000000..bd815b718 --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimapRenderer.ts @@ -0,0 +1,110 @@ +import { ref } from 'vue' +import type { Ref } from 'vue' + +import type { LGraph } from '@/lib/litegraph/src/litegraph' + +import { renderMinimapToCanvas } from '../minimapCanvasRenderer' +import type { UpdateFlags } from '../types' + +export function useMinimapRenderer( + canvasRef: Ref, + graph: Ref, + bounds: Ref<{ minX: number; minY: number; width: number; height: number }>, + scale: Ref, + updateFlags: Ref, + settings: { + nodeColors: Ref + showLinks: Ref + showGroups: Ref + renderBypass: Ref + renderError: Ref + }, + width: number, + height: number +) { + const needsFullRedraw = ref(true) + const needsBoundsUpdate = ref(true) + + const renderMinimap = () => { + const g = graph.value + if (!canvasRef.value || !g) return + + const ctx = canvasRef.value.getContext('2d') + if (!ctx) return + + // Fast path for 0 nodes - just show background + if (!g._nodes || g._nodes.length === 0) { + ctx.clearRect(0, 0, width, height) + return + } + + const needsRedraw = + needsFullRedraw.value || + updateFlags.value.nodes || + updateFlags.value.connections + + if (needsRedraw) { + renderMinimapToCanvas(canvasRef.value, g, { + bounds: bounds.value, + scale: scale.value, + settings: { + nodeColors: settings.nodeColors.value, + showLinks: settings.showLinks.value, + showGroups: settings.showGroups.value, + renderBypass: settings.renderBypass.value, + renderError: settings.renderError.value + }, + width, + height + }) + + needsFullRedraw.value = false + updateFlags.value.nodes = false + updateFlags.value.connections = false + } + } + + const updateMinimap = ( + updateBounds: () => void, + updateViewport: () => void + ) => { + if (needsBoundsUpdate.value || updateFlags.value.bounds) { + updateBounds() + needsBoundsUpdate.value = false + updateFlags.value.bounds = false + needsFullRedraw.value = true + // When bounds change, we need to update the viewport position + updateFlags.value.viewport = true + } + + if ( + needsFullRedraw.value || + updateFlags.value.nodes || + updateFlags.value.connections + ) { + renderMinimap() + } + + // Update viewport if needed (e.g., after bounds change) + if (updateFlags.value.viewport) { + updateViewport() + updateFlags.value.viewport = false + } + } + + const forceFullRedraw = () => { + needsFullRedraw.value = true + updateFlags.value.bounds = true + updateFlags.value.nodes = true + updateFlags.value.connections = true + updateFlags.value.viewport = true + } + + return { + needsFullRedraw, + needsBoundsUpdate, + renderMinimap, + updateMinimap, + forceFullRedraw + } +} diff --git a/src/renderer/extensions/minimap/composables/useMinimapSettings.ts b/src/renderer/extensions/minimap/composables/useMinimapSettings.ts new file mode 100644 index 000000000..e6cf31c29 --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimapSettings.ts @@ -0,0 +1,62 @@ +import { computed } from 'vue' + +import { useSettingStore } from '@/stores/settingStore' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' + +/** + * Composable for minimap configuration options that are set by the user in the + * settings. Provides reactive computed properties for the settings. + */ +export function useMinimapSettings() { + const settingStore = useSettingStore() + const colorPaletteStore = useColorPaletteStore() + + const nodeColors = computed(() => + settingStore.get('Comfy.Minimap.NodeColors') + ) + const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks')) + const showGroups = computed(() => + settingStore.get('Comfy.Minimap.ShowGroups') + ) + const renderBypass = computed(() => + settingStore.get('Comfy.Minimap.RenderBypassState') + ) + const renderError = computed(() => + settingStore.get('Comfy.Minimap.RenderErrorState') + ) + + const width = 250 + const height = 200 + + // Theme-aware colors + const isLightTheme = computed( + () => colorPaletteStore.completedActivePalette.light_theme + ) + + const containerStyles = computed(() => ({ + width: `${width}px`, + height: `${height}px`, + backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C', + border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`, + borderRadius: '8px' + })) + + const panelStyles = computed(() => ({ + width: `210px`, + height: `${height}px`, + backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C', + border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`, + borderRadius: '8px' + })) + + return { + nodeColors, + showLinks, + showGroups, + renderBypass, + renderError, + containerStyles, + panelStyles, + isLightTheme + } +} diff --git a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts new file mode 100644 index 000000000..da67e5a7c --- /dev/null +++ b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts @@ -0,0 +1,145 @@ +import { computed, ref } from 'vue' +import type { Ref } from 'vue' + +import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { + calculateMinimapScale, + calculateNodeBounds, + enforceMinimumBounds +} from '@/renderer/core/spatial/boundsCalculator' + +import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types' + +export function useMinimapViewport( + canvas: Ref, + graph: Ref, + width: number, + height: number +) { + const bounds = ref({ + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + width: 0, + height: 0 + }) + + const scale = ref(1) + const viewportTransform = ref({ + x: 0, + y: 0, + width: 0, + height: 0 + }) + + const canvasDimensions = ref({ + width: 0, + height: 0 + }) + + const updateCanvasDimensions = () => { + const c = canvas.value + if (!c) return + + const canvasEl = c.canvas + const dpr = window.devicePixelRatio || 1 + + canvasDimensions.value = { + width: canvasEl.clientWidth || canvasEl.width / dpr, + height: canvasEl.clientHeight || canvasEl.height / dpr + } + } + + const calculateGraphBounds = (): MinimapBounds => { + const g = graph.value + if (!g || !g._nodes || g._nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + const bounds = calculateNodeBounds(g._nodes) + if (!bounds) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + return enforceMinimumBounds(bounds) + } + + const calculateScale = () => { + return calculateMinimapScale(bounds.value, width, height) + } + + const updateViewport = () => { + const c = canvas.value + if (!c) return + + if ( + canvasDimensions.value.width === 0 || + canvasDimensions.value.height === 0 + ) { + updateCanvasDimensions() + } + + const ds = c.ds + + const viewportWidth = canvasDimensions.value.width / ds.scale + const viewportHeight = canvasDimensions.value.height / ds.scale + + const worldX = -ds.offset[0] + const worldY = -ds.offset[1] + + const centerOffsetX = (width - bounds.value.width * scale.value) / 2 + const centerOffsetY = (height - bounds.value.height * scale.value) / 2 + + viewportTransform.value = { + x: (worldX - bounds.value.minX) * scale.value + centerOffsetX, + y: (worldY - bounds.value.minY) * scale.value + centerOffsetY, + width: viewportWidth * scale.value, + height: viewportHeight * scale.value + } + } + + const updateBounds = () => { + bounds.value = calculateGraphBounds() + scale.value = calculateScale() + } + + const centerViewOn = (worldX: number, worldY: number) => { + const c = canvas.value + if (!c) return + + if ( + canvasDimensions.value.width === 0 || + canvasDimensions.value.height === 0 + ) { + updateCanvasDimensions() + } + + const ds = c.ds + + const viewportWidth = canvasDimensions.value.width / ds.scale + const viewportHeight = canvasDimensions.value.height / ds.scale + + ds.offset[0] = -(worldX - viewportWidth / 2) + ds.offset[1] = -(worldY - viewportHeight / 2) + + c.setDirty(true, true) + } + + const { startSync: startViewportSync, stopSync: stopViewportSync } = + useCanvasTransformSync(updateViewport, { autoStart: false }) + + return { + bounds: computed(() => bounds.value), + scale: computed(() => scale.value), + viewportTransform: computed(() => viewportTransform.value), + canvasDimensions: computed(() => canvasDimensions.value), + updateCanvasDimensions, + updateViewport, + updateBounds, + centerViewOn, + startViewportSync, + stopViewportSync + } +} diff --git a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts new file mode 100644 index 000000000..69528d5c3 --- /dev/null +++ b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts @@ -0,0 +1,238 @@ +import { LGraph, LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' +import { adjustColor } from '@/utils/colorUtil' + +import type { MinimapRenderContext } from './types' + +/** + * Get theme-aware colors for the minimap + */ +function getMinimapColors() { + const colorPaletteStore = useColorPaletteStore() + const isLightTheme = colorPaletteStore.completedActivePalette.light_theme + + return { + nodeColor: isLightTheme ? '#3DA8E099' : '#0B8CE999', + nodeColorDefault: isLightTheme ? '#D9D9D9' : '#353535', + linkColor: isLightTheme ? '#616161' : '#B3B3B3', + slotColor: isLightTheme ? '#616161' : '#B3B3B3', + groupColor: isLightTheme ? '#A2D3EC' : '#1F547A', + groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB', + bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B', + errorColor: '#FF0000', + isLightTheme + } +} + +/** + * Render groups on the minimap + */ +function renderGroups( + ctx: CanvasRenderingContext2D, + graph: LGraph, + offsetX: number, + offsetY: number, + context: MinimapRenderContext, + colors: ReturnType +) { + if (!graph._groups || graph._groups.length === 0) return + + for (const group of graph._groups) { + const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX + const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY + const w = group.size[0] * context.scale + const h = group.size[1] * context.scale + + let color = colors.groupColor + + if (context.settings.nodeColors) { + color = group.color ?? colors.groupColorDefault + + if (colors.isLightTheme) { + color = adjustColor(color, { opacity: 0.5 }) + } + } + + ctx.fillStyle = color + ctx.fillRect(x, y, w, h) + } +} + +/** + * Render nodes on the minimap with performance optimizations + */ +function renderNodes( + ctx: CanvasRenderingContext2D, + graph: LGraph, + offsetX: number, + offsetY: number, + context: MinimapRenderContext, + colors: ReturnType +) { + if (!graph._nodes || graph._nodes.length === 0) return + + // Group nodes by color for batch rendering + const nodesByColor = new Map< + string, + Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }> + >() + + for (const node of graph._nodes) { + const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX + const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY + const w = node.size[0] * context.scale + const h = node.size[1] * context.scale + + let color = colors.nodeColor + + if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { + color = colors.bypassColor + } else if (context.settings.nodeColors) { + color = colors.nodeColorDefault + + if (node.bgcolor) { + color = colors.isLightTheme + ? adjustColor(node.bgcolor, { lightness: 0.5 }) + : node.bgcolor + } + } + + if (!nodesByColor.has(color)) { + nodesByColor.set(color, []) + } + + nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors }) + } + + // Batch render nodes by color + for (const [color, nodes] of nodesByColor) { + ctx.fillStyle = color + for (const node of nodes) { + ctx.fillRect(node.x, node.y, node.w, node.h) + } + } + + // Render error outlines if needed + if (context.settings.renderError) { + ctx.strokeStyle = colors.errorColor + ctx.lineWidth = 0.3 + for (const nodes of nodesByColor.values()) { + for (const node of nodes) { + if (node.hasErrors) { + ctx.strokeRect(node.x, node.y, node.w, node.h) + } + } + } + } +} + +/** + * Render connections on the minimap + */ +function renderConnections( + ctx: CanvasRenderingContext2D, + graph: LGraph, + offsetX: number, + offsetY: number, + context: MinimapRenderContext, + colors: ReturnType +) { + if (!graph || !graph._nodes) return + + ctx.strokeStyle = colors.linkColor + ctx.lineWidth = 0.3 + + const slotRadius = Math.max(context.scale, 0.5) + const connections: Array<{ + x1: number + y1: number + x2: number + y2: number + }> = [] + + for (const node of graph._nodes) { + if (!node.outputs) continue + + const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX + const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY + + for (const output of node.outputs) { + if (!output.links) continue + + for (const linkId of output.links) { + const link = graph.links[linkId] + if (!link) continue + + const targetNode = graph.getNodeById(link.target_id) + if (!targetNode) continue + + const x2 = + (targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX + const y2 = + (targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY + + const outputX = x1 + node.size[0] * context.scale + const outputY = y1 + node.size[1] * context.scale * 0.2 + const inputX = x2 + const inputY = y2 + targetNode.size[1] * context.scale * 0.2 + + // Draw connection line + ctx.beginPath() + ctx.moveTo(outputX, outputY) + ctx.lineTo(inputX, inputY) + ctx.stroke() + + connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) + } + } + } + + // Render connection slots on top + ctx.fillStyle = colors.slotColor + for (const conn of connections) { + // Output slot + ctx.beginPath() + ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2) + ctx.fill() + + // Input slot + ctx.beginPath() + ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2) + ctx.fill() + } +} + +/** + * Render a graph to a minimap canvas + */ +export function renderMinimapToCanvas( + canvas: HTMLCanvasElement, + graph: LGraph, + context: MinimapRenderContext +) { + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Clear canvas + ctx.clearRect(0, 0, context.width, context.height) + + // Fast path for empty graph + if (!graph || !graph._nodes || graph._nodes.length === 0) { + return + } + + const colors = getMinimapColors() + const offsetX = (context.width - context.bounds.width * context.scale) / 2 + const offsetY = (context.height - context.bounds.height * context.scale) / 2 + + // Render in correct order: groups -> links -> nodes + if (context.settings.showGroups) { + renderGroups(ctx, graph, offsetX, offsetY, context, colors) + } + + if (context.settings.showLinks) { + renderConnections(ctx, graph, offsetX, offsetY, context, colors) + } + + renderNodes(ctx, graph, offsetX, offsetY, context, colors) +} diff --git a/src/renderer/extensions/minimap/types.ts b/src/renderer/extensions/minimap/types.ts new file mode 100644 index 000000000..38f464f97 --- /dev/null +++ b/src/renderer/extensions/minimap/types.ts @@ -0,0 +1,68 @@ +/** + * Minimap-specific type definitions + */ +import type { LGraph } from '@/lib/litegraph/src/litegraph' + +/** + * Minimal interface for what the minimap needs from the canvas + */ +export interface MinimapCanvas { + canvas: HTMLCanvasElement + ds: { + scale: number + offset: [number, number] + } + graph?: LGraph | null + setDirty: (fg?: boolean, bg?: boolean) => void +} + +export interface MinimapRenderContext { + bounds: { + minX: number + minY: number + width: number + height: number + } + scale: number + settings: MinimapRenderSettings + width: number + height: number +} + +export interface MinimapRenderSettings { + nodeColors: boolean + showLinks: boolean + showGroups: boolean + renderBypass: boolean + renderError: boolean +} + +export interface MinimapBounds { + minX: number + minY: number + maxX: number + maxY: number + width: number + height: number +} + +export interface ViewportTransform { + x: number + y: number + width: number + height: number +} + +export interface UpdateFlags { + bounds: boolean + nodes: boolean + connections: boolean + viewport: boolean +} + +export type MinimapSettingsKey = + | 'Comfy.Minimap.NodeColors' + | 'Comfy.Minimap.ShowLinks' + | 'Comfy.Minimap.ShowGroups' + | 'Comfy.Minimap.RenderBypassState' + | 'Comfy.Minimap.RenderErrorState' diff --git a/src/composables/useWorkflowThumbnail.ts b/src/renderer/thumbnail/composables/useWorkflowThumbnail.ts similarity index 78% rename from src/composables/useWorkflowThumbnail.ts rename to src/renderer/thumbnail/composables/useWorkflowThumbnail.ts index 5adab4945..391453bf9 100644 --- a/src/composables/useWorkflowThumbnail.ts +++ b/src/renderer/thumbnail/composables/useWorkflowThumbnail.ts @@ -1,38 +1,19 @@ import { ref } from 'vue' +import { createGraphThumbnail } from '@/renderer/thumbnail/graphThumbnailRenderer' import { ComfyWorkflow } from '@/stores/workflowStore' -import { useMinimap } from './useMinimap' - // Store thumbnails for each workflow const workflowThumbnails = ref>(new Map()) -// Shared minimap instance -let minimap: ReturnType | null = null - export const useWorkflowThumbnail = () => { /** * Capture a thumbnail of the canvas */ const createMinimapPreview = (): Promise => { try { - if (!minimap) { - minimap = useMinimap() - minimap.canvasRef.value = document.createElement('canvas') - minimap.canvasRef.value.width = minimap.width - minimap.canvasRef.value.height = minimap.height - } - minimap.renderMinimap() - - return new Promise((resolve) => { - minimap!.canvasRef.value!.toBlob((blob) => { - if (blob) { - resolve(URL.createObjectURL(blob)) - } else { - resolve(null) - } - }) - }) + const thumbnailDataUrl = createGraphThumbnail() + return Promise.resolve(thumbnailDataUrl) } catch (error) { console.error('Failed to capture canvas thumbnail:', error) return Promise.resolve(null) diff --git a/src/renderer/thumbnail/graphThumbnailRenderer.ts b/src/renderer/thumbnail/graphThumbnailRenderer.ts new file mode 100644 index 000000000..5aac33bd2 --- /dev/null +++ b/src/renderer/thumbnail/graphThumbnailRenderer.ts @@ -0,0 +1,64 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { + calculateMinimapScale, + calculateNodeBounds +} from '@/renderer/core/spatial/boundsCalculator' +import { useCanvasStore } from '@/stores/graphStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +import { renderMinimapToCanvas } from '../extensions/minimap/minimapCanvasRenderer' + +/** + * Create a thumbnail of the current canvas's active graph. + * Used by workflow thumbnail generation. + */ +export function createGraphThumbnail(): string | null { + const canvasStore = useCanvasStore() + const workflowStore = useWorkflowStore() + + const graph = workflowStore.activeSubgraph || canvasStore.canvas?.graph + if (!graph || !graph._nodes || graph._nodes.length === 0) { + return null + } + + const width = 250 + const height = 200 + + // Calculate bounds using spatial calculator + const bounds = calculateNodeBounds(graph._nodes) + if (!bounds) { + return null + } + + const scale = calculateMinimapScale(bounds, width, height) + + // Create detached canvas + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + // Render the minimap + renderMinimapToCanvas(canvas, graph as LGraph, { + bounds, + scale, + settings: { + nodeColors: true, + showLinks: false, + showGroups: true, + renderBypass: false, + renderError: false + }, + width, + height + }) + + const dataUrl = canvas.toDataURL() + + // Explicit cleanup (optional but good practice) + const ctx = canvas.getContext('2d') + if (ctx) { + ctx.clearRect(0, 0, width, height) + } + + return dataUrl +} diff --git a/src/services/workflowService.ts b/src/services/workflowService.ts index e52cb5c09..e8ae09a30 100644 --- a/src/services/workflowService.ts +++ b/src/services/workflowService.ts @@ -1,9 +1,9 @@ import { toRaw } from 'vue' -import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail' import { t } from '@/i18n' import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph' +import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' import { app } from '@/scripts/app' import { blankGraph, defaultGraph } from '@/scripts/defaultGraph' diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 9ba0b585f..6219f7225 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -2,8 +2,8 @@ import _ from 'es-toolkit/compat' import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' -import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail' import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' +import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' diff --git a/tests-ui/tests/composables/useMinimap.test.ts b/tests-ui/tests/composables/useMinimap.test.ts index 1c1b12cb7..f172178ad 100644 --- a/tests-ui/tests/composables/useMinimap.test.ts +++ b/tests-ui/tests/composables/useMinimap.test.ts @@ -3,28 +3,39 @@ import { nextTick } from 'vue' const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) +const triggerRAF = async () => { + // Trigger all RAF callbacks + Object.values(rafCallbacks).forEach((cb) => cb?.()) + await flushPromises() +} + const mockPause = vi.fn() const mockResume = vi.fn() -vi.mock('@vueuse/core', () => { - const callbacks: Record void> = {} - let callbackId = 0 +const rafCallbacks: Record void> = {} +let rafCallbackId = 0 +vi.mock('@vueuse/core', () => { return { useRafFn: vi.fn((callback, options) => { - const id = callbackId++ - callbacks[id] = callback + const id = rafCallbackId++ + rafCallbacks[id] = callback if (options?.immediate !== false) { void Promise.resolve().then(() => callback()) } + const resumeFn = vi.fn(() => { + mockResume() + // Execute the RAF callback immediately when resumed + if (rafCallbacks[id]) { + rafCallbacks[id]() + } + }) + return { pause: mockPause, - resume: vi.fn(() => { - mockResume() - void Promise.resolve().then(() => callbacks[id]?.()) - }) + resume: resumeFn } }), useThrottleFn: vi.fn((callback) => { @@ -142,7 +153,9 @@ vi.mock('@/stores/workflowStore', () => ({ })) })) -const { useMinimap } = await import('@/composables/useMinimap') +const { useMinimap } = await import( + '@/renderer/extensions/minimap/composables/useMinimap' +) const { api } = await import('@/scripts/api') describe('useMinimap', () => { @@ -425,7 +438,19 @@ describe('useMinimap', () => { await minimap.init() - await new Promise((resolve) => setTimeout(resolve, 100)) + // Force initial render + minimap.renderMinimap() + + // Force a render by triggering a graph change + mockGraph._nodes.push({ + id: 'new-node', + pos: [150, 150], + size: [100, 50] + }) + + // Trigger RAF to process changes + await triggerRAF() + await nextTick() expect(getContextSpy).toHaveBeenCalled() expect(getContextSpy).toHaveBeenCalledWith('2d') @@ -438,7 +463,15 @@ describe('useMinimap', () => { await minimap.init() - await new Promise((resolve) => setTimeout(resolve, 100)) + // Force initial render + minimap.renderMinimap() + + // Force a render by modifying a node position + mockGraph._nodes[0].pos = [50, 50] + + // Trigger RAF to process changes + await triggerRAF() + await nextTick() const renderingOccurred = mockContext2D.clearRect.mock.calls.length > 0 || @@ -449,6 +482,15 @@ describe('useMinimap', () => { console.log('Minimap initialized:', minimap.initialized.value) console.log('Canvas exists:', !!defaultCanvasStore.canvas) console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph) + console.log( + 'clearRect calls:', + mockContext2D.clearRect.mock.calls.length + ) + console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length) + console.log( + 'getContext calls:', + mockCanvasElement.getContext.mock.calls.length + ) } expect(renderingOccurred).toBe(true) @@ -478,6 +520,10 @@ describe('useMinimap', () => { minimap.canvasRef.value = mockCanvasElement await minimap.init() + + // The renderer has a fast path for empty graphs, force it to execute + minimap.renderMinimap() + await new Promise((resolve) => setTimeout(resolve, 100)) expect(minimap.initialized.value).toBe(true) @@ -917,7 +963,7 @@ describe('useMinimap', () => { describe('setMinimapRef', () => { it('should set minimap reference', () => { const minimap = useMinimap() - const ref = { value: 'test-ref' } + const ref = document.createElement('div') minimap.setMinimapRef(ref) diff --git a/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapGraph.test.ts b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapGraph.test.ts new file mode 100644 index 000000000..914fa707a --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapGraph.test.ts @@ -0,0 +1,299 @@ +import { useThrottleFn } from '@vueuse/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph' +import { api } from '@/scripts/api' + +vi.mock('@vueuse/core', () => ({ + useThrottleFn: vi.fn((fn) => fn) +})) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } +})) + +describe('useMinimapGraph', () => { + let mockGraph: LGraph + let onGraphChangedMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockGraph = { + id: 'test-graph-123', + _nodes: [ + { id: '1', pos: [100, 100], size: [150, 80] }, + { id: '2', pos: [300, 200], size: [120, 60] } + ], + links: { link1: { id: 'link1' } }, + onNodeAdded: vi.fn(), + onNodeRemoved: vi.fn(), + onConnectionChange: vi.fn() + } as any + + onGraphChangedMock = vi.fn() + }) + + it('should initialize with empty state', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + expect(graphManager.updateFlags.value).toEqual({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + }) + + it('should setup event listeners on init', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.init() + + expect(api.addEventListener).toHaveBeenCalledWith( + 'graphChanged', + expect.any(Function) + ) + }) + + it('should wrap graph callbacks on setup', () => { + const originalOnNodeAdded = vi.fn() + const originalOnNodeRemoved = vi.fn() + const originalOnConnectionChange = vi.fn() + + mockGraph.onNodeAdded = originalOnNodeAdded + mockGraph.onNodeRemoved = originalOnNodeRemoved + mockGraph.onConnectionChange = originalOnConnectionChange + + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.setupEventListeners() + + // Should wrap the callbacks + expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded) + expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved) + expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange) + + // Test wrapped callbacks + const testNode = { id: '3' } as LGraphNode + mockGraph.onNodeAdded!(testNode) + + expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode) + expect(onGraphChangedMock).toHaveBeenCalled() + }) + + it('should prevent duplicate event listener setup', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // Store original callbacks for comparison + // const originalCallbacks = { + // onNodeAdded: mockGraph.onNodeAdded, + // onNodeRemoved: mockGraph.onNodeRemoved, + // onConnectionChange: mockGraph.onConnectionChange + // } + + graphManager.setupEventListeners() + const wrappedCallbacks = { + onNodeAdded: mockGraph.onNodeAdded, + onNodeRemoved: mockGraph.onNodeRemoved, + onConnectionChange: mockGraph.onConnectionChange + } + + // Setup again - should not re-wrap + graphManager.setupEventListeners() + + expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded) + expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved) + expect(mockGraph.onConnectionChange).toBe( + wrappedCallbacks.onConnectionChange + ) + }) + + it('should cleanup event listeners properly', () => { + const originalOnNodeAdded = vi.fn() + const originalOnNodeRemoved = vi.fn() + const originalOnConnectionChange = vi.fn() + + mockGraph.onNodeAdded = originalOnNodeAdded + mockGraph.onNodeRemoved = originalOnNodeRemoved + mockGraph.onConnectionChange = originalOnConnectionChange + + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.setupEventListeners() + graphManager.cleanupEventListeners() + + // Should restore original callbacks + expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded) + expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved) + expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange) + }) + + it('should handle cleanup for never-setup graph', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.cleanupEventListeners() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Attempted to cleanup event listeners for graph that was never set up' + ) + + consoleErrorSpy.mockRestore() + }) + + it('should detect node position changes', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // First check - cache initial state + let hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) // Initial cache population + + // No changes + hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(false) + + // Change node position + mockGraph._nodes[0].pos = [200, 150] + hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) + expect(graphManager.updateFlags.value.bounds).toBe(true) + expect(graphManager.updateFlags.value.nodes).toBe(true) + }) + + it('should detect node count changes', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // Cache initial state + graphManager.checkForChanges() + + // Add a node + mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any) + + const hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) + expect(graphManager.updateFlags.value.bounds).toBe(true) + expect(graphManager.updateFlags.value.nodes).toBe(true) + }) + + it('should detect connection changes', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // Cache initial state + graphManager.checkForChanges() + + // Change connections + mockGraph.links = new Map([ + [1, { id: 1 }], + [2, { id: 2 }] + ]) as any + + const hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) + expect(graphManager.updateFlags.value.connections).toBe(true) + }) + + it('should handle node removal in callbacks', () => { + const originalOnNodeRemoved = vi.fn() + mockGraph.onNodeRemoved = originalOnNodeRemoved + + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.setupEventListeners() + + const removedNode = { id: '2' } as LGraphNode + mockGraph.onNodeRemoved!(removedNode) + + expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode) + expect(onGraphChangedMock).toHaveBeenCalled() + }) + + it('should destroy properly', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.init() + graphManager.setupEventListeners() + graphManager.destroy() + + expect(api.removeEventListener).toHaveBeenCalledWith( + 'graphChanged', + expect.any(Function) + ) + }) + + it('should clear cache', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // Populate cache + graphManager.checkForChanges() + + // Clear cache + graphManager.clearCache() + + // Should detect changes again after clear + const hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) + }) + + it('should handle null graph gracefully', () => { + const graphRef = ref(null as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + expect(() => graphManager.setupEventListeners()).not.toThrow() + expect(() => graphManager.cleanupEventListeners()).not.toThrow() + expect(graphManager.checkForChanges()).toBe(false) + }) + + it('should clean up removed nodes from cache', () => { + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + // Cache initial state + graphManager.checkForChanges() + + // Remove a node + mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2') + + const hasChanges = graphManager.checkForChanges() + expect(hasChanges).toBe(true) + expect(graphManager.updateFlags.value.bounds).toBe(true) + }) + + it('should throttle graph changed callback', () => { + const throttledFn = vi.fn() + vi.mocked(useThrottleFn).mockReturnValue(throttledFn) + + const graphRef = ref(mockGraph as any) + const graphManager = useMinimapGraph(graphRef, onGraphChangedMock) + + graphManager.setupEventListeners() + + // Trigger multiple changes rapidly + mockGraph.onNodeAdded!({ id: '3' } as LGraphNode) + mockGraph.onNodeAdded!({ id: '4' } as LGraphNode) + mockGraph.onNodeAdded!({ id: '5' } as LGraphNode) + + // Should be throttled + expect(throttledFn).toHaveBeenCalledTimes(3) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapInteraction.test.ts b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapInteraction.test.ts new file mode 100644 index 000000000..714826ab4 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapInteraction.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction' +import type { MinimapCanvas } from '@/renderer/extensions/minimap/types' + +describe('useMinimapInteraction', () => { + let mockContainer: HTMLDivElement + let mockCanvas: MinimapCanvas + let centerViewOnMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockContainer = { + getBoundingClientRect: vi.fn().mockReturnValue({ + left: 100, + top: 50, + width: 250, + height: 200 + }) + } as any + + mockCanvas = { + ds: { + scale: 1, + offset: [0, 0] + }, + setDirty: vi.fn() + } as any + + centerViewOnMock = vi.fn() + }) + + it('should initialize with default values', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + expect(interaction.isDragging.value).toBe(false) + expect(interaction.containerRect.value).toEqual({ + left: 0, + top: 0, + width: 250, + height: 200 + }) + }) + + it('should update container rect', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + interaction.updateContainerRect() + + expect(mockContainer.getBoundingClientRect).toHaveBeenCalled() + + expect(interaction.containerRect.value).toEqual({ + left: 100, + top: 50, + width: 250, + height: 200 + }) + }) + + it('should handle pointer down and start dragging', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + const event = new PointerEvent('pointerdown', { + clientX: 150, + clientY: 100 + }) + + interaction.handlePointerDown(event) + + expect(interaction.isDragging.value).toBe(true) + expect(mockContainer.getBoundingClientRect).toHaveBeenCalled() + expect(centerViewOnMock).toHaveBeenCalled() + }) + + it('should handle pointer move when dragging', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + // Start dragging + interaction.handlePointerDown( + new PointerEvent('pointerdown', { + clientX: 150, + clientY: 100 + }) + ) + + // Move pointer + const moveEvent = new PointerEvent('pointermove', { + clientX: 200, + clientY: 150 + }) + + interaction.handlePointerMove(moveEvent) + + // Should calculate world coordinates and center view + expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move + + // Calculate expected world coordinates + const x = 200 - 100 // clientX - containerLeft + const y = 150 - 50 // clientY - containerTop + const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2 + const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2 + const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX + const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY + + expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY) + }) + + it('should not move when not dragging', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + const moveEvent = new PointerEvent('pointermove', { + clientX: 200, + clientY: 150 + }) + + interaction.handlePointerMove(moveEvent) + + expect(centerViewOnMock).not.toHaveBeenCalled() + }) + + it('should handle pointer up to stop dragging', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + // Start dragging + interaction.handlePointerDown( + new PointerEvent('pointerdown', { + clientX: 150, + clientY: 100 + }) + ) + + expect(interaction.isDragging.value).toBe(true) + + interaction.handlePointerUp() + + expect(interaction.isDragging.value).toBe(false) + }) + + it('should handle wheel events for zooming', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100, + clientX: 200, + clientY: 150 + }) + wheelEvent.preventDefault = vi.fn() + + interaction.handleWheel(wheelEvent) + + // Should update canvas scale (zoom in) + expect(mockCanvas.ds.scale).toBeCloseTo(1.1) + expect(centerViewOnMock).toHaveBeenCalled() + }) + + it('should respect zoom limits', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + // Set scale close to minimum + mockCanvas.ds.scale = 0.11 + + const wheelEvent = new WheelEvent('wheel', { + deltaY: 100, // Zoom out + clientX: 200, + clientY: 150 + }) + wheelEvent.preventDefault = vi.fn() + + interaction.handleWheel(wheelEvent) + + // Should not go below minimum scale + expect(mockCanvas.ds.scale).toBe(0.11) + expect(centerViewOnMock).not.toHaveBeenCalled() + }) + + it('should handle null container gracefully', () => { + const containerRef = ref(undefined) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(mockCanvas as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + // Should not throw + expect(() => interaction.updateContainerRect()).not.toThrow() + expect(() => + interaction.handlePointerDown(new PointerEvent('pointerdown')) + ).not.toThrow() + }) + + it('should handle null canvas gracefully', () => { + const containerRef = ref(mockContainer) + const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 }) + const scaleRef = ref(0.5) + const canvasRef = ref(null as any) + + const interaction = useMinimapInteraction( + containerRef, + boundsRef, + scaleRef, + 250, + 200, + centerViewOnMock, + canvasRef + ) + + // Should not throw + expect(() => + interaction.handlePointerMove(new PointerEvent('pointermove')) + ).not.toThrow() + expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow() + expect(centerViewOnMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapRenderer.test.ts b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapRenderer.test.ts new file mode 100644 index 000000000..9dcecd646 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapRenderer.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer' +import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer' +import type { UpdateFlags } from '@/renderer/extensions/minimap/types' + +vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({ + renderMinimapToCanvas: vi.fn() +})) + +describe('useMinimapRenderer', () => { + let mockCanvas: HTMLCanvasElement + let mockContext: CanvasRenderingContext2D + let mockGraph: LGraph + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + clearRect: vi.fn() + } as any + + mockCanvas = { + getContext: vi.fn().mockReturnValue(mockContext) + } as any + + mockGraph = { + _nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }] + } as any + }) + + it('should initialize with full redraw needed', () => { + const canvasRef = ref(mockCanvas) + const graphRef = ref(mockGraph as any) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + expect(renderer.needsFullRedraw.value).toBe(true) + expect(renderer.needsBoundsUpdate.value).toBe(true) + }) + + it('should handle empty graph with fast path', () => { + const emptyGraph = { _nodes: [] } as any + const canvasRef = ref(mockCanvas) + const graphRef = ref(emptyGraph) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + renderer.renderMinimap() + + expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200) + expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled() + }) + + it('should only render when redraw is needed', async () => { + const { renderMinimapToCanvas } = await import( + '@/renderer/extensions/minimap/minimapCanvasRenderer' + ) + const canvasRef = ref(mockCanvas) + const graphRef = ref(mockGraph as any) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + // First render (needsFullRedraw is true by default) + renderer.renderMinimap() + expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1) + + // Second render without changes (should not render) + renderer.renderMinimap() + expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1) + + // Set update flag and render again + updateFlagsRef.value.nodes = true + renderer.renderMinimap() + expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2) + }) + + it('should update minimap with bounds and viewport callbacks', () => { + const updateBounds = vi.fn() + const updateViewport = vi.fn() + + const canvasRef = ref(mockCanvas) + const graphRef = ref(mockGraph as any) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: true, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + renderer.updateMinimap(updateBounds, updateViewport) + + expect(updateBounds).toHaveBeenCalled() + expect(updateViewport).toHaveBeenCalled() + expect(updateFlagsRef.value.bounds).toBe(false) + expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false + expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false + }) + + it('should force full redraw when requested', () => { + const canvasRef = ref(mockCanvas) + const graphRef = ref(mockGraph as any) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + renderer.forceFullRedraw() + + expect(renderer.needsFullRedraw.value).toBe(true) + expect(updateFlagsRef.value.bounds).toBe(true) + expect(updateFlagsRef.value.nodes).toBe(true) + expect(updateFlagsRef.value.connections).toBe(true) + expect(updateFlagsRef.value.viewport).toBe(true) + }) + + it('should handle null canvas gracefully', () => { + const canvasRef = ref(undefined) + const graphRef = ref(mockGraph as any) + const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 }) + const scaleRef = ref(1) + const updateFlagsRef = ref({ + bounds: false, + nodes: false, + connections: false, + viewport: false + }) + const settings = { + nodeColors: ref(true), + showLinks: ref(true), + showGroups: ref(true), + renderBypass: ref(false), + renderError: ref(false) + } + + const renderer = useMinimapRenderer( + canvasRef, + graphRef, + boundsRef, + scaleRef, + updateFlagsRef, + settings, + 250, + 200 + ) + + // Should not throw + expect(() => renderer.renderMinimap()).not.toThrow() + expect(mockCanvas.getContext).not.toHaveBeenCalled() + }) +}) diff --git a/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapSettings.test.ts b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapSettings.test.ts new file mode 100644 index 000000000..d4b004180 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapSettings.test.ts @@ -0,0 +1,122 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings' +import { useSettingStore } from '@/stores/settingStore' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' + +vi.mock('@/stores/settingStore') +vi.mock('@/stores/workspace/colorPaletteStore') + +describe('useMinimapSettings', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('should return all minimap settings as computed refs', () => { + const mockSettingStore = { + get: vi.fn((key: string) => { + const settings: Record = { + 'Comfy.Minimap.NodeColors': true, + 'Comfy.Minimap.ShowLinks': false, + 'Comfy.Minimap.ShowGroups': true, + 'Comfy.Minimap.RenderBypassState': false, + 'Comfy.Minimap.RenderErrorState': true + } + return settings[key] + }) + } + + vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any) + vi.mocked(useColorPaletteStore).mockReturnValue({ + completedActivePalette: { light_theme: false } + } as any) + + const settings = useMinimapSettings() + + expect(settings.nodeColors.value).toBe(true) + expect(settings.showLinks.value).toBe(false) + expect(settings.showGroups.value).toBe(true) + expect(settings.renderBypass.value).toBe(false) + expect(settings.renderError.value).toBe(true) + }) + + it('should generate container styles based on theme', () => { + const mockColorPaletteStore = { + completedActivePalette: { light_theme: false } + } + + vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any) + vi.mocked(useColorPaletteStore).mockReturnValue( + mockColorPaletteStore as any + ) + + const settings = useMinimapSettings() + const styles = settings.containerStyles.value + + expect(styles.width).toBe('250px') + expect(styles.height).toBe('200px') + expect(styles.backgroundColor).toBe('#15161C') // dark theme color + expect(styles.border).toBe('1px solid #333') + }) + + it('should generate light theme container styles', () => { + const mockColorPaletteStore = { + completedActivePalette: { light_theme: true } + } + + vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any) + vi.mocked(useColorPaletteStore).mockReturnValue( + mockColorPaletteStore as any + ) + + const settings = useMinimapSettings() + const styles = settings.containerStyles.value + + expect(styles.backgroundColor).toBe('#FAF9F5') // light theme color + expect(styles.border).toBe('1px solid #ccc') + }) + + it('should generate panel styles based on theme', () => { + const mockColorPaletteStore = { + completedActivePalette: { light_theme: false } + } + + vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any) + vi.mocked(useColorPaletteStore).mockReturnValue( + mockColorPaletteStore as any + ) + + const settings = useMinimapSettings() + const styles = settings.panelStyles.value + + expect(styles.backgroundColor).toBe('#15161C') + expect(styles.border).toBe('1px solid #333') + expect(styles.borderRadius).toBe('8px') + }) + + it('should create computed properties that call the store getter', () => { + const mockGet = vi.fn((key: string) => { + if (key === 'Comfy.Minimap.NodeColors') return true + if (key === 'Comfy.Minimap.ShowLinks') return false + return true + }) + const mockSettingStore = { get: mockGet } + + vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any) + vi.mocked(useColorPaletteStore).mockReturnValue({ + completedActivePalette: { light_theme: false } + } as any) + + const settings = useMinimapSettings() + + // Access the computed properties + expect(settings.nodeColors.value).toBe(true) + expect(settings.showLinks.value).toBe(false) + + // Verify the store getter was called with the correct keys + expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors') + expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks') + }) +}) diff --git a/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapViewport.test.ts b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapViewport.test.ts new file mode 100644 index 000000000..dafc5eaaa --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/composables/useMinimapViewport.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport' +import type { MinimapCanvas } from '@/renderer/extensions/minimap/types' + +vi.mock('@/composables/canvas/useCanvasTransformSync') +vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({ + calculateNodeBounds: vi.fn(), + calculateMinimapScale: vi.fn(), + enforceMinimumBounds: vi.fn() +})) + +describe('useMinimapViewport', () => { + let mockCanvas: MinimapCanvas + let mockGraph: LGraph + + beforeEach(() => { + vi.clearAllMocks() + + mockCanvas = { + canvas: { + clientWidth: 800, + clientHeight: 600, + width: 1600, + height: 1200 + } as HTMLCanvasElement, + ds: { + scale: 1, + offset: [0, 0] + }, + setDirty: vi.fn() + } + + mockGraph = { + _nodes: [ + { pos: [100, 100], size: [150, 80] }, + { pos: [300, 200], size: [120, 60] } + ] + } as any + + vi.mocked(useCanvasTransformSync).mockReturnValue({ + startSync: vi.fn(), + stopSync: vi.fn() + } as any) + }) + + it('should initialize with default bounds', () => { + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + expect(viewport.bounds.value).toEqual({ + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + width: 0, + height: 0 + }) + + expect(viewport.scale.value).toBe(1) + }) + + it('should calculate graph bounds from nodes', async () => { + const { calculateNodeBounds, enforceMinimumBounds } = await import( + '@/renderer/core/spatial/boundsCalculator' + ) + + vi.mocked(calculateNodeBounds).mockReturnValue({ + minX: 100, + minY: 100, + maxX: 420, + maxY: 260, + width: 320, + height: 160 + }) + + vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateBounds() + + expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes) + expect(enforceMinimumBounds).toHaveBeenCalled() + }) + + it('should handle empty graph', async () => { + const { calculateNodeBounds } = await import( + '@/renderer/core/spatial/boundsCalculator' + ) + + vi.mocked(calculateNodeBounds).mockReturnValue(null) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref({ _nodes: [] } as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateBounds() + + expect(viewport.bounds.value).toEqual({ + minX: 0, + minY: 0, + maxX: 100, + maxY: 100, + width: 100, + height: 100 + }) + }) + + it('should update canvas dimensions', () => { + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateCanvasDimensions() + + expect(viewport.canvasDimensions.value).toEqual({ + width: 800, + height: 600 + }) + }) + + it('should calculate viewport transform', async () => { + const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } = + await import('@/renderer/core/spatial/boundsCalculator') + + // Mock the bounds calculation + vi.mocked(calculateNodeBounds).mockReturnValue({ + minX: 0, + minY: 0, + maxX: 500, + maxY: 400, + width: 500, + height: 400 + }) + + vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds) + vi.mocked(calculateMinimapScale).mockReturnValue(0.5) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + // Set canvas transform + mockCanvas.ds.scale = 2 + mockCanvas.ds.offset = [-100, -50] + + // Update bounds and viewport + viewport.updateBounds() + viewport.updateCanvasDimensions() + viewport.updateViewport() + + const transform = viewport.viewportTransform.value + + // World coordinates + const worldX = -(-100) // -offset[0] = 100 + const worldY = -(-50) // -offset[1] = 50 + + // Viewport size in world coordinates + const viewportWidth = 800 / 2 // canvasWidth / scale = 400 + const viewportHeight = 600 / 2 // canvasHeight / scale = 300 + + // Center offsets + const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0 + const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0 + + // Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX + expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50 + expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25 + expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200 + expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150 + }) + + it('should center view on world coordinates', () => { + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateCanvasDimensions() + mockCanvas.ds.scale = 2 + + viewport.centerViewOn(300, 200) + + // Should update canvas offset to center on the given world coordinates + const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2) + const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2) + + expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX) + expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true) + }) + + it('should start and stop viewport sync', () => { + const startSyncMock = vi.fn() + const stopSyncMock = vi.fn() + + vi.mocked(useCanvasTransformSync).mockReturnValue({ + startSync: startSyncMock, + stopSync: stopSyncMock + } as any) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.startViewportSync() + expect(startSyncMock).toHaveBeenCalled() + + viewport.stopViewportSync() + expect(stopSyncMock).toHaveBeenCalled() + }) + + it('should handle null canvas gracefully', () => { + const canvasRef = ref(null as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + // Should not throw + expect(() => viewport.updateCanvasDimensions()).not.toThrow() + expect(() => viewport.updateViewport()).not.toThrow() + expect(() => viewport.centerViewOn(100, 100)).not.toThrow() + }) + + it('should calculate scale correctly', async () => { + const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } = + await import('@/renderer/core/spatial/boundsCalculator') + + const testBounds = { + minX: 0, + minY: 0, + maxX: 500, + maxY: 400, + width: 500, + height: 400 + } + + vi.mocked(calculateNodeBounds).mockReturnValue(testBounds) + vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds) + vi.mocked(calculateMinimapScale).mockReturnValue(0.4) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateBounds() + + expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200) + expect(viewport.scale.value).toBe(0.4) + }) + + it('should handle device pixel ratio', () => { + const originalDPR = window.devicePixelRatio + Object.defineProperty(window, 'devicePixelRatio', { + value: 2, + configurable: true + }) + + const canvasRef = ref(mockCanvas as any) + const graphRef = ref(mockGraph as any) + + const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200) + + viewport.updateCanvasDimensions() + + // Should use client dimensions or calculate from canvas dimensions / dpr + expect(viewport.canvasDimensions.value.width).toBe(800) + expect(viewport.canvasDimensions.value.height).toBe(600) + + Object.defineProperty(window, 'devicePixelRatio', { + value: originalDPR, + configurable: true + }) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/minimap/minimapCanvasRenderer.test.ts b/tests-ui/tests/renderer/extensions/minimap/minimapCanvasRenderer.test.ts new file mode 100644 index 000000000..25d0058cd --- /dev/null +++ b/tests-ui/tests/renderer/extensions/minimap/minimapCanvasRenderer.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer' +import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' +import { adjustColor } from '@/utils/colorUtil' + +vi.mock('@/stores/workspace/colorPaletteStore') +vi.mock('@/utils/colorUtil', () => ({ + adjustColor: vi.fn((color: string) => color + '_adjusted') +})) + +describe('minimapCanvasRenderer', () => { + let mockCanvas: HTMLCanvasElement + let mockContext: CanvasRenderingContext2D + let mockGraph: LGraph + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1 + } as any + + mockCanvas = { + getContext: vi.fn().mockReturnValue(mockContext) + } as any + + mockGraph = { + _nodes: [ + { + id: '1', + pos: [100, 100], + size: [150, 80], + bgcolor: '#FF0000', + mode: LGraphEventMode.ALWAYS, + has_errors: false, + outputs: [] + }, + { + id: '2', + pos: [300, 200], + size: [120, 60], + bgcolor: '#00FF00', + mode: LGraphEventMode.BYPASS, + has_errors: true, + outputs: [] + } + ] as unknown as LGraphNode[], + _groups: [], + links: {}, + getNodeById: vi.fn() + } as any + + vi.mocked(useColorPaletteStore).mockReturnValue({ + completedActivePalette: { light_theme: false } + } as any) + }) + + it('should clear canvas and render nodes', () => { + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: true, + renderError: true + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Should clear the canvas first + expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200) + + // Should render nodes (batch by color) + expect(mockContext.fillRect).toHaveBeenCalled() + }) + + it('should handle empty graph', () => { + mockGraph._nodes = [] + + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200) + expect(mockContext.fillRect).not.toHaveBeenCalled() + }) + + it('should batch render nodes by color', () => { + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Should set fill style for each color group + const fillStyleCalls = [] + let currentStyle = '' + + mockContext.fillStyle = '' + Object.defineProperty(mockContext, 'fillStyle', { + get: () => currentStyle, + set: (value) => { + currentStyle = value + fillStyleCalls.push(value) + } + }) + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Different colors for different nodes + expect(fillStyleCalls.length).toBeGreaterThan(0) + }) + + it('should render bypass nodes with special color', () => { + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: true, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Node 2 is in bypass mode, should be rendered + expect(mockContext.fillRect).toHaveBeenCalled() + }) + + it('should render error outlines when enabled', () => { + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: false, + renderError: true + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Should set stroke style for errors + expect(mockContext.strokeStyle).toBe('#FF0000') + expect(mockContext.strokeRect).toHaveBeenCalled() + }) + + it('should render groups when enabled', () => { + mockGraph._groups = [ + { + pos: [50, 50], + size: [400, 300], + color: '#0000FF' + } + ] as any + + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: true, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Groups should be rendered before nodes + expect(mockContext.fillRect).toHaveBeenCalled() + }) + + it('should render connections when enabled', () => { + const targetNode = { + id: '2', + pos: [300, 200], + size: [120, 60] + } + + mockGraph._nodes[0].outputs = [ + { + links: [1] + } + ] as any + + // Create a hybrid Map/Object for links as LiteGraph expects + const linksMap = new Map([[1, { id: 1, target_id: 2 }]]) + const links = Object.assign(linksMap, { + 1: { id: 1, target_id: 2 } + }) + mockGraph.links = links as any + + mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode) + + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: false, + showLinks: true, + showGroups: false, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Should draw connection lines + expect(mockContext.beginPath).toHaveBeenCalled() + expect(mockContext.moveTo).toHaveBeenCalled() + expect(mockContext.lineTo).toHaveBeenCalled() + expect(mockContext.stroke).toHaveBeenCalled() + + // Should draw connection slots + expect(mockContext.arc).toHaveBeenCalled() + expect(mockContext.fill).toHaveBeenCalled() + }) + + it('should handle light theme colors', () => { + vi.mocked(useColorPaletteStore).mockReturnValue({ + completedActivePalette: { light_theme: true } + } as any) + + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 500, height: 400 }, + scale: 0.5, + settings: { + nodeColors: true, + showLinks: false, + showGroups: false, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // Color adjustment should be called for light theme + expect(adjustColor).toHaveBeenCalled() + }) + + it('should calculate correct offsets for centering', () => { + const context: MinimapRenderContext = { + bounds: { minX: 0, minY: 0, width: 200, height: 100 }, + scale: 0.5, + settings: { + nodeColors: false, + showLinks: false, + showGroups: false, + renderBypass: false, + renderError: false + }, + width: 250, + height: 200 + } + + renderMinimapToCanvas(mockCanvas, mockGraph, context) + + // With bounds 200x100 at scale 0.5 = 100x50 + // Canvas is 250x200, so offset should be (250-100)/2 = 75, (200-50)/2 = 75 + // This affects node positioning + expect(mockContext.fillRect).toHaveBeenCalled() + }) +}) diff --git a/tests-ui/tests/composables/useWorkflowThumbnail.spec.ts b/tests-ui/tests/renderer/thumbnail/composables/useWorkflowThumbnail.spec.ts similarity index 93% rename from tests-ui/tests/composables/useWorkflowThumbnail.spec.ts rename to tests-ui/tests/renderer/thumbnail/composables/useWorkflowThumbnail.spec.ts index 37d132344..868fb05fa 100644 --- a/tests-ui/tests/composables/useWorkflowThumbnail.spec.ts +++ b/tests-ui/tests/renderer/thumbnail/composables/useWorkflowThumbnail.spec.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore' -vi.mock('@/composables/useMinimap', () => ({ - useMinimap: vi.fn() +vi.mock('@/renderer/thumbnail/graphThumbnailRenderer', () => ({ + createGraphThumbnail: vi.fn() })) vi.mock('@/scripts/api', () => ({ @@ -19,13 +19,14 @@ vi.mock('@/scripts/api', () => ({ })) const { useWorkflowThumbnail } = await import( - '@/composables/useWorkflowThumbnail' + '@/renderer/thumbnail/composables/useWorkflowThumbnail' +) +const { createGraphThumbnail } = await import( + '@/renderer/thumbnail/graphThumbnailRenderer' ) -const { useMinimap } = await import('@/composables/useMinimap') const { api } = await import('@/scripts/api') describe('useWorkflowThumbnail', () => { - let mockMinimapInstance: any let workflowStore: ReturnType beforeEach(() => { @@ -39,35 +40,23 @@ describe('useWorkflowThumbnail', () => { // Now set up mocks vi.clearAllMocks() - const blob = new Blob() - global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test') global.URL.revokeObjectURL = vi.fn() // Mock API responses vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response) - mockMinimapInstance = { - renderMinimap: vi.fn(), - canvasRef: { - value: { - toBlob: vi.fn((cb) => cb(blob)) - } - }, - width: 250, - height: 200 - } - - vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance) + // Default createGraphThumbnail to return test value + vi.mocked(createGraphThumbnail).mockReturnValue( + 'data:image/png;base64,test' + ) }) it('should capture minimap thumbnail', async () => { const { createMinimapPreview } = useWorkflowThumbnail() const thumbnail = await createMinimapPreview() - expect(useMinimap).toHaveBeenCalledOnce() - expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce() - + expect(createGraphThumbnail).toHaveBeenCalledOnce() expect(thumbnail).toBe('data:image/png;base64,test') }) @@ -161,6 +150,9 @@ describe('useWorkflowThumbnail', () => { // Reset the mock to track new calls and create different URL vi.clearAllMocks() global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2') + vi.mocked(createGraphThumbnail).mockReturnValue( + 'data:image/png;base64,test2' + ) // Store second thumbnail for same workflow - should revoke the first URL await storeThumbnail(mockWorkflow)