Compare commits

..

10 Commits

Author SHA1 Message Date
Christian Byrne
5a35562d3d [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
2025-08-17 21:24:08 -07:00
Jin Yi
ceac8f3741 Modal Standardization (#4784) 2025-08-18 09:41:15 +09:00
Christian Byrne
b1057f164b [fix] Resolve group node execution error when connecting to external nodes (#5054)
* [fix] resolve group node execution error when connecting to external nodes

Fixed ExecutableGroupNodeChildDTO.resolveInput to properly handle connections from group node children to external nodes. The method now tries to find nodes by their full ID first (for external nodes) before falling back to the shortened ID (for internal group nodes).

Added comprehensive unit tests to prevent regression.

* [feat] Add error check for unsupported group nodes inside subgraphs

Added validation to detect when group node children are executing within subgraph contexts (execution ID has >2 segments) and provide clear error message directing users to convert to subgraphs instead.

Includes comprehensive test coverage for the new validation.
2025-08-17 16:39:06 -07:00
pythongosssss
4a189bdc93 Minor updates to subgraph breadcrumb item (#5060)
- change active item text to primary color
- change rename action to behave the same as double clicking label
2025-08-17 11:18:36 -07:00
Christian Byrne
f0adb4c9d3 [bugfix] Allow removeInput/removeOutput on nodes without graph reference (#5053)
- Modified removeInput/removeOutput to skip disconnect operations when node has no graph
- Both methods now safely handle nodes that aren't part of a graph
- Added comprehensive tests for the new behavior
- Fixes #5037
2025-08-17 11:14:53 -07:00
Christian Byrne
d5d0aa52c2 [refactor] Refactor minimap initialization logic (#5052)
* move ref initialization to the component

* remove redundant init
2025-08-17 10:52:25 -07:00
ComfyUI Wiki
69c660b3b7 handle minimap cleanup called before map set (#5038)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-08-17 09:46:59 -07:00
pythongosssss
88579c2a40 Update menu items with a active toggle state to not close menu when clicked (#5050) 2025-08-17 09:01:41 -07:00
Christian Byrne
7ab247aa1d Improve release command flow and GTM criteria (#5040)
- Reorganize steps to complete all analysis before execution
- Move Breaking Change Analysis to Step 3 (was Step 6)
- Move Dependency Analysis to Step 4 (was Step 7)
- Move GTM Feature Summary to Step 5 (was Step 16)
- Add stricter GTM criteria to avoid minor features
- Simplify PR data extraction to prevent timeouts
- Enhance Version Preview to suggest version based on analysis

These changes ensure critical analysis steps aren't skipped during
execution and provide clearer criteria for marketing-worthy features.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-16 12:25:28 -07:00
Alexander Piskun
c78d03dd2c api_nodes: added prices for Vidu Video nodes (#5035) 2025-08-16 07:45:15 -07:00
72 changed files with 4190 additions and 1186 deletions

View File

@@ -111,50 +111,7 @@ echo "Last stable release: $LAST_STABLE"
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
### Step 3: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
@@ -169,7 +126,7 @@ echo "Last stable release: $LAST_STABLE"
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Analyze Dependency Updates
### Step 4: Analyze Dependency Updates
1. **Check significant dependency updates:**
```bash
@@ -195,7 +152,117 @@ echo "Last stable release: $LAST_STABLE"
done
```
### Step 8: Generate Comprehensive Release Notes
### Step 5: Generate GTM Feature Summary
1. **Collect PR data for analysis:**
```bash
# Get list of PR numbers from commits
PR_NUMBERS=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | \
grep -oE "#[0-9]+" | tr -d '#' | sort -u)
# Save PR data for each PR
echo "[" > prs-${NEW_VERSION}.json
first=true
for PR in $PR_NUMBERS; do
[[ "$first" == true ]] && first=false || echo "," >> prs-${NEW_VERSION}.json
gh pr view $PR --json number,title,author,body,labels 2>/dev/null >> prs-${NEW_VERSION}.json || echo "{}" >> prs-${NEW_VERSION}.json
done
echo "]" >> prs-${NEW_VERSION}.json
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify features worthy of marketing attention.
A feature is GTM-worthy if it meets ALL of these criteria:
- Introduces a NEW capability users didn't have before (not just improvements)
- Would be a compelling reason for users to upgrade to this version
- Can be demonstrated visually or has clear before/after comparison
- Affects a significant portion of the user base
NOT GTM-worthy:
- Bug fixes (even important ones)
- Minor UI tweaks or color changes
- Performance improvements without user-visible impact
- Internal refactoring
- Small convenience features
- Features that only improve existing functionality marginally
For each GTM-worthy feature, note:
- PR number, title, and author
- Media links from the PR description
- One compelling sentence on why users should care
If there are no GTM-worthy features, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification:**
```bash
# Save to gtm-summary-${NEW_VERSION}.md based on analysis
# If GTM-worthy features exist, include them with testing instructions
# If not, note that this is a maintenance/bug fix release
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
### Step 6: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number based on analysis:
- Major version if breaking changes detected
- Minor version if new features added
- Patch version if only bug fixes
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 7: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 8: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 9: Generate Comprehensive Release Notes
1. Extract commit messages since base release:
```bash
@@ -257,7 +324,7 @@ echo "Last stable release: $LAST_STABLE"
- Ensure consistent bullet format: `- Description (#PR_NUMBER)`
5. **CONTENT REVIEW**: Release notes follow standard format?
### Step 9: Create Version Bump PR
### Step 10: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
@@ -303,7 +370,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
4. **PR REVIEW**: Version bump PR created with standardized release notes?
### Step 10: Critical Release PR Verification
### Step 11: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
@@ -325,7 +392,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 11: Pre-Merge Validation
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
@@ -333,7 +400,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 12: Execute Release
### Step 13: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
@@ -366,7 +433,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 13: Enhance GitHub Release
### Step 14: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
@@ -394,7 +461,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh release view v${NEW_VERSION}
```
### Step 14: Verify Multi-Channel Distribution
### Step 15: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
@@ -432,7 +499,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 15: Post-Release Monitoring Setup
### Step 16: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
@@ -508,87 +575,42 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **RELEASE COMPLETION**: All post-release setup completed?
### Step 16: Generate GTM Feature Summary
### Step 17: Create Release Summary
1. **Extract and analyze PR data:**
1. **Create comprehensive release summary:**
```bash
echo "📊 Checking for marketing-worthy features..."
# Extract all PR data inline
PR_DATA=$(
PR_LIST=$(git log ${BASE_TAG}..HEAD --grep="Merge pull request" --pretty=format:"%s" | grep -oE "#[0-9]+" | tr -d '#' | sort -u)
echo "["
first=true
for PR in $PR_LIST; do
[[ "$first" == true ]] && first=false || echo ","
gh pr view $PR --json number,title,author,body,files,labels,closedAt 2>/dev/null || continue
done
echo "]"
)
# Save for analysis
echo "$PR_DATA" > prs-${NEW_VERSION}.json
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify if ANY would interest a marketing/growth team.
Consider if a PR:
- Changes something users directly interact with or experience
- Makes something noticeably better, faster, or easier
- Introduces capabilities users have been asking for
- Has visual assets (screenshots, GIFs, videos) that could be shared
- Tells a compelling story about improvement or innovation
- Would make users excited if they heard about it
Many releases contain only technical improvements, bug fixes, or internal changes -
that's perfectly normal. Only flag PRs that would genuinely interest end users.
If you find marketing-worthy PRs, note:
- PR number, title, and author
- Any media links from the description
- One sentence on why it's worth showcasing
If nothing is marketing-worthy, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification (only if needed):**
```
If there are marketing-worthy features, create a message for #gtm with:
🚀 Frontend Release v${NEW_VERSION}
Timeline: Available now in nightly, ~2-3 weeks for core
Features worth showcasing:
[List the selected PRs with media links and authors]
Testing: --front-end-version ${NEW_VERSION}
If there are NO marketing-worthy features, generate:
"No marketing-worthy features in v${NEW_VERSION} - mostly internal improvements and bug fixes."
```
4. **Save the output:**
```bash
# Claude generates the GTM summary and saves it
# Save to gtm-summary-${NEW_VERSION}.md
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
2. **RELEASE COMPLETION**: All steps completed successfully?
## Advanced Safety Features

View File

@@ -73,6 +73,77 @@ test.describe('Menu', () => {
expect(isTextCutoff).toBe(false)
})
test('Clicking on active state items does not close menu', async ({
comfyPage
}) => {
// Open the menu
await comfyPage.menu.topbar.openTopbarMenu()
const menu = comfyPage.page.locator('.comfy-command-menu')
// Navigate to View menu
const viewMenuItem = comfyPage.page.locator(
'.p-menubar-item-label:text-is("View")'
)
await viewMenuItem.hover()
// Wait for submenu to appear
const viewSubmenu = comfyPage.page
.locator('.p-tieredmenu-submenu:visible')
.first()
await viewSubmenu.waitFor({ state: 'visible' })
// Find Bottom Panel menu item
const bottomPanelMenuItem = viewSubmenu
.locator('.p-tieredmenu-item:has-text("Bottom Panel")')
.first()
const bottomPanelItem = bottomPanelMenuItem.locator(
'.p-menubar-item-label:text-is("Bottom Panel")'
)
await bottomPanelItem.waitFor({ state: 'visible' })
// Get checkmark icon element
const checkmark = bottomPanelMenuItem.locator('.pi-check')
// Check initial state of bottom panel (it's initially hidden)
const bottomPanel = comfyPage.page.locator('.bottom-panel')
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click Bottom Panel to toggle it on
await bottomPanelItem.click()
// Verify menu is still visible after clicking
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is now visible
await expect(bottomPanel).toBeVisible()
// Checkmark should now be visible (panel is shown)
await expect(checkmark).not.toHaveClass(/invisible/)
// Click Bottom Panel again to toggle it off
await bottomPanelItem.click()
// Verify menu is still visible after second click
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')

View File

@@ -10,7 +10,8 @@
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
'p-breadcrumb-item-link-icon-visible': isActive,
'active-breadcrumb-item': isActive
}"
@click="handleClick"
>
@@ -111,21 +112,7 @@ const menuItems = computed<MenuItem[]>(() => {
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
@@ -175,20 +162,24 @@ const handleClick = (event: MouseEvent) => {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
startRename()
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -212,4 +203,8 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<button
class="flex justify-center items-center outline-none border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white w-8 h-8 rounded-lg cursor-pointer"
role="button"
@click="onClick"
>
<slot></slot>
</button>
</template>
<script setup lang="ts">
const { onClick } = defineProps<{
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<BaseWidgetLayout>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i-lucide:puzzle class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ t('g.title') }}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<!-- here -->
</template>
<template #content>
<!-- here -->
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
</template>
</BaseWidgetLayout>
</template>
<script setup lang="ts">
import { provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
import BaseWidgetLayout from './layout/BaseWidgetLayout.vue'
import LeftSidePanel from './panel/LeftSidePanel.vue'
import RightSidePanel from './panel/RightSidePanel.vue'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])
const selectedNavItem = ref<string | null>('installed')
</script>

View File

@@ -0,0 +1,176 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-100 dark-theme:bg-zinc-800"
>
<IconButton
v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
@click="toggleRightPanel"
>
<i-lucide:panel-right class="text-sm" />
</IconButton>
<IconButton
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<i class="pi pi-times text-sm"></i>
</IconButton>
<div class="flex w-full h-full">
<Transition name="slide-panel">
<nav
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
<div class="w-full h-full flex flex-col">
<header
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<div class="flex-1 flex gap-2 flex-shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
<i-lucide:panel-left-close v-else class="text-sm" />
</IconButton>
<slot name="header"></slot>
</div>
<div class="flex justify-end gap-2 min-w-20">
<slot name="header-right-area"></slot>
<IconButton
v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel"
>
<i-lucide:panel-right-close class="text-sm" />
</IconButton>
</div>
</header>
<main class="flex-1">
<slot name="content"></slot>
</main>
</div>
<Transition name="slide-panel-right">
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
>
<slot name="rightPanel"></slot>
</aside>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/custom/button/IconButton.vue'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
const BREAKPOINTS = { sm: 480 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('sm')
const isLeftPanelOpen = ref<boolean>(true)
const isRightPanelOpen = ref<boolean>(false)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
}
})
const showLeftPanel = computed(() => {
const shouldShow = notMobile.value
? isLeftPanelOpen.value
: mobileMenuOpen.value
return shouldShow
})
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
} else {
mobileMenuOpen.value = !mobileMenuOpen.value
}
}
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
/* Slide transition for right panel */
.slide-panel-right-enter-active,
.slide-panel-right-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-right-enter-from,
.slide-panel-right-leave-to {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
:class="
active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
: 'text-neutral hover:bg-zinc-100 hover:dark-theme:bg-zinc-700/50'
"
role="button"
@click="onClick"
>
<i-lucide:folder class="text-xs text-neutral" />
<span>
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
const { active, onClick } = defineProps<{
active?: boolean
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-sm font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>
</template>
<script setup lang="ts">
const { title } = defineProps<{
title: string
}>()
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav class="flex-1 px-3 py-4 flex flex-col gap-2">
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle :title="item.title" />
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import NavItem from '../nav/NavItem.vue'
import NavTitle from '../nav/NavTitle.vue'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const getFirstItemId = () => {
if (!navItems || navItems.length === 0) {
return null
}
const firstEntry = navItems[0]
if ('items' in firstEntry && firstEntry.items.length > 0) {
return firstEntry.items[0].id
}
if ('id' in firstEntry) {
return firstEntry.id
}
return null
}
const activeItem = computed({
get: () => modelValue ?? getFirstItemId(),
set: (value: string | null) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,12 @@
<template>
<header class="flex items-center justify-between h-16 px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i-lucide:puzzle class="text-neutral text-base" />
</slot>
<h2 class="font-bold text-base text-neutral">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
<slot></slot>
</div>
</template>

View File

@@ -10,14 +10,16 @@
:aria-labelledby="item.key"
>
<template #header>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component

View File

@@ -21,7 +21,6 @@
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
@@ -71,7 +70,6 @@ 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'
@@ -119,9 +117,7 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -358,13 +354,6 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -1,6 +1,7 @@
<template>
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<MiniMapPanel
@@ -54,15 +55,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MiniMapPanel from './MiniMapPanel.vue'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const minimapRef = ref<HTMLDivElement>()
const {
initialized,
@@ -80,13 +79,13 @@ const {
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel
} = minimap
handleWheel,
setMinimapRef
} = useMinimap()
const showOptionsPanel = ref(false)
@@ -94,19 +93,9 @@ const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
onMounted(() => {
if (minimapRef.value) {
setMinimapRef(minimapRef.value)
}
})

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

@@ -59,7 +59,7 @@
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="isZoomCommand(item) ? handleZoomClick($event) : undefined"
@click="handleItemClick(item, $event)"
>
<i
v-if="hasActiveStateSiblings(item)"
@@ -285,11 +285,19 @@ const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
}
}
const handleZoomClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// Prevent the menu from closing for zoom commands
return false
const handleItemClick = (item: MenuItem, event: MouseEvent) => {
// Prevent the menu from closing for zoom commands or commands that have active state
if (isZoomCommand(item) || item.comfyCommand?.active) {
event.preventDefault()
event.stopPropagation()
if (item.comfyCommand?.active) {
item.command?.({
item,
originalEvent: event
})
}
return false
}
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {

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

@@ -1371,6 +1371,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
return 'Token-based'
}
},
ViduTextToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduImageToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduReferenceVideoNode: {
displayPrice: '$0.4/Run'
},
ViduStartEndToVideoNode: {
displayPrice: '$0.4/Run'
}
}

View File

@@ -1,5 +1,6 @@
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -878,6 +879,17 @@ export function useCoreCommands(): ComfyCommand[] {
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
},
{
id: 'Comfy.Dev.ShowModelSelector',
icon: 'pi pi-box',
label: 'Show Model Selector (Dev)',
versionAdded: '1.26.2',
category: 'view-controls' as const,
function: () => {
const modelSelectorDialog = useModelSelectorDialog()
modelSelectorDialog.show()
}
}
]

View File

@@ -1,848 +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) {
throw new Error(
'Attempted to cleanup event listeners for graph that was never set up'
)
}
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,29 @@
import ModelSelector from '@/components/custom/widget/ModelSelector.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-model-selector'
export const useModelSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ModelSelector,
props: {
onClose: hide
}
})
}
return {
show,
hide
}
}

View File

@@ -1574,7 +1574,10 @@ export class LGraphNode
* remove an existing output slot
*/
removeOutput(slot: number): void {
this.disconnectOutput(slot)
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectOutput(slot)
}
const { outputs } = this
outputs.splice(slot, 1)
@@ -1582,11 +1585,12 @@ export class LGraphNode
const output = outputs[i]
if (!output || !output.links) continue
for (const linkId of output.links) {
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
// Only update link indices if node is part of a graph
if (this.graph) {
for (const linkId of output.links) {
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
}
}
}
@@ -1626,7 +1630,10 @@ export class LGraphNode
* remove an existing input slot
*/
removeInput(slot: number): void {
this.disconnectInput(slot, true)
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectInput(slot, true)
}
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
@@ -1634,9 +1641,11 @@ export class LGraphNode
const input = inputs[i]
if (!input?.link) continue
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
// Only update link indices if node is part of a graph
if (this.graph) {
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)

View File

@@ -656,4 +656,119 @@ describe('LGraphNode', () => {
spy.mockRestore()
})
})
describe('removeInput/removeOutput on copied nodes', () => {
beforeEach(() => {
// Register a test node type so clone() can work
LiteGraph.registerNodeType('TestNode', LGraphNode)
})
test('should NOT throw error when calling removeInput on a copied node without graph', () => {
// Create a node with an input
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove inputs on nodes without graph
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should NOT throw error when calling removeOutput on a copied node without graph', () => {
// Create a node with an output
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove outputs on nodes without graph
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('should skip disconnectInput/disconnectOutput when node has no graph', () => {
// Create nodes with input/output
const nodeWithInput = new LGraphNode('Test Node')
nodeWithInput.type = 'TestNode'
nodeWithInput.addInput('input1', 'number')
const nodeWithOutput = new LGraphNode('Test Node')
nodeWithOutput.type = 'TestNode'
nodeWithOutput.addOutput('output1', 'number')
// Clone nodes (no graph reference)
const clonedInput = nodeWithInput.clone()
const clonedOutput = nodeWithOutput.clone()
// Mock disconnect methods to verify they're not called
clonedInput!.disconnectInput = vi.fn()
clonedOutput!.disconnectOutput = vi.fn()
// Remove input/output - disconnect methods should NOT be called
clonedInput!.removeInput(0)
clonedOutput!.removeOutput(0)
expect(clonedInput!.disconnectInput).not.toHaveBeenCalled()
expect(clonedOutput!.disconnectOutput).not.toHaveBeenCalled()
})
test('should be able to removeInput on a copied node after adding to graph', () => {
// Create a graph and a node with an input
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should be able to removeOutput on a copied node after adding to graph', () => {
// Create a graph and a node with an output
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('RerouteNode clone scenario - should be able to removeOutput and addOutput on cloned node', () => {
// This simulates the RerouteNode clone method behavior
const originalNode = new LGraphNode('Reroute')
originalNode.type = 'TestNode'
originalNode.addOutput('*', '*')
// Clone the node (simulating RerouteNode.clone)
const clonedNode = originalNode.clone()
expect(clonedNode).not.toBeNull()
// This should not throw - we should be able to modify outputs on a cloned node
expect(() => {
clonedNode!.removeOutput(0)
clonedNode!.addOutput('renamed', '*')
}).not.toThrow()
expect(clonedNode!.outputs).toHaveLength(1)
expect(clonedNode!.outputs[0].name).toBe('renamed')
})
})
})

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "الاتصال بالدعم"
},
"Comfy_Dev_ShowModelSelector": {
"label": "إظهار منتقي النماذج (للمطورين)"
},
"Comfy_DuplicateWorkflow": {
"label": "تكرار سير العمل الحالي"
},

View File

@@ -840,6 +840,7 @@
"Save": "حفظ",
"Save As": "حفظ باسم",
"Show Keybindings Dialog": "عرض مربع حوار اختصارات لوحة المفاتيح",
"Show Model Selector (Dev)": "إظهار منتقي النماذج (للمطورين)",
"Show Settings Dialog": "عرض نافذة الإعدادات",
"Sign Out": "تسجيل خروج",
"Toggle Essential Bottom Panel": "تبديل اللوحة السفلية الأساسية",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contact Support"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Show Model Selector (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Duplicate Current Workflow"
},

View File

@@ -980,6 +980,7 @@
"Clear Pending Tasks": "Clear Pending Tasks",
"Clear Workflow": "Clear Workflow",
"Contact Support": "Contact Support",
"Show Model Selector (Dev)": "Show Model Selector (Dev)",
"Duplicate Current Workflow": "Duplicate Current Workflow",
"Export": "Export",
"Export (API)": "Export (API)",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contactar soporte"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Mostrar selector de modelo (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Duplicar flujo de trabajo actual"
},

View File

@@ -840,6 +840,7 @@
"Save": "Guardar",
"Save As": "Guardar como",
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
"Show Model Selector (Dev)": "Mostrar selector de modelo (Desarrollo)",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contacter le support"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Afficher le sélecteur de modèle (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Dupliquer le flux de travail actuel"
},

View File

@@ -840,6 +840,7 @@
"Save": "Enregistrer",
"Save As": "Enregistrer sous",
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
"Show Model Selector (Dev)": "Afficher le sélecteur de modèle (Dev)",
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Essential Bottom Panel": "Afficher/Masquer le panneau inférieur essentiel",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "サポートに連絡"
},
"Comfy_Dev_ShowModelSelector": {
"label": "モデルセレクターを表示(開発用)"
},
"Comfy_DuplicateWorkflow": {
"label": "現在のワークフローを複製"
},

View File

@@ -840,6 +840,7 @@
"Save": "保存",
"Save As": "名前を付けて保存",
"Show Keybindings Dialog": "キーバインドダイアログを表示",
"Show Model Selector (Dev)": "モデルセレクターを表示(開発用)",
"Show Settings Dialog": "設定ダイアログを表示",
"Sign Out": "サインアウト",
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "지원팀에 문의하기"
},
"Comfy_Dev_ShowModelSelector": {
"label": "모델 선택기 표시 (개발자용)"
},
"Comfy_DuplicateWorkflow": {
"label": "현재 워크플로 복제"
},

View File

@@ -839,7 +839,8 @@
"Restart": "재시작",
"Save": "저장",
"Save As": "다른 이름으로 저장",
"Show Keybindings Dialog": "키 바인딩 대화상자 표시",
"Show Keybindings Dialog": "단축키 대화상자 표시",
"Show Model Selector (Dev)": "모델 선택기 표시 (개발자용)",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
"Toggle Essential Bottom Panel": "필수 하단 패널 전환",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Связаться с поддержкой"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Показать выбор модели (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Дублировать текущий рабочий процесс"
},

View File

@@ -840,6 +840,7 @@
"Save": "Сохранить",
"Save As": "Сохранить как",
"Show Keybindings Dialog": "Показать диалог клавиш быстрого доступа",
"Show Model Selector (Dev)": "Показать выбор модели (Dev)",
"Show Settings Dialog": "Показать диалог настроек",
"Sign Out": "Выйти",
"Toggle Essential Bottom Panel": "Показать/скрыть основную нижнюю панель",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "聯絡支援"
},
"Comfy_Dev_ShowModelSelector": {
"label": "顯示模型選擇器(開發)"
},
"Comfy_DuplicateWorkflow": {
"label": "複製目前工作流程"
},

View File

@@ -840,6 +840,7 @@
"Save": "儲存",
"Save As": "另存新檔",
"Show Keybindings Dialog": "顯示快捷鍵對話框",
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
"Show Settings Dialog": "顯示設定對話框",
"Sign Out": "登出",
"Toggle Essential Bottom Panel": "切換基本下方面板",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "联系支持"
},
"Comfy_Dev_ShowModelSelector": {
"label": "顯示模型選擇器(開發)"
},
"Comfy_DuplicateWorkflow": {
"label": "复制当前工作流"
},

View File

@@ -839,7 +839,8 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "示快捷键对话框",
"Show Keybindings Dialog": "示快捷鍵對話框",
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Essential Bottom Panel": "切换基础底部面板",

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,3 +1,6 @@
import { merge } from 'es-toolkit/compat'
import { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
@@ -20,7 +23,11 @@ import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsCo
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import {
type DialogComponentProps,
type ShowDialogOptions,
useDialogStore
} from '@/stores/dialogStore'
export type ConfirmationDialogType =
| 'default'
@@ -424,6 +431,33 @@ export const useDialogService = () => {
}
}
function showLayoutDialog(options: {
key: string
component: Component
props: { onClose: () => void }
dialogComponentProps?: DialogComponentProps
}) {
const layoutDefaultProps: DialogComponentProps = {
headless: true,
unstyled: true,
modal: true,
closable: false,
pt: {
mask: {
class: 'bg-black bg-opacity-40'
}
}
}
return dialogStore.showDialog({
...options,
dialogComponentProps: merge(
layoutDefaultProps,
options.dialogComponentProps || {}
)
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -443,6 +477,7 @@ export const useDialogService = () => {
prompt,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog
toggleManagerProgressDialog,
showLayoutDialog
}
}

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

@@ -28,9 +28,11 @@ interface CustomDialogComponentProps {
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
unstyled?: boolean
headless?: boolean
}
type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
CustomDialogComponentProps
interface DialogInstance {

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'

View File

@@ -0,0 +1,2 @@
export * from './navTypes'
export * from './widgetTypes'

View File

@@ -0,0 +1,9 @@
export interface NavItemData {
id: string
label: string
}
export interface NavGroupData {
title: string
items: NavItemData[]
}

View File

@@ -0,0 +1,3 @@
import { InjectionKey } from 'vue'
export const OnCloseKey: InjectionKey<() => void> = Symbol()

View File

@@ -27,25 +27,41 @@ export class ExecutableGroupNodeChildDTO extends ExecutableNodeDTO {
}
override resolveInput(slot: number) {
// Check if this group node is inside a subgraph (unsupported)
if (this.id.split(':').length > 2) {
throw new Error(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
}
const inputNode = this.node.getInputNode(slot)
if (!inputNode) return
const link = this.node.getInputLink(slot)
if (!link) throw new Error('Failed to get input link')
const id = String(inputNode.id).split(':').at(-1)
if (id === undefined) throw new Error('Invalid input node id')
const inputNodeId = String(inputNode.id)
// Try to find the node using the full ID first (for nodes outside the group)
let inputNodeDto = this.nodesByExecutionId?.get(inputNodeId)
// If not found, try with just the last part of the ID (for nodes inside the group)
if (!inputNodeDto) {
const id = inputNodeId.split(':').at(-1)
if (id !== undefined) {
inputNodeDto = this.nodesByExecutionId?.get(id)
}
}
const inputNodeDto = this.nodesByExecutionId?.get(id)
if (!inputNodeDto) {
throw new Error(
`Failed to get input node ${id} for group node child ${this.id} with slot ${slot}`
`Failed to get input node ${inputNodeId} for group node child ${this.id} with slot ${slot}`
)
}
return {
node: inputNodeDto,
origin_id: String(inputNode.id),
origin_id: inputNodeId,
origin_slot: link.origin_slot
}
}

View File

@@ -79,14 +79,14 @@ export default {
colors: {
zinc: {
50: '#fafafa',
100: '#f4f4f5',
100: '#8282821a',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
700: '#38393b',
800: '#262729',
900: '#18181b',
950: '#09090b'
},

View File

@@ -3,28 +3,39 @@ import { nextTick } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const triggerRAF = async () => {
// Trigger all RAF callbacks
Object.values(rafCallbacks).forEach((cb) => cb?.())
await flushPromises()
}
const mockPause = vi.fn()
const mockResume = vi.fn()
vi.mock('@vueuse/core', () => {
const callbacks: Record<string, () => void> = {}
let callbackId = 0
const rafCallbacks: Record<string, () => void> = {}
let rafCallbackId = 0
vi.mock('@vueuse/core', () => {
return {
useRafFn: vi.fn((callback, options) => {
const id = callbackId++
callbacks[id] = callback
const id = rafCallbackId++
rafCallbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
const resumeFn = vi.fn(() => {
mockResume()
// Execute the RAF callback immediately when resumed
if (rafCallbacks[id]) {
rafCallbacks[id]()
}
})
return {
pause: mockPause,
resume: vi.fn(() => {
mockResume()
void Promise.resolve().then(() => callbacks[id]?.())
})
resume: resumeFn
}
}),
useThrottleFn: vi.fn((callback) => {
@@ -142,7 +153,9 @@ vi.mock('@/stores/workflowStore', () => ({
}))
}))
const { useMinimap } = await import('@/composables/useMinimap')
const { useMinimap } = await import(
'@/renderer/extensions/minimap/composables/useMinimap'
)
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
@@ -425,7 +438,19 @@ describe('useMinimap', () => {
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
// Force initial render
minimap.renderMinimap()
// Force a render by triggering a graph change
mockGraph._nodes.push({
id: 'new-node',
pos: [150, 150],
size: [100, 50]
})
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
@@ -438,7 +463,15 @@ describe('useMinimap', () => {
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
// Force initial render
minimap.renderMinimap()
// Force a render by modifying a node position
mockGraph._nodes[0].pos = [50, 50]
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
const renderingOccurred =
mockContext2D.clearRect.mock.calls.length > 0 ||
@@ -449,6 +482,15 @@ describe('useMinimap', () => {
console.log('Minimap initialized:', minimap.initialized.value)
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
console.log(
'clearRect calls:',
mockContext2D.clearRect.mock.calls.length
)
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
console.log(
'getContext calls:',
mockCanvasElement.getContext.mock.calls.length
)
}
expect(renderingOccurred).toBe(true)
@@ -478,6 +520,10 @@ describe('useMinimap', () => {
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
// The renderer has a fast path for empty graphs, force it to execute
minimap.renderMinimap()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
@@ -917,7 +963,7 @@ describe('useMinimap', () => {
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = { value: 'test-ref' }
const ref = document.createElement('div')
minimap.setMinimapRef(ref)

View File

@@ -1,38 +0,0 @@
/**
* Test for issue #5033: Type compatibility between comfyui-frontend-types and @comfyorg/litegraph
*
* This test verifies that the generated types from this package can be used alongside
* external litegraph types without causing "#private field" conflicts.
*/
import { describe, expect, it } from 'vitest'
import type { LGraph, LLink } from '@/lib/litegraph/src/litegraph'
import type { ComfyApp } from '@/scripts/app'
describe('Issue #5033: Type compatibility', () => {
it('should allow ComfyApp.graph to be assigned to LGraph type', () => {
// This test verifies that the types are compatible after removing #private fields
// from the generated .d.ts files
function getGraph(app: ComfyApp): LGraph {
// This should not cause TypeScript errors about #private field conflicts
return app.graph
}
// Type test - if this compiles, the issue is fixed
expect(typeof getGraph).toBe('function')
})
it('should allow graph.links to be compatible with LLink type', () => {
type LGraphFromApp = ComfyApp['graph']
function getLinks(app: ComfyApp): LLink | null {
const graph: LGraphFromApp = app.graph
// This should not cause TypeScript errors about #private field conflicts
return graph.links.get(0) ?? null
}
// Type test - if this compiles, the issue is fixed
expect(typeof getLinks).toBe('function')
})
})

View File

@@ -0,0 +1,299 @@
import { useThrottleFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
import { api } from '@/scripts/api'
vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe('useMinimapGraph', () => {
let mockGraph: LGraph
let onGraphChangedMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
id: 'test-graph-123',
_nodes: [
{ id: '1', pos: [100, 100], size: [150, 80] },
{ id: '2', pos: [300, 200], size: [120, 60] }
],
links: { link1: { id: 'link1' } },
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
} as any
onGraphChangedMock = vi.fn()
})
it('should initialize with empty state', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(graphManager.updateFlags.value).toEqual({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
})
it('should setup event listeners on init', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should wrap graph callbacks on setup', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Should wrap the callbacks
expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange)
// Test wrapped callbacks
const testNode = { id: '3' } as LGraphNode
mockGraph.onNodeAdded!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should prevent duplicate event listener setup', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
onNodeRemoved: mockGraph.onNodeRemoved,
onConnectionChange: mockGraph.onConnectionChange
}
// Setup again - should not re-wrap
graphManager.setupEventListeners()
expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
wrappedCallbacks.onConnectionChange
)
})
it('should cleanup event listeners properly', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
// Should restore original callbacks
expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})
it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.cleanupEventListeners()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Attempted to cleanup event listeners for graph that was never set up'
)
consoleErrorSpy.mockRestore()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// First check - cache initial state
let hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true) // Initial cache population
// No changes
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(false)
// Change node position
mockGraph._nodes[0].pos = [200, 150]
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect node count changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Add a node
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect connection changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Change connections
mockGraph.links = new Map([
[1, { id: 1 }],
[2, { id: 2 }]
]) as any
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should handle node removal in callbacks', () => {
const originalOnNodeRemoved = vi.fn()
mockGraph.onNodeRemoved = originalOnNodeRemoved
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const removedNode = { id: '2' } as LGraphNode
mockGraph.onNodeRemoved!(removedNode)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should destroy properly', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
graphManager.setupEventListeners()
graphManager.destroy()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should clear cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Populate cache
graphManager.checkForChanges()
// Clear cache
graphManager.clearCache()
// Should detect changes again after clear
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
})
it('should handle null graph gracefully', () => {
const graphRef = ref(null as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(() => graphManager.setupEventListeners()).not.toThrow()
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
expect(graphManager.checkForChanges()).toBe(false)
})
it('should clean up removed nodes from cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Remove a node
mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2')
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
})
it('should throttle graph changed callback', () => {
const throttledFn = vi.fn()
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Trigger multiple changes rapidly
mockGraph.onNodeAdded!({ id: '3' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '4' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '5' } as LGraphNode)
// Should be throttled
expect(throttledFn).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,328 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
describe('useMinimapInteraction', () => {
let mockContainer: HTMLDivElement
let mockCanvas: MinimapCanvas
let centerViewOnMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockContainer = {
getBoundingClientRect: vi.fn().mockReturnValue({
left: 100,
top: 50,
width: 250,
height: 200
})
} as any
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
} as any
centerViewOnMock = vi.fn()
})
it('should initialize with default values', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
expect(interaction.isDragging.value).toBe(false)
expect(interaction.containerRect.value).toEqual({
left: 0,
top: 0,
width: 250,
height: 200
})
})
it('should update container rect', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
interaction.updateContainerRect()
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(interaction.containerRect.value).toEqual({
left: 100,
top: 50,
width: 250,
height: 200
})
})
it('should handle pointer down and start dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const event = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
interaction.handlePointerDown(event)
expect(interaction.isDragging.value).toBe(true)
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should handle pointer move when dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
// Move pointer
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
// Should calculate world coordinates and center view
expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move
// Calculate expected world coordinates
const x = 200 - 100 // clientX - containerLeft
const y = 150 - 50 // clientY - containerTop
const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2
const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2
const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX
const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY
expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY)
})
it('should not move when not dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
expect(interaction.isDragging.value).toBe(true)
interaction.handlePointerUp()
expect(interaction.isDragging.value).toBe(false)
})
it('should handle wheel events for zooming', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should update canvas scale (zoom in)
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should respect zoom limits', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Set scale close to minimum
mockCanvas.ds.scale = 0.11
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100, // Zoom out
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should not go below minimum scale
expect(mockCanvas.ds.scale).toBe(0.11)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle null container gracefully', () => {
const containerRef = ref<HTMLDivElement | undefined>(undefined)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() => interaction.updateContainerRect()).not.toThrow()
expect(() =>
interaction.handlePointerDown(new PointerEvent('pointerdown'))
).not.toThrow()
})
it('should handle null canvas gracefully', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(null as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() =>
interaction.handlePointerMove(new PointerEvent('pointermove'))
).not.toThrow()
expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow()
expect(centerViewOnMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { UpdateFlags } from '@/renderer/extensions/minimap/types'
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
renderMinimapToCanvas: vi.fn()
}))
describe('useMinimapRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn()
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
} as any
})
it('should initialize with full redraw needed', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
expect(renderer.needsFullRedraw.value).toBe(true)
expect(renderer.needsBoundsUpdate.value).toBe(true)
})
it('should handle empty graph with fast path', () => {
const emptyGraph = { _nodes: [] } as any
const canvasRef = ref(mockCanvas)
const graphRef = ref(emptyGraph)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.renderMinimap()
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } = await import(
'@/renderer/extensions/minimap/minimapCanvasRenderer'
)
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// First render (needsFullRedraw is true by default)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Second render without changes (should not render)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Set update flag and render again
updateFlagsRef.value.nodes = true
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2)
})
it('should update minimap with bounds and viewport callbacks', () => {
const updateBounds = vi.fn()
const updateViewport = vi.fn()
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: true,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.updateMinimap(updateBounds, updateViewport)
expect(updateBounds).toHaveBeenCalled()
expect(updateViewport).toHaveBeenCalled()
expect(updateFlagsRef.value.bounds).toBe(false)
expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false
expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false
})
it('should force full redraw when requested', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.forceFullRedraw()
expect(renderer.needsFullRedraw.value).toBe(true)
expect(updateFlagsRef.value.bounds).toBe(true)
expect(updateFlagsRef.value.nodes).toBe(true)
expect(updateFlagsRef.value.connections).toBe(true)
expect(updateFlagsRef.value.viewport).toBe(true)
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref<HTMLCanvasElement | undefined>(undefined)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// Should not throw
expect(() => renderer.renderMinimap()).not.toThrow()
expect(mockCanvas.getContext).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,122 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
vi.mock('@/stores/settingStore')
vi.mock('@/stores/workspace/colorPaletteStore')
describe('useMinimapSettings', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should return all minimap settings as computed refs', () => {
const mockSettingStore = {
get: vi.fn((key: string) => {
const settings: Record<string, any> = {
'Comfy.Minimap.NodeColors': true,
'Comfy.Minimap.ShowLinks': false,
'Comfy.Minimap.ShowGroups': true,
'Comfy.Minimap.RenderBypassState': false,
'Comfy.Minimap.RenderErrorState': true
}
return settings[key]
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
expect(settings.showGroups.value).toBe(true)
expect(settings.renderBypass.value).toBe(false)
expect(settings.renderError.value).toBe(true)
})
it('should generate container styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C') // dark theme color
expect(styles.border).toBe('1px solid #333')
})
it('should generate light theme container styles', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: true }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.backgroundColor).toBe('#FAF9F5') // light theme color
expect(styles.border).toBe('1px solid #ccc')
})
it('should generate panel styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.panelStyles.value
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.borderRadius).toBe('8px')
})
it('should create computed properties that call the store getter', () => {
const mockGet = vi.fn((key: string) => {
if (key === 'Comfy.Minimap.NodeColors') return true
if (key === 'Comfy.Minimap.ShowLinks') return false
return true
})
const mockSettingStore = { get: mockGet }
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
// Access the computed properties
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
// Verify the store getter was called with the correct keys
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors')
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks')
})
})

View File

@@ -0,0 +1,289 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@/composables/canvas/useCanvasTransformSync')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),
enforceMinimumBounds: vi.fn()
}))
describe('useMinimapViewport', () => {
let mockCanvas: MinimapCanvas
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockCanvas = {
canvas: {
clientWidth: 800,
clientHeight: 600,
width: 1600,
height: 1200
} as HTMLCanvasElement,
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
mockGraph = {
_nodes: [
{ pos: [100, 100], size: [150, 80] },
{ pos: [300, 200], size: [120, 60] }
]
} as any
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: vi.fn(),
stopSync: vi.fn()
} as any)
})
it('should initialize with default bounds', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
maxX: 420,
maxY: 260,
width: 320,
height: 160
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes)
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref({ _nodes: [] } as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
width: 100,
height: 100
})
})
it('should update canvas dimensions', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
expect(viewport.canvasDimensions.value).toEqual({
width: 800,
height: 600
})
})
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Set canvas transform
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
// Update bounds and viewport
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const transform = viewport.viewportTransform.value
// World coordinates
const worldX = -(-100) // -offset[0] = 100
const worldY = -(-50) // -offset[1] = 50
// Viewport size in world coordinates
const viewportWidth = 800 / 2 // canvasWidth / scale = 400
const viewportHeight = 600 / 2 // canvasHeight / scale = 300
// Center offsets
const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0
const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0
// Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX
expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50
expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25
expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
mockCanvas.ds.scale = 2
viewport.centerViewOn(300, 200)
// Should update canvas offset to center on the given world coordinates
const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2)
const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2)
expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX)
expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should start and stop viewport sync', () => {
const startSyncMock = vi.fn()
const stopSyncMock = vi.fn()
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: startSyncMock,
stopSync: stopSyncMock
} as any)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.startViewportSync()
expect(startSyncMock).toHaveBeenCalled()
viewport.stopViewportSync()
expect(stopSyncMock).toHaveBeenCalled()
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref(null as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Should not throw
expect(() => viewport.updateCanvasDimensions()).not.toThrow()
expect(() => viewport.updateViewport()).not.toThrow()
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
}
vi.mocked(calculateNodeBounds).mockReturnValue(testBounds)
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200)
expect(viewport.scale.value).toBe(0.4)
})
it('should handle device pixel ratio', () => {
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
// Should use client dimensions or calculate from canvas dimensions / dpr
expect(viewport.canvasDimensions.value.width).toBe(800)
expect(viewport.canvasDimensions.value.height).toBe(600)
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
})
})

View File

@@ -0,0 +1,324 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
vi.mock('@/stores/workspace/colorPaletteStore')
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_adjusted')
}))
describe('minimapCanvasRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [
{
id: '1',
pos: [100, 100],
size: [150, 80],
bgcolor: '#FF0000',
mode: LGraphEventMode.ALWAYS,
has_errors: false,
outputs: []
},
{
id: '2',
pos: [300, 200],
size: [120, 60],
bgcolor: '#00FF00',
mode: LGraphEventMode.BYPASS,
has_errors: true,
outputs: []
}
] as unknown as LGraphNode[],
_groups: [],
links: {},
getNodeById: vi.fn()
} as any
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
})
it('should clear canvas and render nodes', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should clear the canvas first
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
// Should render nodes (batch by color)
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should handle empty graph', () => {
mockGraph._nodes = []
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(mockContext.fillRect).not.toHaveBeenCalled()
})
it('should batch render nodes by color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set fill style for each color group
const fillStyleCalls = []
let currentStyle = ''
mockContext.fillStyle = ''
Object.defineProperty(mockContext, 'fillStyle', {
get: () => currentStyle,
set: (value) => {
currentStyle = value
fillStyleCalls.push(value)
}
})
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Different colors for different nodes
expect(fillStyleCalls.length).toBeGreaterThan(0)
})
it('should render bypass nodes with special color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Node 2 is in bypass mode, should be rendered
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render error outlines when enabled', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set stroke style for errors
expect(mockContext.strokeStyle).toBe('#FF0000')
expect(mockContext.strokeRect).toHaveBeenCalled()
})
it('should render groups when enabled', () => {
mockGraph._groups = [
{
pos: [50, 50],
size: [400, 300],
color: '#0000FF'
}
] as any
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: true,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Groups should be rendered before nodes
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render connections when enabled', () => {
const targetNode = {
id: '2',
pos: [300, 200],
size: [120, 60]
}
mockGraph._nodes[0].outputs = [
{
links: [1]
}
] as any
// Create a hybrid Map/Object for links as LiteGraph expects
const linksMap = new Map([[1, { id: 1, target_id: 2 }]])
const links = Object.assign(linksMap, {
1: { id: 1, target_id: 2 }
})
mockGraph.links = links as any
mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: true,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should draw connection lines
expect(mockContext.beginPath).toHaveBeenCalled()
expect(mockContext.moveTo).toHaveBeenCalled()
expect(mockContext.lineTo).toHaveBeenCalled()
expect(mockContext.stroke).toHaveBeenCalled()
// Should draw connection slots
expect(mockContext.arc).toHaveBeenCalled()
expect(mockContext.fill).toHaveBeenCalled()
})
it('should handle light theme colors', () => {
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: true }
} as any)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Color adjustment should be called for light theme
expect(adjustColor).toHaveBeenCalled()
})
it('should calculate correct offsets for centering', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 200, height: 100 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// With bounds 200x100 at scale 0.5 = 100x50
// Canvas is 250x200, so offset should be (250-100)/2 = 75, (200-50)/2 = 75
// This affects node positioning
expect(mockContext.fillRect).toHaveBeenCalled()
})
})

View File

@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
vi.mock('@/composables/useMinimap', () => ({
useMinimap: vi.fn()
vi.mock('@/renderer/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
@@ -19,13 +19,14 @@ vi.mock('@/scripts/api', () => ({
}))
const { useWorkflowThumbnail } = await import(
'@/composables/useWorkflowThumbnail'
'@/renderer/thumbnail/composables/useWorkflowThumbnail'
)
const { createGraphThumbnail } = await import(
'@/renderer/thumbnail/graphThumbnailRenderer'
)
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let mockMinimapInstance: any
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
@@ -39,35 +40,23 @@ describe('useWorkflowThumbnail', () => {
// Now set up mocks
vi.clearAllMocks()
const blob = new Blob()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
global.URL.revokeObjectURL = vi.fn()
// Mock API responses
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
mockMinimapInstance = {
renderMinimap: vi.fn(),
canvasRef: {
value: {
toBlob: vi.fn((cb) => cb(blob))
}
},
width: 250,
height: 200
}
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
// Default createGraphThumbnail to return test value
vi.mocked(createGraphThumbnail).mockReturnValue(
'data:image/png;base64,test'
)
})
it('should capture minimap thumbnail', async () => {
const { createMinimapPreview } = useWorkflowThumbnail()
const thumbnail = await createMinimapPreview()
expect(useMinimap).toHaveBeenCalledOnce()
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
expect(createGraphThumbnail).toHaveBeenCalledOnce()
expect(thumbnail).toBe('data:image/png;base64,test')
})
@@ -161,6 +150,9 @@ describe('useWorkflowThumbnail', () => {
// Reset the mock to track new calls and create different URL
vi.clearAllMocks()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
vi.mocked(createGraphThumbnail).mockReturnValue(
'data:image/png;base64,test2'
)
// Store second thumbnail for same workflow - should revoke the first URL
await storeThumbnail(mockWorkflow)

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GroupNodeHandler } from '@/extensions/core/groupNode'
import type {
ExecutableLGraphNode,
ExecutionId,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
describe('ExecutableGroupNodeChildDTO', () => {
let mockNode: LGraphNode
let mockInputNode: LGraphNode
let mockNodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>
let mockGroupNodeHandler: GroupNodeHandler
beforeEach(() => {
// Create mock nodes
mockNode = {
id: '3', // Simple node ID for most tests
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
mockInputNode = {
id: '1',
graph: {}
} as any
// Create the nodesByExecutionId map
mockNodesByExecutionId = new Map()
mockGroupNodeHandler = {} as GroupNodeHandler
})
describe('resolveInput', () => {
it('should resolve input from external node (node outside the group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// External node with ID '1'
const externalNodeDto = {
id: '1',
type: 'TestNode'
} as ExecutableLGraphNode
mockNodesByExecutionId.set('1', externalNodeDto)
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[], // No subgraph path - group is in root graph
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: externalNodeDto,
origin_id: '1',
origin_slot: 0
})
})
it('should resolve input from internal node (node inside the same group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
// Internal node with ID '10:2'
const internalInputNode = {
id: '10:2',
graph: {}
} as LGraphNode
const internalNodeDto = {
id: '2',
type: 'InternalNode'
} as ExecutableLGraphNode
// Internal nodes are stored with just their index
mockNodesByExecutionId.set('2', internalNodeDto)
groupNodeChild.getInputNode.mockReturnValue(internalInputNode)
groupNodeChild.getInputLink.mockReturnValue({
origin_slot: 1
})
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: internalNodeDto,
origin_id: '10:2',
origin_slot: 1
})
})
it('should return undefined if no input node exists', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toBeUndefined()
})
it('should throw error if input link is missing', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow('Failed to get input link')
})
it('should throw error if input node cannot be found in nodesByExecutionId', () => {
// Node exists but is not in the map
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue({
origin_slot: 0
})
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId, // Empty map
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Failed to get input node 1 for group node child 3 with slot 0'
)
})
it('should throw error for group nodes inside subgraphs (unsupported)', () => {
// Setup: Group node child inside a subgraph (execution ID has more than 2 segments)
const nestedGroupNode = {
id: '1:2:3', // subgraph:groupnode:innernode
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// Create DTO with deeply nested path to simulate group node inside subgraph
const dto = new ExecutableGroupNodeChildDTO(
nestedGroupNode,
['1', '2'], // Path indicating it's inside a subgraph then group
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
})
})
})

View File

@@ -18,18 +18,7 @@ export default defineConfig({
dts({
copyDtsFiles: true,
rollupTypes: true,
tsconfigPath: 'tsconfig.types.json',
beforeWriteFile: (filePath, content) => {
// Remove #private field declarations to prevent conflicts with external packages
// This fixes issue #5033 where #private fields cause type incompatibility
const cleanedContent = content
.replace(/\s*#private;\s*/g, '') // Remove "#private;" declarations
.replace(/\s*#[a-zA-Z_$][a-zA-Z0-9_$]*\s*:\s*[^;]+;\s*/g, '') // Remove full private field declarations
return {
filePath,
content: cleanedContent
}
}
tsconfigPath: 'tsconfig.types.json'
})
]
})