Compare commits

..

10 Commits

Author SHA1 Message Date
Terry Jia
e4085bdeb4 update tests 2025-07-23 22:50:03 -04:00
github-actions
0066fdf0b7 Update test expectations [skip ci] 2025-07-24 02:45:39 +00:00
Terry Jia
d55c68eb9c update tests 2025-07-23 22:26:11 -04:00
Terry Jia
7d5ab8faa4 update tests 2025-07-23 21:40:39 -04:00
Terry Jia
38934f3915 update tests 2025-07-23 20:49:20 -04:00
Terry Jia
2ffde2c4e1 unit tests and playwright tests 2025-07-22 21:55:03 -04:00
github-actions
b744d4d133 Update locales [skip ci] 2025-07-21 14:48:47 +00:00
Terry Jia
f3def8373a remove Comfy.Minimap.RefreshDelay 2025-07-21 10:39:59 -04:00
github-actions
09088c773a Update locales [skip ci] 2025-07-19 03:08:36 +00:00
Terry Jia
0e7bdcf209 minimap 2025-07-18 23:00:48 -04:00
43 changed files with 1829 additions and 338 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -9,6 +9,10 @@ import {
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
})
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('mixed_graph_items')
await comfyPage.canvas.press('Control+a')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,146 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Minimap', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await expect(toggleButton).not.toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
})
test('Validate minimap position and size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
const boundingBox = await minimapContainer.boundingBox()
expect(boundingBox).not.toBeNull()
if (boundingBox) {
expect(boundingBox.width).toBeGreaterThan(0)
expect(boundingBox.height).toBeGreaterThan(0)
const viewportSize = comfyPage.page.viewportSize()!
expect(boundingBox.x + boundingBox.width).toBeCloseTo(
viewportSize.width - 90,
50
)
expect(boundingBox.y + boundingBox.height).toBeCloseTo(
viewportSize.height - 20,
50
)
}
})
test('Validate minimap canvas dimensions', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
const minimapCanvas = comfyPage.page.locator(
'.litegraph-minimap .minimap-canvas'
)
await expect(minimapCanvas).toBeVisible()
const width = await minimapCanvas.getAttribute('width')
const height = await minimapCanvas.getAttribute('height')
expect(parseInt(width || '0')).toBeGreaterThan(0)
expect(parseInt(height || '0')).toBeGreaterThan(0)
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
})
})

View File

