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