[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
This commit is contained in:
Christian Byrne
2025-08-17 21:24:08 -07:00
committed by GitHub
parent ceac8f3741
commit 5a35562d3d
25 changed files with 3025 additions and 913 deletions

View File

@@ -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(() => {

View File

@@ -80,7 +80,7 @@
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { MinimapOptionKey } from '@/composables/useMinimap'
import type { MinimapSettingsKey } from '@/renderer/extensions/minimap/types'
defineProps<{
panelStyles: any
@@ -92,6 +92,6 @@ defineProps<{
}>()
defineEmits<{
updateOption: [key: MinimapOptionKey, value: boolean]
updateOption: [key: MinimapSettingsKey, value: boolean]
}>()
</script>

View File

@@ -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'

View File

@@ -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<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<any>(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<NodeId, string>()
const linksCache = ref<string>('')
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<string, GraphCallbacks>()
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
}
}

View File

@@ -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<number>
size: ArrayLike<number>
}
/**
* 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
}

View File

@@ -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<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<HTMLElement | null>(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
}
}

View File

@@ -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<LGraph | null>,
onGraphChanged: () => void
) {
const nodeStatesCache = new Map<NodeId, string>()
const linksCache = ref<string>('')
const lastNodeCount = ref(0)
const updateFlags = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
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
}
}

View File

@@ -0,0 +1,107 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { MinimapCanvas } from '../types'
export function useMinimapInteraction(
containerRef: Ref<HTMLDivElement | undefined>,
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
scale: Ref<number>,
width: number,
height: number,
centerViewOn: (worldX: number, worldY: number) => void,
canvas: Ref<MinimapCanvas | null>
) {
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
}
}

View File

@@ -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<HTMLCanvasElement | undefined>,
graph: Ref<LGraph | null>,
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
scale: Ref<number>,
updateFlags: Ref<UpdateFlags>,
settings: {
nodeColors: Ref<boolean>
showLinks: Ref<boolean>
showGroups: Ref<boolean>
renderBypass: Ref<boolean>
renderError: Ref<boolean>
},
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
}
}

View File

@@ -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
}
}

View File

@@ -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<MinimapCanvas | null>,
graph: Ref<LGraph | null>,
width: number,
height: number
) {
const bounds = ref<MinimapBounds>({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
const scale = ref(1)
const viewportTransform = ref<ViewportTransform>({
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
}
}

View File

@@ -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<typeof getMinimapColors>
) {
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<typeof getMinimapColors>
) {
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<typeof getMinimapColors>
) {
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)
}

View File

@@ -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'

View File

@@ -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<Map<string, string>>(new Map())
// Shared minimap instance
let minimap: ReturnType<typeof useMinimap> | null = null
export const useWorkflowThumbnail = () => {
/**
* Capture a thumbnail of the canvas
*/
const createMinimapPreview = (): Promise<string | null> => {
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)

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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'