@@ -38,14 +38,23 @@ import { useI18n } from 'vue-i18n'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { MODEL_SOURCES } from '@/constants/urls'
import { useSettingStore } from '@/stores/settingStore'
import { isElectron } from '@/utils/envUtil'
/** @todo Read this from server internal API rather than hardcoding here */
const allowedSources = MODEL_SOURCES.allowedDomains
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
const whiteListedUrls = new Set(MODEL_SOURCES.whitelistedUrls)
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {
name: string

View File

@@ -19,6 +19,12 @@
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
<MiniMap
v-if="comfyAppReady"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
</LiteGraphCanvasSplitterOverlay>
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
@@ -53,6 +59,7 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
@@ -67,6 +74,7 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
@@ -114,6 +122,9 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -345,6 +356,13 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -56,6 +56,15 @@
data-testid="toggle-link-visibility-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' [Atl + M]'"
severity="secondary"
:icon="'pi pi-map'"
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
:class="{ 'minimap-active': minimap.visible.value }"
data-testid="toggle-minimap-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
/>
</ButtonGroup>
</template>
@@ -66,6 +75,7 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMinimap } from '@/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -74,6 +84,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const minimap = useMinimap()
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
@@ -107,4 +118,15 @@ const stopRepeat = () => {
margin: 0;
border-radius: 0;
}
.p-button.minimap-active {
background-color: var(--p-button-primary-background);
border-color: var(--p-button-primary-border-color);
color: var(--p-button-primary-color);
}
.p-button.minimap-active:hover {
background-color: var(--p-button-primary-hover-background);
border-color: var(--p-button-primary-hover-border-color);
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div
v-if="visible"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const {
initialized,
visible,
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel
} = minimap
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
})
onUnmounted(() => {
destroy()
})
</script>
<style scoped>
.litegraph-minimap {
overflow: hidden;
}
.minimap-canvas {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.minimap-viewport {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
</style>

View File

@@ -123,7 +123,6 @@ import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS, GITHUB_REPOS, getDesktopGuideUrl } from '@/constants/urls'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
@@ -144,10 +143,11 @@ interface MenuItem {
// Constants
const EXTERNAL_LINKS = {
DOCS: COMFY_URLS.docs.base,
DISCORD: COMFY_URLS.community.discord,
GITHUB: GITHUB_REPOS.comfyui,
UPDATE_GUIDE: COMFY_URLS.docs.installation.update
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
@@ -199,7 +199,7 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => {
openExternalLink(getDesktopGuideUrl(locale.value))
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
}
},
@@ -451,13 +451,13 @@ const onUpdate = (_: ReleaseNote): void => {
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
if (showVersionUpdates.value && !hasReleases.value) {
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})

View File

@@ -48,7 +48,6 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS } from '@/constants/urls'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -73,8 +72,8 @@ const shouldShow = computed(
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
@@ -121,7 +120,7 @@ const handleLearnMore = () => {
}
const handleUpdate = () => {
window.open(COMFY_URLS.docs.installation.update, '_blank')
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
dismissToast()
}

View File

@@ -68,7 +68,6 @@ import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS } from '@/constants/urls'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -93,8 +92,8 @@ const shouldShow = computed(
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)

View File

@@ -43,7 +43,7 @@ import Panel from 'primevue/panel'
import { ModelRef, computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/mirrors'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'

View File

@@ -23,7 +23,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import { UVMirror } from '@/constants/mirrors'
import { UVMirror } from '@/constants/uvMirrors'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'

View File

@@ -7,11 +7,11 @@ import {
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useMinimap } from '@/composables/useMinimap'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { COMFY_URLS, GITHUB_REPOS } from '@/constants/urls'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -314,6 +314,15 @@ export function useCoreCommands(): ComfyCommand[] {
}
})()
},
{
id: 'Comfy.Canvas.ToggleMinimap',
icon: 'pi pi-map',
label: 'Canvas Toggle Minimap',
versionAdded: '1.24.1',
function: async () => {
await useMinimap().toggle()
}
},
{
id: 'Comfy.QueuePrompt',
icon: 'pi pi-play',
@@ -544,7 +553,10 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Issues',
versionAdded: '1.5.5',
function: () => {
window.open(GITHUB_REPOS.comfyuiIssues, '_blank')
window.open(
'https://github.com/comfyanonymous/ComfyUI/issues',
'_blank'
)
}
},
{
@@ -554,7 +566,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Docs',
versionAdded: '1.5.5',
function: () => {
window.open(COMFY_URLS.docs.base, '_blank')
window.open('https://docs.comfy.org/', '_blank')
}
},
{
@@ -564,7 +576,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'Comfy-Org Discord',
versionAdded: '1.5.5',
function: () => {
window.open(COMFY_URLS.community.discord, '_blank')
window.open('https://www.comfy.org/discord', '_blank')
}
},
{
@@ -644,7 +656,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Forum',
versionAdded: '1.8.2',
function: () => {
window.open(COMFY_URLS.community.forum.base, '_blank')
window.open('https://forum.comfy.org/', '_blank')
}
},
{

View File

@@ -0,0 +1,631 @@
import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const globalState = {
visible: ref(true),
minimapRef: ref<any>(null),
initialized: false
}
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const visible = globalState.visible
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
const nodeColor = '#666'
const viewportColor = '#FFF'
const backgroundColor = '#1e1e1e'
const borderColor = '#444'
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(() => canvas.value?.graph)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: backgroundColor,
border: `1px solid ${borderColor}`
}))
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 ${viewportColor}`,
backgroundColor: `${viewportColor}33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
pointerEvents: 'none' as const
}))
const calculateGraphBounds = () => {
const g = graph.value
if (!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])
}
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY
}
}
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
return Math.min(scaleX, scaleY) * 0.9
}
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
ctx.fillStyle = node.color || node.constructor.color || nodeColor
ctx.fillRect(x, y, w, h)
ctx.strokeStyle = 'rgba(0,0,0,0.2)'
ctx.lineWidth = 0.5
ctx.strokeRect(x, y, w, h)
}
}
const renderConnections = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g) return
ctx.strokeStyle = 'rgba(255,255,255,0.2)'
ctx.lineWidth = 2
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
ctx.beginPath()
ctx.moveTo(
x1 + node.size[0] * scale.value,
y1 + node.size[1] * scale.value * 0.2
)
ctx.lineTo(x2, y2 + targetNode.size[1] * scale.value * 0.2)
ctx.stroke()
}
}
}
}
const renderMinimap = () => {
if (!canvasRef.value || !graph.value) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) 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
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
}
if (
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
) {
renderMinimap()
}
}
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 { pause: pauseViewportUpdate, resume: resumeViewportUpdate } = useRafFn(
() => {
updateViewport()
},
{ immediate: false, fpsLimit: 60 }
)
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true
updateContainerRect()
handleMouseMove(e)
}
const handleMouseMove = (e: MouseEvent) => {
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 handleMouseUp = () => {
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)
}
let originalCallbacks: any = {}
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
originalCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
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
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
}
}
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()
resumeViewportUpdate()
}
initialized.value = true
}
}
const destroy = () => {
pauseChangeDetection()
pauseViewportUpdate()
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()
pauseViewportUpdate()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
}
if (newCanvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
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()
updateMinimap()
updateViewport()
resumeChangeDetection()
resumeViewportUpdate()
} else {
pauseChangeDetection()
pauseViewportUpdate()
}
})
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)
}
const setMinimapRef = (ref: any) => {
globalState.minimapRef.value = ref
}
return {
visible: computed(() => visible.value),
initialized: computed(() => initialized.value),
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
toggle,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel,
setMinimapRef
}
}

View File

@@ -1,14 +0,0 @@
/**
* Base domain configuration and core website URLs
* Forkers can change the base domain to use their own
*/
export const COMFY_BASE_DOMAIN =
import.meta.env.VITE_COMFY_BASE_DOMAIN || 'comfy.org'
const WEBSITE_BASE_URL = `https://www.${COMFY_BASE_DOMAIN}`
export const COMFY_WEBSITE_URLS = {
base: WEBSITE_BASE_URL,
termsOfService: `${WEBSITE_BASE_URL}/terms-of-service`,
privacy: `${WEBSITE_BASE_URL}/privacy`
}

View File

@@ -181,5 +181,12 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
},
{
combo: {
key: 'm',
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
}
]

View File

@@ -812,6 +812,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.15.12'
},
{
id: 'Comfy.Minimap.Visible',
name: 'Display minimap on canvas',
type: 'hidden',
defaultValue: true,
versionAdded: '1.24.1'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',

View File

@@ -1,95 +0,0 @@
/**
* URL constants for ComfyUI frontend
* Centralized location for all URL references
*/
import { COMFY_BASE_DOMAIN } from '@/config/comfyDomain'
const DOCS_BASE_URL = `https://docs.${COMFY_BASE_DOMAIN}`
const FORUM_BASE_URL = `https://forum.${COMFY_BASE_DOMAIN}`
const WEBSITE_BASE_URL = `https://www.${COMFY_BASE_DOMAIN}`
export const COMFY_URLS = {
website: {
base: WEBSITE_BASE_URL,
termsOfService: `${WEBSITE_BASE_URL}/terms-of-service`,
privacy: `${WEBSITE_BASE_URL}/privacy`
},
docs: {
base: DOCS_BASE_URL,
changelog: {
en: `${DOCS_BASE_URL}/changelog`,
zh: `${DOCS_BASE_URL}/zh-CN/changelog`
},
installation: {
update: `${DOCS_BASE_URL}/installation/update_comfyui`
},
tutorials: {
apiNodes: {
overview: `${DOCS_BASE_URL}/tutorials/api-nodes/overview`,
faq: `${DOCS_BASE_URL}/tutorials/api-nodes/faq`,
pricing: `${DOCS_BASE_URL}/tutorials/api-nodes/pricing`,
apiKeyLogin: `${DOCS_BASE_URL}/interface/user#logging-in-with-an-api-key`,
nonWhitelistedLogin: `${DOCS_BASE_URL}/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites`
}
},
getLocalized: (path: string, locale: string) => {
return locale === 'zh' || locale === 'zh-CN'
? `${DOCS_BASE_URL}/zh-CN/${path}`
: `${DOCS_BASE_URL}/${path}`
}
},
community: {
discord: `${WEBSITE_BASE_URL}/discord`,
forum: {
base: `${FORUM_BASE_URL}/`,
v1Feedback: `${FORUM_BASE_URL}/c/v1-feedback/`
}
}
}
export const GITHUB_REPOS = {
comfyui: 'https://github.com/comfyanonymous/ComfyUI',
comfyuiIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
frontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
electron: 'https://github.com/Comfy-Org/electron',
desktopPlatforms:
'https://github.com/Comfy-Org/desktop#currently-supported-platforms'
}
export const MODEL_SOURCES = {
repos: {
civitai: 'https://civitai.com/',
huggingface: 'https://huggingface.co/'
},
whitelistedUrls: [
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
],
allowedDomains: [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // TODO: Remove in production
]
}
export const DEVELOPER_TOOLS = {
git: 'https://git-scm.com/downloads/',
vcRedist: 'https://aka.ms/vs/17/release/vc_redist.x64.exe',
uv: 'https://docs.astral.sh/uv/getting-started/installation/'
}
// Platform and locale-aware desktop guide URL generator
export const getDesktopGuideUrl = (locale: string): string => {
const isChineseLocale = locale === 'zh'
const isMacOS =
typeof navigator !== 'undefined' &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0
const platform = isMacOS ? 'macos' : 'windows'
const baseUrl = isChineseLocale
? `https://docs.${COMFY_BASE_DOMAIN}/zh-CN/installation/desktop`
: `https://docs.${COMFY_BASE_DOMAIN}/installation/desktop`
return `${baseUrl}/${platform}`
}

View File

@@ -1,8 +1,7 @@
import log from 'loglevel'
import { PYTHON_MIRROR } from '@/constants/mirrors'
import { getDesktopGuideUrl } from '@/constants/urls'
import { i18n, t } from '@/i18n'
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useToastStore } from '@/stores/toastStore'
@@ -160,7 +159,7 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
label: 'Desktop User Guide',
icon: 'pi pi-book',
function() {
window.open(getDesktopGuideUrl(i18n.global.locale.value), '_blank')
window.open('https://comfyorg.notion.site/', '_blank')
}
},
{

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Canvas Toggle Lock"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Canvas Toggle Minimap"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Pin/Unpin Selected Items"
},

View File

@@ -862,7 +862,8 @@
"fitView": "Fit View",
"selectMode": "Select Mode",
"panMode": "Pan Mode",
"toggleLinkVisibility": "Toggle Link Visibility"
"toggleLinkVisibility": "Toggle Link Visibility",
"toggleMinimap": "Toggle Minimap"
},
"groupNode": {
"create": "Create group node",
@@ -938,6 +939,7 @@
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
"Canvas Toggle Lock": "Canvas Toggle Lock",
"Canvas Toggle Minimap": "Canvas Toggle Minimap",
"Pin/Unpin Selected Items": "Pin/Unpin Selected Items",
"Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes",
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Alternar bloqueo en lienzo"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Lienzo Alternar Minimapa"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Omitir/No omitir nodos seleccionados"
},

View File

@@ -392,6 +392,7 @@
"resetView": "Restablecer vista",
"selectMode": "Modo de selección",
"toggleLinkVisibility": "Alternar visibilidad de enlace",
"toggleMinimap": "Alternar minimapa",
"zoomIn": "Acercar",
"zoomOut": "Alejar"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
"Canvas Toggle Minimap": "Lienzo: Alternar minimapa",
"Check for Updates": "Buscar actualizaciones",
"Clear Pending Tasks": "Borrar tareas pendientes",
"Clear Workflow": "Borrar flujo de trabajo",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Basculer le verrouillage du canevas"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Basculer la mini-carte du canevas"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Contourner/Ne pas contourner les nœuds sélectionnés"
},

View File

@@ -392,6 +392,7 @@
"resetView": "Réinitialiser la vue",
"selectMode": "Mode sélection",
"toggleLinkVisibility": "Basculer la visibilité des liens",
"toggleMinimap": "Afficher/masquer la mini-carte",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
"Canvas Toggle Minimap": "Canvas : basculer la mini-carte",
"Check for Updates": "Vérifier les mises à jour",
"Clear Pending Tasks": "Effacer les tâches en attente",
"Clear Workflow": "Effacer le flux de travail",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "キャンバスのロックを切り替える"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "キャンバス ミニマップの切り替え"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "選択したノードのバイパス/バイパス解除"
},

View File

@@ -392,6 +392,7 @@
"resetView": "ビューをリセット",
"selectMode": "選択モード",
"toggleLinkVisibility": "リンクの表示切り替え",
"toggleMinimap": "ミニマップの切り替え",
"zoomIn": "拡大",
"zoomOut": "縮小"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",
"Canvas Toggle Link Visibility": "キャンバスのリンク表示を切り替え",
"Canvas Toggle Lock": "キャンバスのロックを切り替え",
"Canvas Toggle Minimap": "キャンバス ミニマップの切り替え",
"Check for Updates": "更新を確認する",
"Clear Pending Tasks": "保留中のタスクをクリア",
"Clear Workflow": "ワークフローをクリア",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "캔버스 잠금 토글"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "캔버스 미니맵 토글"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "선택한 노드 우회/우회 해제"
},

View File

@@ -392,6 +392,7 @@
"resetView": "보기 재설정",
"selectMode": "선택 모드",
"toggleLinkVisibility": "링크 가시성 전환",
"toggleMinimap": "미니맵 전환",
"zoomIn": "확대",
"zoomOut": "축소"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "선택한 노드 우회/우회 해제",
"Canvas Toggle Link Visibility": "캔버스 토글 링크 가시성",
"Canvas Toggle Lock": "캔버스 토글 잠금",
"Canvas Toggle Minimap": "캔버스 미니맵 전환",
"Check for Updates": "업데이트 확인",
"Clear Pending Tasks": "보류 중인 작업 제거하기",
"Clear Workflow": "워크플로 지우기",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Переключить блокировку холста"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Полотно: Показать/скрыть миникарту"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Обход/Необход выбранных нод"
},

View File

@@ -392,6 +392,7 @@
"resetView": "Сбросить вид",
"selectMode": "Выбрать режим",
"toggleLinkVisibility": "Переключить видимость ссылок",
"toggleMinimap": "Показать/скрыть миникарту",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",
"Canvas Toggle Link Visibility": "Переключение видимости ссылки на холст",
"Canvas Toggle Lock": "Переключение блокировки холста",
"Canvas Toggle Minimap": "Показать/скрыть миникарту холста",
"Check for Updates": "Проверить наличие обновлений",
"Clear Pending Tasks": "Очистить ожидающие задачи",
"Clear Workflow": "Очистить рабочий процесс",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "畫布切換鎖定"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "畫布切換小地圖"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "略過/取消略過選取的節點"
},

View File

@@ -392,6 +392,7 @@
"resetView": "重設視圖",
"selectMode": "選取模式",
"toggleLinkVisibility": "切換連結顯示",
"toggleMinimap": "切換小地圖",
"zoomIn": "放大",
"zoomOut": "縮小"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "繞過/取消繞過選取節點",
"Canvas Toggle Link Visibility": "切換連結可見性",
"Canvas Toggle Lock": "切換畫布鎖定",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Updates": "檢查更新",
"Clear Pending Tasks": "清除待處理任務",
"Clear Workflow": "清除工作流程",

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "畫布切換小地圖"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
},

View File

@@ -392,6 +392,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切換小地圖",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -721,6 +722,7 @@
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Toggle Lock": "切换视图锁定",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",

View File

@@ -457,6 +457,7 @@ const zSettings = z.object({
'Comfy.TutorialCompleted': z.boolean(),
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { COMFY_URLS, GITHUB_REPOS } from '@/constants/urls'
import { AboutPageBadge } from '@/types/comfy'
import { electronAPI, isElectron } from '@/utils/envUtil'
@@ -25,20 +24,20 @@ export const useAboutPanelStore = defineStore('aboutPanel', () => {
? 'v' + electronAPI().getComfyUIVersion()
: coreVersion.value
}`,
url: GITHUB_REPOS.comfyui,
url: 'https://github.com/comfyanonymous/ComfyUI',
icon: 'pi pi-github'
},
{
label: `ComfyUI_frontend v${frontendVersion}`,
url: GITHUB_REPOS.frontend,
url: 'https://github.com/Comfy-Org/ComfyUI_frontend',
icon: 'pi pi-github'
},
{
label: 'Discord',
url: COMFY_URLS.community.discord,
url: 'https://www.comfy.org/discord',
icon: 'pi pi-discord'
},
{ label: 'ComfyOrg', url: COMFY_URLS.website.base, icon: 'pi pi-globe' }
{ label: 'ComfyOrg', url: 'https://www.comfy.org/', icon: 'pi pi-globe' }
])
const allBadges = computed<AboutPageBadge[]>(() => [

View File

@@ -0,0 +1,811 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const mockPause = vi.fn()
const mockResume = vi.fn()
vi.mock('@vueuse/core', () => {
const callbacks: Record<string, () => void> = {}
let callbackId = 0
return {
useRafFn: vi.fn((callback, options) => {
const id = callbackId++
callbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
return {
pause: mockPause,
resume: vi.fn(() => {
mockResume()
void Promise.resolve().then(() => callbacks[id]?.())
})
}
}),
useThrottleFn: vi.fn((callback) => {
return (...args: any[]) => {
return callback(...args)
}
})
}
})
let mockCanvas: any
let mockGraph: any
const setupMocks = () => {
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
}
setupMocks()
const defaultCanvasStore = {
canvas: mockCanvas
}
const defaultSettingStore = {
get: vi.fn().mockReturnValue(true),
set: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => defaultCanvasStore)
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
let mockCanvas: any
let mockGraph: any
let mockCanvasElement: any
let mockContainerElement: any
let mockContext2D: any
const createAndInitializeMinimap = async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await nextTick()
await flushPromises()
return minimap
}
beforeEach(() => {
vi.clearAllMocks()
mockPause.mockClear()
mockResume.mockClear()
mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 0
}
mockCanvasElement = {
getContext: vi.fn().mockReturnValue(mockContext2D),
width: 250,
height: 200,
clientWidth: 250,
clientHeight: 200
}
const mockRect = {
left: 100,
top: 100,
width: 250,
height: 200,
right: 350,
bottom: 300,
x: 100,
y: 100
}
mockContainerElement = {
getBoundingClientRect: vi.fn(() => ({ ...mockRect }))
}
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
defaultCanvasStore.canvas = mockCanvas
defaultSettingStore.get = vi.fn().mockReturnValue(true)
defaultSettingStore.set = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(window, 'devicePixelRatio', {
writable: true,
configurable: true,
value: 1
})
window.addEventListener = vi.fn()
window.removeEventListener = vi.fn()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
expect(minimap.width).toBe(250)
expect(minimap.height).toBe(200)
expect(minimap.visible.value).toBe(true)
expect(minimap.initialized.value).toBe(false)
defaultCanvasStore.canvas = originalCanvas
})
it('should initialize minimap when canvas is available', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.initialized.value).toBe(true)
expect(defaultSettingStore.get).toHaveBeenCalledWith(
'Comfy.Minimap.Visible'
)
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
if (minimap.visible.value) {
expect(mockResume).toHaveBeenCalled()
}
})
it('should not initialize without canvas and graph', async () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(false)
expect(api.addEventListener).not.toHaveBeenCalled()
defaultCanvasStore.canvas = originalCanvas
})
it('should setup event listeners on graph', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(mockGraph.onNodeAdded).toBeDefined()
expect(mockGraph.onNodeRemoved).toBeDefined()
expect(mockGraph.onConnectionChange).toBeDefined()
})
it('should handle visibility from settings', async () => {
defaultSettingStore.get.mockReturnValue(false)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.visible.value).toBe(false)
expect(mockResume).not.toHaveBeenCalled()
})
})
describe('destroy', () => {
it('should cleanup all resources', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
minimap.destroy()
expect(mockPause).toHaveBeenCalled()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
expect(window.removeEventListener).toHaveBeenCalled()
expect(minimap.initialized.value).toBe(false)
})
it('should restore original graph callbacks', async () => {
const originalCallbacks = {
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
}
mockGraph.onNodeAdded = originalCallbacks.onNodeAdded
mockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved
mockGraph.onConnectionChange = originalCallbacks.onConnectionChange
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
minimap.destroy()
expect(mockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
originalCallbacks.onConnectionChange
)
})
})
describe('toggle', () => {
it('should toggle visibility and save to settings', async () => {
const minimap = useMinimap()
const initialVisibility = minimap.visible.value
await minimap.toggle()
expect(minimap.visible.value).toBe(!initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
!initialVisibility
)
await minimap.toggle()
expect(minimap.visible.value).toBe(initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
initialVisibility
)
})
})
describe('rendering', () => {
it('should verify context is obtained during render', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext')
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
})
it('should render at least once after initialization', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
const renderingOccurred =
mockContext2D.clearRect.mock.calls.length > 0 ||
mockContext2D.fillRect.mock.calls.length > 0
if (!renderingOccurred) {
console.log('Minimap visible:', minimap.visible.value)
console.log('Minimap initialized:', minimap.initialized.value)
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
}
expect(renderingOccurred).toBe(true)
})
it('should not render when context is null', async () => {
mockCanvasElement.getContext = vi.fn().mockReturnValue(null)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(mockContext2D.clearRect).not.toHaveBeenCalled()
mockCanvasElement.getContext = vi.fn().mockReturnValue(mockContext2D)
})
it('should handle empty graph', async () => {
const originalNodes = [...mockGraph._nodes]
mockGraph._nodes = []
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
expect(mockContext2D.fillRect).not.toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
})
describe('mouse interactions', () => {
it('should handle mouse down and start dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle mouse move while dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should not move when not dragging', async () => {
const minimap = await createAndInitializeMinimap()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle mouse up to stop dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
minimap.handleMouseUp()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('wheel interactions', () => {
it('should handle wheel zoom in', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle wheel zoom out', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(0.9)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should respect zoom limits', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.ds.scale = 0.1
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockCanvas.ds.scale).toBe(0.1)
})
it('should update container rect if needed', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
})
})
describe('viewport updates', () => {
it('should update viewport transform correctly', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await nextTick()
const viewportStyles = minimap.viewportStyles.value
expect(viewportStyles).toBeDefined()
expect(viewportStyles.transform).toMatch(
/translate\(-?\d+(\.\d+)?px, -?\d+(\.\d+)?px\)/
)
expect(viewportStyles.width).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.height).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.border).toBe('2px solid #FFF')
})
it('should handle canvas dimension updates', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.canvas.clientWidth = 1200
mockCanvas.canvas.clientHeight = 900
const resizeHandler = (window.addEventListener as any).mock.calls.find(
(call: any) => call[0] === 'resize'
)?.[1]
if (resizeHandler) {
resizeHandler()
}
await nextTick()
expect(minimap.viewportStyles.value).toBeDefined()
})
})
describe('graph change handling', () => {
it('should handle node addition', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const newNode = {
id: 'node3',
pos: [300, 200],
size: [100, 100],
constructor: { color: '#666' }
}
mockGraph._nodes.push(newNode)
if (mockGraph.onNodeAdded) {
mockGraph.onNodeAdded(newNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle node removal', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const removedNode = mockGraph._nodes[0]
mockGraph._nodes.splice(0, 1)
if (mockGraph.onNodeRemoved) {
mockGraph.onNodeRemoved(removedNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle connection changes', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
if (mockGraph.onConnectionChange) {
mockGraph.onConnectionChange(mockGraph._nodes[0])
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
})
describe('container styles', () => {
it('should provide correct container styles', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#1e1e1e')
expect(styles.border).toBe('1px solid #444')
})
})
describe('edge cases', () => {
it('should handle missing node outputs', async () => {
mockGraph._nodes[0].outputs = null
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle invalid link references', async () => {
mockGraph.links.link1.target_id = 'invalid-node'
mockGraph.getNodeById.mockReturnValue(null)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle high DPI displays', async () => {
window.devicePixelRatio = 2
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.initialized.value).toBe(true)
})
it('should handle nodes without color', async () => {
mockGraph._nodes[0].color = undefined
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const renderMinimap = (minimap as any).renderMinimap
if (renderMinimap) {
renderMinimap()
}
expect(mockContext2D.fillRect).toHaveBeenCalled()
expect(mockContext2D.fillStyle).toBeDefined()
})
})
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = { value: 'test-ref' }
minimap.setMinimapRef(ref)
expect(() => minimap.setMinimapRef(ref)).not.toThrow()
})
})
})

View File

@@ -1,192 +0,0 @@
import { describe, expect, it } from 'vitest'
import { COMFY_BASE_DOMAIN, COMFY_WEBSITE_URLS } from '@/config/comfyDomain'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/mirrors'
import {
COMFY_URLS,
DEVELOPER_TOOLS,
GITHUB_REPOS,
MODEL_SOURCES,
getDesktopGuideUrl
} from '@/constants/urls'
describe('URL Constants', () => {
describe('URL Format Validation', () => {
it('should have valid HTTPS URLs throughout', () => {
const httpsPattern =
/^https:\/\/[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}(:[0-9]{1,5})?(\/.*)?$/i
// Test COMFY_URLS
expect(COMFY_URLS.website.base).toMatch(httpsPattern)
expect(COMFY_URLS.docs.base).toMatch(httpsPattern)
expect(COMFY_URLS.community.discord).toMatch(httpsPattern)
// Test GITHUB_REPOS
Object.values(GITHUB_REPOS).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
// Test MODEL_SOURCES
Object.values(MODEL_SOURCES.repos).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
// Test DEVELOPER_TOOLS
Object.values(DEVELOPER_TOOLS).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
})
it('should have proper GitHub URL format', () => {
const githubPattern = /^https:\/\/github\.com\/[\w-]+\/[\w-]+(\/[\w-]+)?$/
expect(GITHUB_REPOS.comfyui).toMatch(githubPattern)
expect(GITHUB_REPOS.comfyuiIssues).toMatch(githubPattern)
expect(GITHUB_REPOS.frontend).toMatch(githubPattern)
})
})
describe('Mirror Configuration', () => {
it('should have valid mirror URLs', () => {
const urlPattern =
/^https?:\/\/[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}(:[0-9]{1,5})?(\/.*)?$/i
expect(PYTHON_MIRROR.mirror).toMatch(urlPattern)
expect(PYTHON_MIRROR.fallbackMirror).toMatch(urlPattern)
expect(PYPI_MIRROR.mirror).toMatch(urlPattern)
expect(PYPI_MIRROR.fallbackMirror).toMatch(urlPattern)
})
})
describe('Domain Configuration', () => {
it('should have valid domain format', () => {
const domainPattern = /^[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}$/i
expect(COMFY_BASE_DOMAIN).toMatch(domainPattern)
})
it('should construct proper website URLs from base domain', () => {
const expectedBase = `https://www.${COMFY_BASE_DOMAIN}`
expect(COMFY_WEBSITE_URLS.base).toBe(expectedBase)
expect(COMFY_WEBSITE_URLS.termsOfService).toBe(
`${expectedBase}/terms-of-service`
)
expect(COMFY_WEBSITE_URLS.privacy).toBe(`${expectedBase}/privacy`)
})
})
describe('Localization', () => {
it('should handle valid language codes in getLocalized', () => {
const validLanguageCodes = ['en', 'zh', 'ja', 'ko', 'es', 'fr', 'de']
validLanguageCodes.forEach((lang) => {
const result = COMFY_URLS.docs.getLocalized('test-path', lang)
expect(result).toMatch(
/^https:\/\/docs\.comfy\.org\/(([a-z]{2}-[A-Z]{2}\/)?test-path)$/
)
})
})
it('should properly format localized paths', () => {
expect(COMFY_URLS.docs.getLocalized('test-path', 'en')).toBe(
'https://docs.comfy.org/test-path'
)
expect(COMFY_URLS.docs.getLocalized('test-path', 'zh')).toBe(
'https://docs.comfy.org/zh-CN/test-path'
)
expect(COMFY_URLS.docs.getLocalized('', 'en')).toBe(
'https://docs.comfy.org/'
)
expect(COMFY_URLS.docs.getLocalized('', 'zh')).toBe(
'https://docs.comfy.org/zh-CN/'
)
})
it('should generate platform and locale-aware desktop guide URLs', () => {
// Mock navigator for testing
const originalNavigator = global.navigator
// Test Windows platform
Object.defineProperty(global, 'navigator', {
value: { platform: 'Win32' },
writable: true
})
const winUrl = getDesktopGuideUrl('en')
expect(winUrl).toBe('https://docs.comfy.org/installation/desktop/windows')
// Test macOS platform
Object.defineProperty(global, 'navigator', {
value: { platform: 'MacIntel' },
writable: true
})
const macUrl = getDesktopGuideUrl('en')
expect(macUrl).toBe('https://docs.comfy.org/installation/desktop/macos')
// Test Chinese locale with macOS
const zhMacUrl = getDesktopGuideUrl('zh')
expect(zhMacUrl).toBe(
'https://docs.comfy.org/zh-CN/installation/desktop/macos'
)
// Test Chinese locale with Windows
Object.defineProperty(global, 'navigator', {
value: { platform: 'Win32' },
writable: true
})
const zhWinUrl = getDesktopGuideUrl('zh')
expect(zhWinUrl).toBe(
'https://docs.comfy.org/zh-CN/installation/desktop/windows'
)
// Test other locales default to English
const frUrl = getDesktopGuideUrl('fr')
expect(frUrl).toBe('https://docs.comfy.org/installation/desktop/windows')
// Test environment without navigator
Object.defineProperty(global, 'navigator', {
value: undefined,
writable: true
})
const noNavUrl = getDesktopGuideUrl('en')
expect(noNavUrl).toBe(
'https://docs.comfy.org/installation/desktop/windows'
)
// Restore original navigator
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
writable: true
})
})
})
describe('Security', () => {
it('should only use secure HTTPS for external URLs', () => {
const allUrls = [
...Object.values(GITHUB_REPOS),
...Object.values(MODEL_SOURCES.repos),
...Object.values(DEVELOPER_TOOLS),
COMFY_URLS.website.base,
COMFY_URLS.docs.base,
COMFY_URLS.community.discord
]
allUrls.forEach((url) => {
expect(url.startsWith('https://')).toBe(true)
expect(url.startsWith('http://')).toBe(false)
})
})
it('should have valid URL allowlist for model sources', () => {
const urlPattern =
/^https?:\/\/([a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}|localhost)(:[0-9]+)?\/?.*$/i
MODEL_SOURCES.allowedDomains.forEach((url) => {
expect(url).toMatch(urlPattern)
})
})
})
})