mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-17 19:07:32 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7fd4b22b | ||
|
|
17ad24907c | ||
|
|
3fe70be39d | ||
|
|
afd561eb83 | ||
|
|
808d63996c | ||
|
|
cf1ff71651 | ||
|
|
5a35562d3d | ||
|
|
ceac8f3741 | ||
|
|
b1057f164b | ||
|
|
4a189bdc93 | ||
|
|
f0adb4c9d3 | ||
|
|
d5d0aa52c2 | ||
|
|
69c660b3b7 | ||
|
|
88579c2a40 | ||
|
|
7ab247aa1d | ||
|
|
c78d03dd2c | ||
|
|
65785af348 | ||
|
|
ec4ad5ea92 | ||
|
|
e9ddf29507 | ||
|
|
fdd8564c07 | ||
|
|
d18081a54e | ||
|
|
45cc6ca2b4 | ||
|
|
c303a3f037 | ||
|
|
c90fd18ade | ||
|
|
2ed1704749 | ||
|
|
7d5a4d423e | ||
|
|
7aaa0f022e | ||
|
|
a132dad216 | ||
|
|
9dbdc6a72b | ||
|
|
7b228d693d | ||
|
|
547af0e043 | ||
|
|
4ca6220adf | ||
|
|
1e41c6dc45 | ||
|
|
5224c63bce |
@@ -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
|
||||
@@ -502,11 +569,49 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
## 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
|
||||
```
|
||||
|
||||
4. **RELEASE COMPLETION**: All post-release setup completed?
|
||||
|
||||
### Step 17: Create Release Summary
|
||||
|
||||
1. **Create comprehensive release summary:**
|
||||
```bash
|
||||
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. **RELEASE COMPLETION**: All steps completed successfully?
|
||||
|
||||
## Advanced Safety Features
|
||||
|
||||
### Rollback Procedures
|
||||
|
||||
13
.github/workflows/test-cicd.yml
vendored
13
.github/workflows/test-cicd.yml
vendored
@@ -1,13 +0,0 @@
|
||||
name: Test CI/CD Validation
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Validate CI/CD
|
||||
run: echo "CI/CD pipeline validation successful"
|
||||
@@ -13,6 +13,10 @@ module.exports = defineConfig({
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
|
||||
|
||||
IMPORTANT Chinese Translation Guidelines:
|
||||
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
|
||||
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
|
||||
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Topbar {
|
||||
workflowName: string,
|
||||
command: 'Save' | 'Save As' | 'Export'
|
||||
) {
|
||||
await this.triggerTopbarCommand(['Workflow', command])
|
||||
await this.triggerTopbarCommand(['File', command])
|
||||
await this.getSaveDialog().fill(workflowName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -72,8 +72,8 @@ export class Topbar {
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
if (path.length < 1) {
|
||||
throw new Error('Path cannot be empty')
|
||||
}
|
||||
|
||||
const menu = await this.openTopbarMenu()
|
||||
@@ -85,6 +85,13 @@ export class Topbar {
|
||||
.locator('.p-tieredmenu-item')
|
||||
.filter({ has: topLevelMenuItem })
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
|
||||
// Handle top-level commands (like "New")
|
||||
if (path.length === 1) {
|
||||
await topLevelMenuItem.click()
|
||||
return
|
||||
}
|
||||
|
||||
await topLevelMenu.hover()
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
|
||||
@@ -268,10 +268,7 @@ test.describe('Group Node', () => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand([
|
||||
'Edit',
|
||||
'Clear Workflow'
|
||||
])
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.ctrlV()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
@@ -280,7 +277,7 @@ test.describe('Group Node', () => {
|
||||
test('Copies and pastes group node into a newly created blank workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.ctrlV()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
@@ -296,7 +293,7 @@ test.describe('Group Node', () => {
|
||||
test('Serializes group node after copy and paste across workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.ctrlV()
|
||||
const currentGraphState = await comfyPage.page.evaluate(() =>
|
||||
window['app'].graph.serialize()
|
||||
|
||||
@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage to persist the workflow paths before reloading
|
||||
|
||||
@@ -73,9 +73,80 @@ 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('Workflow')
|
||||
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
|
||||
await workflowMenuItem.hover()
|
||||
const exportTag = comfyPage.page.locator('.keybinding-tag', {
|
||||
hasText: 'Ctrl + s'
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
|
||||
[workflowName]: workflowName
|
||||
})
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
|
||||
// Insert the workflow
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
|
||||
@@ -63,7 +63,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
test('Should show thumbnail when hovering over a non-active tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
0,
|
||||
@@ -73,7 +73,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
})
|
||||
|
||||
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
1,
|
||||
@@ -105,7 +105,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Create a new workflow (tab 1) which will be empty
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.2",
|
||||
"version": "1.26.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.2",
|
||||
"version": "1.26.4",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.26.2",
|
||||
"version": "1.26.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ command.label || command.id }}
|
||||
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
src/components/common/ListExplorer.vue
Normal file
208
src/components/common/ListExplorer.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="h-full overflow-hidden pb-1">
|
||||
<div class="flex item-center">
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.key"
|
||||
class="flex justify-between items-center px-2 overflow-hidden hover:bg-blue-600/40 cursor-pointer"
|
||||
:style="{ flexBasis: `${item.width}px`, height: '36px' }"
|
||||
@click="changeSort(item)"
|
||||
>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ $t(`g.${item.key}`) }}
|
||||
</span>
|
||||
<span
|
||||
v-show="item.key === sortField"
|
||||
:class="[
|
||||
'text-xs pi',
|
||||
sortDirection === 'asc' ? 'pi-angle-up' : 'pi-angle-down'
|
||||
]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ height: 'calc(100% - 36px)' }">
|
||||
<VirtualScroll :items="sortedItems" :item-size="36">
|
||||
<template #item="{ item: row }">
|
||||
<div
|
||||
class="h-full py-px"
|
||||
@click="emit('itemClick', row, $event)"
|
||||
@dblclick="emit('itemDbClick', row, $event)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center h-full hover:bg-blue-600/40',
|
||||
selectedKeys.includes(row.key) ? 'bg-blue-700/40' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in columns"
|
||||
:key="item.key"
|
||||
class="flex items-center px-2 py-1 overflow-hidden select-none"
|
||||
:style="{ flexBasis: `${item.width}px`, textAlign: item.align }"
|
||||
>
|
||||
<span v-if="index === 0" :class="['mr-2 pi', row.icon]"></span>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ (row._display as any)[item.key] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScroll>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import VirtualScroll from './VirtualScroll.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
name: string
|
||||
type: string
|
||||
modifyTime: number
|
||||
size: number
|
||||
}
|
||||
|
||||
type RecordString<T> = Record<keyof T, any>
|
||||
|
||||
type ResolvedItem<T> = T & {
|
||||
icon: string
|
||||
_display: RecordString<T>
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
width: number
|
||||
align?: 'left' | 'right'
|
||||
defaultSort?: SortDirection
|
||||
renderText: (val: any, row: Item) => string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: Item[]
|
||||
}>()
|
||||
|
||||
const selectedKeys = defineModel<string[]>({ default: [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
itemClick: [Item, MouseEvent]
|
||||
itemDbClick: [Item, MouseEvent]
|
||||
}>()
|
||||
|
||||
const columns = ref<Column[]>([
|
||||
{
|
||||
key: 'name',
|
||||
width: 300,
|
||||
renderText: (val) => val
|
||||
},
|
||||
{
|
||||
key: 'modifyTime',
|
||||
width: 200,
|
||||
defaultSort: 'desc',
|
||||
renderText: (val) =>
|
||||
new Date(val).toLocaleDateString() +
|
||||
' ' +
|
||||
new Date(val).toLocaleTimeString()
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
width: 100,
|
||||
renderText: (val) => t(`g.${val}`)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
width: 120,
|
||||
defaultSort: 'desc',
|
||||
align: 'right',
|
||||
renderText: (val, item) => (item.type === 'folder' ? '' : formatSize(val))
|
||||
}
|
||||
])
|
||||
|
||||
provide('listExplorerColumns', columns)
|
||||
|
||||
const sortDirection = ref<SortDirection>('asc')
|
||||
const sortField = ref('name')
|
||||
|
||||
const iconMapLegacy = (icon: string) => {
|
||||
const prefix = 'pi-'
|
||||
const legacy: Record<string, string> = {
|
||||
audio: 'headphones'
|
||||
}
|
||||
return prefix + (legacy[icon] || icon)
|
||||
}
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
const columnRenderText = columns.value.reduce(
|
||||
(acc, column) => {
|
||||
acc[column.key] = column.renderText
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, (val: any, row: Item) => string>
|
||||
)
|
||||
|
||||
return props.items.map((item) => {
|
||||
const display = Object.entries(item).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = columnRenderText[key]?.(value, item) ?? value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
return { ...item, icon: iconMapLegacy(item.type), _display: display }
|
||||
})
|
||||
})
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const folderItems: ResolvedItem<Item>[] = []
|
||||
const fileItems: ResolvedItem<Item>[] = []
|
||||
|
||||
for (const item of renderedItems.value) {
|
||||
if (item.type === 'folder') {
|
||||
folderItems.push(item)
|
||||
} else {
|
||||
fileItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
const sorting = (a: ResolvedItem<Item>, b: ResolvedItem<Item>) => {
|
||||
const aValue = (a as any)[sortField.value]
|
||||
const bValue = (b as any)[sortField.value]
|
||||
|
||||
const result =
|
||||
typeof aValue === 'string'
|
||||
? aValue.localeCompare(bValue)
|
||||
: aValue - bValue
|
||||
|
||||
return result * direction
|
||||
}
|
||||
|
||||
folderItems.sort(sorting)
|
||||
fileItems.sort(sorting)
|
||||
|
||||
const folderFirstField = ['modifyTime', 'type']
|
||||
return direction > 0 || folderFirstField.includes(sortField.value)
|
||||
? [...folderItems, ...fileItems]
|
||||
: [...fileItems, ...folderItems]
|
||||
})
|
||||
|
||||
const changeSort = (column: Column) => {
|
||||
if (column.key === sortField.value) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = column.key
|
||||
sortDirection.value = column.defaultSort ?? 'asc'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
src/components/common/VirtualScroll.vue
Normal file
85
src/components/common/VirtualScroll.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${state.start * itemSize}px` }"></div>
|
||||
<div :style="contentStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
:style="{ height: `${itemSize}px` }"
|
||||
data-virtual-item
|
||||
>
|
||||
<slot name="item" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{ height: `${(items.length - state.end) * itemSize}px` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useElementSize, useScroll } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { type CSSProperties, computed, ref } from 'vue'
|
||||
|
||||
type Item = T & { key: string }
|
||||
|
||||
const props = defineProps<{
|
||||
items: Item[]
|
||||
itemSize: number
|
||||
contentStyle?: Partial<CSSProperties>
|
||||
scrollThrottle?: number
|
||||
}>()
|
||||
|
||||
const { scrollThrottle = 64 } = props
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { height } = useElementSize(container)
|
||||
const { y: scrollY } = useScroll(container, {
|
||||
throttle: scrollThrottle,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / props.itemSize))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / props.itemSize))
|
||||
|
||||
const state = computed(() => {
|
||||
const bufferRows = viewRows.value
|
||||
|
||||
const fromRow = offsetRows.value - bufferRows
|
||||
const toRow = offsetRows.value + bufferRows + viewRows.value
|
||||
|
||||
return {
|
||||
start: clamp(fromRow, 0, props.items.length),
|
||||
end: clamp(toRow, fromRow, props.items.length)
|
||||
}
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
return props.items.slice(state.value.start, state.value.end)
|
||||
})
|
||||
|
||||
const reset = () => {}
|
||||
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/components/custom/button/IconButton.vue
Normal file
15
src/components/custom/button/IconButton.vue
Normal 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>
|
||||
67
src/components/custom/widget/ModelSelector.vue
Normal file
67
src/components/custom/widget/ModelSelector.vue
Normal 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>
|
||||
176
src/components/custom/widget/layout/BaseWidgetLayout.vue
Normal file
176
src/components/custom/widget/layout/BaseWidgetLayout.vue
Normal 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>
|
||||
24
src/components/custom/widget/nav/NavItem.vue
Normal file
24
src/components/custom/widget/nav/NavItem.vue
Normal 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>
|
||||
13
src/components/custom/widget/nav/NavTitle.vue
Normal file
13
src/components/custom/widget/nav/NavTitle.vue
Normal 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>
|
||||
75
src/components/custom/widget/panel/LeftSidePanel.vue
Normal file
75
src/components/custom/widget/panel/LeftSidePanel.vue
Normal 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>
|
||||
12
src/components/custom/widget/panel/PanelHeader.vue
Normal file
12
src/components/custom/widget/panel/PanelHeader.vue
Normal 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>
|
||||
5
src/components/custom/widget/panel/RightSidePanel.vue
Normal file
5
src/components/custom/widget/panel/RightSidePanel.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -90,18 +90,16 @@ const closeDialog = () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
if (!triggerEvent) {
|
||||
console.warn('The trigger event was undefined when addNode was called.')
|
||||
return
|
||||
}
|
||||
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
if (disconnectOnReset) {
|
||||
if (disconnectOnReset && triggerEvent) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
} else if (!triggerEvent) {
|
||||
console.warn('The trigger event was undefined when addNode was called.')
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
|
||||
@@ -58,11 +58,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
@@ -70,8 +71,9 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { shouldShowRedDot } = storeToRefs(releaseStore)
|
||||
const isHelpCenterVisible = ref(false)
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -80,11 +82,11 @@ const sidebarLocation = computed(() =>
|
||||
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
|
||||
|
||||
const toggleHelpCenter = () => {
|
||||
isHelpCenterVisible.value = !isHelpCenterVisible.value
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
isHelpCenterVisible.value = false
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
@@ -130,6 +132,7 @@ onMounted(async () => {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
@@ -30,11 +30,18 @@
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-refresh"
|
||||
icon="pi pi-filter-slash"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('menu.refresh')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
@@ -139,6 +146,7 @@ import {
|
||||
DEFAULT_SORTING_ID,
|
||||
nodeOrganizationService
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
@@ -155,6 +163,7 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const commandStore = useCommandStore()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
|
||||
253
src/components/sidebar/tabs/OutputExplorerSidebarTab.vue
Normal file
253
src/components/sidebar/tabs/OutputExplorerSidebarTab.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.outputExplorer')">
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.back')"
|
||||
icon="pi pi-arrow-up"
|
||||
severity="secondary"
|
||||
text
|
||||
:disabled="!currentFolder"
|
||||
@click="handleBackParentFolder"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="loadFolderItems"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="model-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchIn', ['output'])"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="h-full overflow-hidden">
|
||||
<ListExplorer
|
||||
class="flex-1"
|
||||
:style="{ height: 'calc(100% - 36px)' }"
|
||||
:items="renderedItems"
|
||||
@item-db-click="handleDbClickItem"
|
||||
></ListExplorer>
|
||||
<div class="h-8 flex items-center px-2 text-sm">
|
||||
<div class="flex gap-1">
|
||||
{{ $t('g.itemsCount', [itemsCount]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="previewVisible"
|
||||
class="fixed left-0 top-0 z-[5000] flex h-full w-full items-center justify-center bg-black/70"
|
||||
>
|
||||
<div class="absolute right-3 top-3">
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="closePreview"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="h-full w-full select-none p-10">
|
||||
<img
|
||||
v-if="currentItem?.type === 'image'"
|
||||
class="h-full w-full object-contain"
|
||||
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
|
||||
alt="preview"
|
||||
/>
|
||||
<video
|
||||
v-if="currentItem?.type === 'video'"
|
||||
class="h-full w-full object-contain"
|
||||
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
|
||||
controls
|
||||
></video>
|
||||
<div
|
||||
v-if="currentItem?.type === 'audio'"
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="px-8 pt-6 rounded-full"
|
||||
:style="{ background: 'var(--p-button-secondary-background)' }"
|
||||
>
|
||||
<div class="text-center mb-2">{{ currentItem?.name }}</div>
|
||||
<audio
|
||||
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
|
||||
controls
|
||||
></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-2 top-1/2">
|
||||
<Button
|
||||
icon="pi pi-angle-left"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="openPreviousItem"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="absolute right-2 top-1/2">
|
||||
<Button
|
||||
icon="pi pi-angle-right"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="openNextItem"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import ListExplorer from '@/components/common/ListExplorer.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface OutputItem {
|
||||
key: string
|
||||
name: string
|
||||
type: 'folder' | 'image' | 'video' | 'audio'
|
||||
size: number
|
||||
createTime: number
|
||||
modifyTime: number
|
||||
}
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const folderPaths = ref<OutputItem[]>([])
|
||||
const currentFolder = computed(() => {
|
||||
return folderPaths.value.map((item) => item.name).join('/')
|
||||
})
|
||||
const currentFolderItems = ref<OutputItem[]>([])
|
||||
const folderPrefix = computed(() => {
|
||||
return currentFolder.value ? `${currentFolder.value}/` : ''
|
||||
})
|
||||
|
||||
const filterContent = ref('')
|
||||
|
||||
const itemsCount = computed(() => {
|
||||
return currentFolderItems.value.length.toLocaleString()
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
const query = filterContent.value
|
||||
let items = currentFolderItems.value
|
||||
|
||||
if (query) {
|
||||
items = items.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
// Convert OutputItem to Item format expected by ListExplorer
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
modifyTime: item.modifyTime
|
||||
}))
|
||||
})
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
filterContent.value = query
|
||||
}
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const currentItem = ref<OutputItem | null>(null)
|
||||
const currentItemIndex = ref(-1)
|
||||
const currentTypeItems = ref<OutputItem[]>([])
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
const openPreviousItem = () => {
|
||||
currentItemIndex.value--
|
||||
if (currentItemIndex.value < 0) {
|
||||
currentItemIndex.value = currentTypeItems.value.length - 1
|
||||
}
|
||||
const item = currentTypeItems.value[currentItemIndex.value]
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
const openNextItem = () => {
|
||||
currentItemIndex.value++
|
||||
if (currentItemIndex.value > currentTypeItems.value.length - 1) {
|
||||
currentItemIndex.value = 0
|
||||
}
|
||||
const item = currentTypeItems.value[currentItemIndex.value]
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
const openItemPreview = (item: OutputItem) => {
|
||||
previewVisible.value = true
|
||||
currentItem.value = item
|
||||
|
||||
const itemType = item.type
|
||||
currentTypeItems.value = currentFolderItems.value.filter(
|
||||
(o) => o.type === itemType
|
||||
)
|
||||
|
||||
currentItemIndex.value = currentTypeItems.value.indexOf(item)
|
||||
}
|
||||
|
||||
const loadFolderItems = async () => {
|
||||
const resData = await api.getOutputFolderItems(currentFolder.value)
|
||||
currentFolderItems.value = resData.map((item: any) => ({
|
||||
key: item.name,
|
||||
...item
|
||||
}))
|
||||
}
|
||||
|
||||
const openFolder = async (item: OutputItem, pathIndex: number) => {
|
||||
folderPaths.value.splice(pathIndex)
|
||||
folderPaths.value.push(item)
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
const handleBackParentFolder = async () => {
|
||||
folderPaths.value.pop()
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
const handleDbClickItem = (item: any, _event: MouseEvent) => {
|
||||
// Find the original OutputItem from currentFolderItems
|
||||
const originalItem = currentFolderItems.value.find(
|
||||
(outputItem) => outputItem.key === item.key
|
||||
)
|
||||
if (!originalItem) return
|
||||
|
||||
if (originalItem.type === 'folder') {
|
||||
void openFolder(originalItem, folderPaths.value.length)
|
||||
} else {
|
||||
openItemPreview(originalItem)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFolderItems()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.pi-fake-spacer) {
|
||||
height: 1px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
:deep(audio::-webkit-media-controls-enclosure) {
|
||||
background-color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -55,9 +55,30 @@
|
||||
v-bind="props.action"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
:class="typeof item.class === 'function' ? item.class() : item.class"
|
||||
@mousedown="
|
||||
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
|
||||
"
|
||||
@click="handleItemClick(item, $event)"
|
||||
>
|
||||
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
|
||||
<i
|
||||
v-if="hasActiveStateSiblings(item)"
|
||||
class="p-menubar-item-icon pi pi-check text-sm"
|
||||
:class="{ invisible: !item.comfyCommand?.active?.() }"
|
||||
/>
|
||||
<span
|
||||
v-else-if="
|
||||
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
|
||||
"
|
||||
class="p-menubar-item-icon"
|
||||
:class="item.icon"
|
||||
/>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<i
|
||||
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
|
||||
class="ml-auto"
|
||||
:class="item.icon"
|
||||
/>
|
||||
<span
|
||||
v-if="item?.comfyCommand?.keybinding"
|
||||
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
|
||||
@@ -94,6 +115,7 @@ import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
@@ -163,16 +185,22 @@ const extraMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
key: 'browse-templates',
|
||||
label: t('menuLabels.Browse Templates'),
|
||||
icon: 'pi pi-folder-open',
|
||||
command: () => commandStore.execute('Comfy.BrowseTemplates')
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
command: () => showSettings()
|
||||
},
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
}
|
||||
]
|
||||
|
||||
@@ -237,6 +265,44 @@ const onMenuShow = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isZoomCommand = (item: MenuItem) => {
|
||||
return (
|
||||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
|
||||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
|
||||
)
|
||||
}
|
||||
|
||||
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
|
||||
if (item.comfyCommand) {
|
||||
whileMouseDown(
|
||||
event,
|
||||
async () => {
|
||||
await commandStore.execute(item.comfyCommand!.id)
|
||||
},
|
||||
50
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1362,9 +1362,27 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.0004/$0.0016 per 1K tokens'
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return '$0.002/$0.008 per 1K tokens'
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return '$0.00005/$0.0004 per 1K tokens'
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return '$0.00025/$0.002 per 1K tokens'
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
ViduTextToVideoNode: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
ViduImageToVideoNode: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
ViduReferenceVideoNode: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
ViduStartEndToVideoNode: {
|
||||
displayPrice: '$0.4/Run'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
src/composables/sidebarTabs/outputExplorerSidebarTab.ts
Normal file
18
src/composables/sidebarTabs/outputExplorerSidebarTab.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import OutputExplorerSidebarTab from '@/components/sidebar/tabs/OutputExplorerSidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useOutputExplorerSidebarTab = (): SidebarTabExtension => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
id: 'output-explorer',
|
||||
icon: 'pi pi-image',
|
||||
title: t('sideToolbar.outputExplorer'),
|
||||
tooltip: t('sideToolbar.outputExplorer'),
|
||||
component: markRaw(OutputExplorerSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -21,6 +22,7 @@ import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -278,6 +280,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
menubarLabel: 'Zoom to fit',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
@@ -303,6 +306,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ToggleLinkVisibility',
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Canvas Toggle Link Visibility',
|
||||
menubarLabel: 'Node Links',
|
||||
versionAdded: '1.3.6',
|
||||
|
||||
function: (() => {
|
||||
@@ -324,12 +328,15 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
})(),
|
||||
active: () =>
|
||||
useSettingStore().get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.ToggleMinimap',
|
||||
icon: 'pi pi-map',
|
||||
label: 'Canvas Toggle Minimap',
|
||||
menubarLabel: 'Minimap',
|
||||
versionAdded: '1.24.1',
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
@@ -337,7 +344,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
'Comfy.Minimap.Visible',
|
||||
!settingStore.get('Comfy.Minimap.Visible')
|
||||
)
|
||||
}
|
||||
},
|
||||
active: () => useSettingStore().get('Comfy.Minimap.Visible')
|
||||
},
|
||||
{
|
||||
id: 'Comfy.QueuePrompt',
|
||||
@@ -541,21 +549,25 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Workspace.ToggleBottomPanel',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
menubarLabel: 'Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
active: () => bottomPanelStore.bottomPanelVisible
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleFocusMode',
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
menubarLabel: 'Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
},
|
||||
active: () => useWorkspaceStore().focusMode
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.FitGroupToContents',
|
||||
@@ -815,6 +827,34 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.OpenManagerDialog',
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
label: 'Manager',
|
||||
function: () => {
|
||||
dialogService.showManagerDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleHelpCenter',
|
||||
icon: 'pi pi-question-circle',
|
||||
label: 'Help Center',
|
||||
function: () => {
|
||||
useHelpCenterStore().toggle()
|
||||
},
|
||||
active: () => useHelpCenterStore().isVisible
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleCanvasInfo',
|
||||
icon: 'pi pi-info-circle',
|
||||
label: 'Canvas Performance',
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const currentValue = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
await settingStore.set('Comfy.Graph.CanvasInfo', !currentValue)
|
||||
},
|
||||
active: () => useSettingStore().get('Comfy.Graph.CanvasInfo')
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
@@ -839,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()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useLitegraphSettings = () => {
|
||||
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.show_info = canvasInfoEnabled
|
||||
canvasStore.canvas.draw(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,819 +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 { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
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 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 = ref(app.canvas?.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)
|
||||
}
|
||||
|
||||
let originalCallbacks: 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
|
||||
|
||||
originalCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
|
||||
g.onNodeAdded = function (node) {
|
||||
originalCallbacks.onNodeAdded?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onNodeRemoved = function (node) {
|
||||
originalCallbacks.onNodeRemoved?.call(this, node)
|
||||
nodeStatesCache.delete(node.id)
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onConnectionChange = function (node) {
|
||||
originalCallbacks.onConnectionChange?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
if (originalCallbacks.onNodeAdded !== undefined) {
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
}
|
||||
if (originalCallbacks.onNodeRemoved !== undefined) {
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
}
|
||||
if (originalCallbacks.onConnectionChange !== undefined) {
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
}
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.value) return
|
||||
|
||||
visible.value = settingStore.get('Comfy.Minimap.Visible')
|
||||
|
||||
if (canvas.value && graph.value) {
|
||||
setupEventListeners()
|
||||
|
||||
api.addEventListener('graphChanged', handleGraphChanged)
|
||||
|
||||
if (containerRef.value) {
|
||||
updateContainerRect()
|
||||
}
|
||||
updateCanvasDimensions()
|
||||
|
||||
window.addEventListener('resize', updateContainerRect)
|
||||
window.addEventListener('scroll', updateContainerRect)
|
||||
window.addEventListener('resize', updateCanvasDimensions)
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
|
||||
if (visible.value) {
|
||||
resumeChangeDetection()
|
||||
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(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
|
||||
}
|
||||
}
|
||||
29
src/composables/useModelSelectorDialog.ts
Normal file
29
src/composables/useModelSelectorDialog.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.model-library'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.output-explorer'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 's',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export const CORE_MENU_COMMANDS = [
|
||||
[['Workflow'], ['Comfy.NewBlankWorkflow']],
|
||||
[['Workflow'], ['Comfy.OpenWorkflow', 'Comfy.BrowseTemplates']],
|
||||
[[], ['Comfy.NewBlankWorkflow']],
|
||||
[[], []], // Separator after New
|
||||
[['File'], ['Comfy.OpenWorkflow']],
|
||||
[
|
||||
['Workflow'],
|
||||
['File'],
|
||||
[
|
||||
'Comfy.SaveWorkflow',
|
||||
'Comfy.SaveWorkflowAs',
|
||||
@@ -11,8 +12,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[
|
||||
['Help'],
|
||||
|
||||
@@ -772,7 +772,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold',
|
||||
name: 'Low quality rendering zoom threshold',
|
||||
tooltip: 'Render low quality shapes when zoomed out',
|
||||
tooltip:
|
||||
'Zoom level threshold for performance mode. Lower values (0.1) = quality at all zoom levels. Higher values (1.0) = performance mode even when zoomed in. Performance mode simplifies rendering by hiding text labels, shadows, and details.',
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0.1,
|
||||
|
||||
@@ -3608,6 +3608,7 @@ export class LGraphCanvas
|
||||
subgraphs: []
|
||||
}
|
||||
|
||||
// NOTE: logic for traversing nested subgraphs depends on this being a set.
|
||||
const subgraphs = new Set<Subgraph>()
|
||||
|
||||
// Create serialisable objects
|
||||
@@ -3646,8 +3647,13 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
// Add unique subgraph entries
|
||||
// TODO: Must find all nested subgraphs
|
||||
// NOTE: subgraphs is appended to mid iteration.
|
||||
for (const subgraph of subgraphs) {
|
||||
for (const node of subgraph.nodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
subgraphs.add(node.subgraph)
|
||||
}
|
||||
}
|
||||
const cloned = subgraph.clone(true).asSerialisable()
|
||||
serialisable.subgraphs.push(cloned)
|
||||
}
|
||||
@@ -3764,12 +3770,19 @@ export class LGraphCanvas
|
||||
created.push(group)
|
||||
}
|
||||
|
||||
// Update subgraph ids with nesting
|
||||
function updateSubgraphIds(nodes: { type: string }[]) {
|
||||
for (const info of nodes) {
|
||||
const subgraph = results.subgraphs.get(info.type)
|
||||
if (!subgraph) continue
|
||||
info.type = subgraph.id
|
||||
updateSubgraphIds(subgraph.nodes)
|
||||
}
|
||||
}
|
||||
updateSubgraphIds(parsed.nodes)
|
||||
|
||||
// Nodes
|
||||
for (const info of parsed.nodes) {
|
||||
// If the subgraph was cloned, update references to use the new subgraph ID.
|
||||
const subgraph = results.subgraphs.get(info.type)
|
||||
if (subgraph) info.type = subgraph.id
|
||||
|
||||
const node = info.type == null ? null : LiteGraph.createNode(info.type)
|
||||
if (!node) {
|
||||
// failedNodes.push(info)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -414,6 +414,18 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
|
||||
*/
|
||||
disconnect(network: LinkNetwork, keepReroutes?: 'input' | 'output'): void {
|
||||
// Clean up the target node's input slot
|
||||
if (this.target_id !== -1) {
|
||||
const targetNode = network.getNodeById(this.target_id)
|
||||
if (targetNode) {
|
||||
const targetInput = targetNode.inputs?.[this.target_slot]
|
||||
if (targetInput && targetInput.link === this.id) {
|
||||
targetInput.link = null
|
||||
targetNode.setDirtyCanvas?.(true, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reroutes = LLink.getReroutes(network, this)
|
||||
|
||||
const lastReroute = reroutes.at(-1)
|
||||
|
||||
@@ -135,6 +135,10 @@ export class FloatingRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
|
||||
@@ -681,6 +681,20 @@ export class LinkConnector {
|
||||
let targetSlot = input
|
||||
|
||||
for (const link of renderLinks) {
|
||||
// Validate the connection type before proceeding
|
||||
if (
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
!link.canConnectToSubgraphInput(targetSlot)
|
||||
) {
|
||||
console.warn(
|
||||
'Invalid connection type',
|
||||
link.fromSlot.type,
|
||||
'->',
|
||||
targetSlot.type
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
link.connectToSubgraphInput(targetSlot, this.events)
|
||||
|
||||
// If we just connected to an EmptySubgraphInput, check if we should reuse the slot
|
||||
@@ -941,6 +955,14 @@ export class LinkConnector {
|
||||
)
|
||||
}
|
||||
|
||||
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
|
||||
return this.renderLinks.some(
|
||||
(link) =>
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
link.canConnectToSubgraphInput(input)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a reroute is a valid drop target for any of the links being connected.
|
||||
* @param reroute The reroute that would be dropped on.
|
||||
|
||||
@@ -55,6 +55,10 @@ export class MovingOutputLink extends MovingLinkBase {
|
||||
return reroute.origin_id !== this.outputNode.id
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(): never {
|
||||
throw new Error('MovingOutputLink cannot connect to an input.')
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export class ToOutputRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToOutput(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
|
||||
@@ -16,7 +16,10 @@ import type {
|
||||
GraphOrSubgraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
@@ -266,10 +269,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
if (!subgraphInput)
|
||||
throw new Error(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
|
||||
if (!subgraphInput) {
|
||||
// Skip inputs that don't exist in the subgraph definition
|
||||
// This can happen when loading workflows with dynamically added inputs
|
||||
console.warn(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
@@ -536,4 +543,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
const widget = this.widgets[i]
|
||||
const input = this.inputs.find((inp) => inp.name === widget.name)
|
||||
|
||||
if (input) {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
|
||||
if (subgraphInput) {
|
||||
// Find all widgets connected to this subgraph input
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
// Update the value of all connected widgets
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = widget.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call parent serialize method
|
||||
return super.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
@@ -14,4 +14,84 @@ describe('LLink', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should clear the target input link reference when disconnecting', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Connect the nodes
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
expect(link).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link?.id)
|
||||
|
||||
// Mock setDirtyCanvas
|
||||
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
|
||||
|
||||
// Disconnect the link
|
||||
link?.disconnect(graph)
|
||||
|
||||
// Verify the target input's link reference is cleared
|
||||
expect(targetNode.inputs[0].link).toBeNull()
|
||||
|
||||
// Verify setDirtyCanvas was called
|
||||
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
|
||||
})
|
||||
|
||||
it('should handle disconnecting when target node is not found', () => {
|
||||
// Create a link with invalid target
|
||||
const graph = new LGraph()
|
||||
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
|
||||
|
||||
// Should not throw when disconnecting
|
||||
expect(() => link.disconnect(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should only clear link reference if it matches the current link id', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode1 = new LGraphNode('Source1')
|
||||
const sourceNode2 = new LGraphNode('Source2')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode1)
|
||||
graph.add(sourceNode2)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode1.addOutput('out', 'number')
|
||||
sourceNode2.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Create first connection
|
||||
const link1 = sourceNode1.connect(0, targetNode, 0)
|
||||
expect(link1).toBeDefined()
|
||||
|
||||
// Disconnect first connection
|
||||
targetNode.disconnectInput(0)
|
||||
|
||||
// Create second connection
|
||||
const link2 = sourceNode2.connect(0, targetNode, 0)
|
||||
expect(link2).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
|
||||
// Try to disconnect the first link (which is already disconnected)
|
||||
// It should not affect the current connection
|
||||
link1?.disconnect(graph)
|
||||
|
||||
// The input should still have the second link
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { MovingOutputLink } from '@/lib/litegraph/src/canvas/MovingOutputLink'
|
||||
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
|
||||
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
|
||||
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
|
||||
|
||||
describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
let connector: LinkConnector
|
||||
const mockSetConnectingLinks = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new LinkConnector(mockSetConnectingLinks)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
|
||||
it('should validate type compatibility correctly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid link (number -> number)
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
|
||||
// Create invalid link (string -> number)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const numberInput = subgraph.inputs[0]
|
||||
|
||||
// Test validation
|
||||
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
|
||||
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle wildcard types', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'wildcard_input', type: '*' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
const wildcardInput = subgraph.inputs[0]
|
||||
|
||||
// Wildcard should accept any type
|
||||
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToOutputRenderLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
// Create a minimal valid setup
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.id = 1
|
||||
node.addInput('test_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const slot = node.inputs[0] as NodeInputSlot
|
||||
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropOnIoNode validation', () => {
|
||||
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create an invalid link (string output -> string input, but subgraph expects number)
|
||||
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Mock console.warn to verify it's called
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it's NOT called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the invalid connection was skipped
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Invalid connection type',
|
||||
'string',
|
||||
'->',
|
||||
'number'
|
||||
)
|
||||
expect(connectSpy).not.toHaveBeenCalled()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should allow valid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a valid link (number -> number)
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it IS called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the valid connection was made
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
subgraph.inputs[0],
|
||||
connector.events
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSubgraphInputValidDrop', () => {
|
||||
it('should check if render links can connect to SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid and invalid links
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Test with only invalid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
|
||||
// Test with valid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
|
||||
// Test with mixed links
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink, validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle render links without canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
// Create a mock render link without the method
|
||||
const mockLink = {
|
||||
fromSlot: { type: 'number' }
|
||||
// No canConnectToSubgraphInput method
|
||||
} as any
|
||||
|
||||
connector.renderLinks.push(mockLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Should return false as the link doesn't have the method
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "الاتصال بالدعم"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "إظهار منتقي النماذج (للمطورين)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "تكرار سير العمل الحالي"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Clipspace"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "مدير"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "فتح سير عمل"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "عرض نافذة الإعدادات"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "أداء اللوحة"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "مركز المساعدة"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "تبديل النمط (فاتح/داكن)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "تبديل الشريط الجانبي لمكتبة العقد",
|
||||
"tooltip": "مكتبة العقد"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "تبديل الشريط الجانبي لمستكشف النتائج",
|
||||
"tooltip": "مستكشف النتائج"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
|
||||
"tooltip": "قائمة الانتظار"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "Contact Support"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "Show Model Selector (Dev)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "Duplicate Current Workflow"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Clipspace"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "Manager"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Open Workflow"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "Show Settings Dialog"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "Canvas Performance"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "Help Center"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Toggle Theme (Dark/Light)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "Toggle Node Library Sidebar",
|
||||
"tooltip": "Node Library"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "Toggle Output Explorer Sidebar",
|
||||
"tooltip": "Output Explorer"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "Toggle Queue Sidebar",
|
||||
"tooltip": "Queue"
|
||||
|
||||
@@ -146,7 +146,16 @@
|
||||
"micPermissionDenied": "Microphone permission denied",
|
||||
"noAudioRecorded": "No audio recorded",
|
||||
"nodesRunning": "nodes running",
|
||||
"duplicate": "Duplicate"
|
||||
"duplicate": "Duplicate",
|
||||
"audio": "Audio",
|
||||
"folder": "Folder",
|
||||
"image": "Image",
|
||||
"itemsCount": "{0} Items",
|
||||
"modifyTime": "Modify Time",
|
||||
"searchIn": "Search in {0}",
|
||||
"size": "Size",
|
||||
"type": "Type",
|
||||
"video": "Video"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -497,7 +506,8 @@
|
||||
"bookmarks": "Bookmarks",
|
||||
"open": "Open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"outputExplorer": "Output Explorer"
|
||||
},
|
||||
"helpCenter": {
|
||||
"docs": "Docs",
|
||||
@@ -546,7 +556,8 @@
|
||||
"light": "Light",
|
||||
"manageExtensions": "Manage Extensions",
|
||||
"settings": "Settings",
|
||||
"help": "Help"
|
||||
"help": "Help",
|
||||
"queue": "Queue Panel"
|
||||
},
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicate Tab",
|
||||
@@ -940,7 +951,7 @@
|
||||
"Image Layer": "Image Layer"
|
||||
},
|
||||
"menuLabels": {
|
||||
"Workflow": "Workflow",
|
||||
"File": "File",
|
||||
"Edit": "Edit",
|
||||
"Help": "Help",
|
||||
"Check for Updates": "Check for Updates",
|
||||
@@ -959,16 +970,16 @@
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Add Edit Model Step": "Add Edit Model Step",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Fit view to selected nodes": "Fit view to selected nodes",
|
||||
"Zoom to fit": "Zoom to fit",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
"Move Selected Nodes Left": "Move Selected Nodes Left",
|
||||
"Move Selected Nodes Right": "Move Selected Nodes Right",
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
|
||||
"Node Links": "Node Links",
|
||||
"Canvas Toggle Lock": "Canvas Toggle Lock",
|
||||
"Canvas Toggle Minimap": "Canvas Toggle Minimap",
|
||||
"Minimap": "Minimap",
|
||||
"Pin/Unpin Selected Items": "Pin/Unpin Selected Items",
|
||||
"Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes",
|
||||
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",
|
||||
@@ -979,6 +990,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)",
|
||||
@@ -1005,6 +1017,7 @@
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"New": "New",
|
||||
"Clipspace": "Clipspace",
|
||||
"Manager": "Manager",
|
||||
"Open": "Open",
|
||||
"Queue Prompt": "Queue Prompt",
|
||||
"Queue Prompt (Front)": "Queue Prompt (Front)",
|
||||
@@ -1014,6 +1027,8 @@
|
||||
"Save": "Save",
|
||||
"Save As": "Save As",
|
||||
"Show Settings Dialog": "Show Settings Dialog",
|
||||
"Canvas Performance": "Canvas Performance",
|
||||
"Help Center": "Help Center",
|
||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
@@ -1022,17 +1037,18 @@
|
||||
"Next Opened Workflow": "Next Opened Workflow",
|
||||
"Previous Opened Workflow": "Previous Opened Workflow",
|
||||
"Toggle Search Box": "Toggle Search Box",
|
||||
"Toggle Bottom Panel": "Toggle Bottom Panel",
|
||||
"Bottom Panel": "Bottom Panel",
|
||||
"Show Keybindings Dialog": "Show Keybindings Dialog",
|
||||
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
|
||||
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
|
||||
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
|
||||
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
|
||||
"Toggle Focus Mode": "Toggle Focus Mode",
|
||||
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
|
||||
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
|
||||
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
|
||||
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar"
|
||||
"Focus Mode": "Focus Mode",
|
||||
"Model Library": "Model Library",
|
||||
"Node Library": "Node Library",
|
||||
"Output Explorer": "Output Explorer",
|
||||
"Queue Panel": "Queue Panel",
|
||||
"Workflows": "Workflows"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"reinstall": "Reinstall",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
|
||||
"name": "Low quality rendering zoom threshold",
|
||||
"tooltip": "Render low quality shapes when zoomed out"
|
||||
"tooltip": "Zoom level threshold for performance mode. Lower values (0.1) = quality at all zoom levels. Higher values (1.0) = performance mode even when zoomed in. Performance mode simplifies rendering by hiding text labels, shadows, and details."
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "Maximum FPS",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Abrir espacio de clips"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "Administrador"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Abrir Flujo de Trabajo"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "Mostrar Diálogo de Configuraciones"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "Rendimiento del lienzo"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "Centro de ayuda"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Cambiar Tema (Oscuro/Claro)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "Alternar Barra Lateral de Biblioteca de Nodos",
|
||||
"tooltip": "Biblioteca de Nodos"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "Alternar barra lateral del Explorador de Salidas",
|
||||
"tooltip": "Explorador de Salidas"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "Alternar Barra Lateral de Cola",
|
||||
"tooltip": "Cola"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Espace de clip"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "Gestionnaire"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Ouvrir le flux de travail"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "Afficher la boîte de dialogue des paramètres"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "Performance du canvas"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "Centre d'aide"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Changer de thème (Sombre/Clair)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "Basculer la barre latérale de la bibliothèque de nœuds",
|
||||
"tooltip": "Bibliothèque de nœuds"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "Basculer la barre latérale de l’Explorateur de sortie",
|
||||
"tooltip": "Explorateur de sortie"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "Basculer la barre latérale de la file d'attente",
|
||||
"tooltip": "File d'attente"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "サポートに連絡"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "モデルセレクターを表示(開発用)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "現在のワークフローを複製"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "クリップスペース"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "マネージャー"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "ワークフローを開く"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "設定ダイアログを表示"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "キャンバスパフォーマンス"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "ヘルプセンター"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "テーマの切り替え(ダーク/ライト)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "ノードライブラリサイドバーの切り替え",
|
||||
"tooltip": "ノードライブラリ"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "出力エクスプローラーサイドバーを切り替え",
|
||||
"tooltip": "出力エクスプローラー"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "キューサイドバーの切り替え",
|
||||
"tooltip": "キュー"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "지원팀에 문의하기"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "모델 선택기 표시 (개발자용)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "현재 워크플로 복제"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "클립스페이스"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "매니저"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "워크플로 열기"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "설정 대화상자 보기"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "캔버스 성능"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "도움말 센터"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "밝기 테마 전환 (어두운/밝은)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "노드 라이브러리 사이드바 토글",
|
||||
"tooltip": "노드 라이브러리"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "출력 탐색기 사이드바 전환",
|
||||
"tooltip": "출력 탐색기"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "실행 큐 사이드바 토글",
|
||||
"tooltip": "실행 큐"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "Связаться с поддержкой"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "Показать выбор модели (Dev)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "Дублировать текущий рабочий процесс"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Клипспейс"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "Менеджер"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Открыть рабочий процесс"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "Показать диалог настроек"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "Производительность холста"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "Центр поддержки"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Переключить тему (Тёмная/Светлая)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "Переключить боковую панель библиотеки нод",
|
||||
"tooltip": "Библиотека нод"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "Переключить боковую панель проводника вывода",
|
||||
"tooltip": "Проводник вывода"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "Переключить боковую панель очереди",
|
||||
"tooltip": "Очередь"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "聯絡支援"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "顯示模型選擇器(開發)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "複製目前工作流程"
|
||||
},
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Clipspace"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "管理器"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "開啟工作流程"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "顯示設定對話框"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "畫布效能"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "說明中心"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "切換主題(深色/淺色)"
|
||||
},
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "切換節點庫側邊欄",
|
||||
"tooltip": "節點庫"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "切換輸出總覽側邊欄",
|
||||
"tooltip": "輸出總覽"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "切換佇列側邊欄",
|
||||
"tooltip": "佇列"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
"label": "重启"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "為所選節點開啟 3D 檢視器(Beta 版)"
|
||||
"label": "为所选节点开启 3D 浏览器(Beta 版)"
|
||||
},
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "浏览模板"
|
||||
@@ -75,7 +75,7 @@
|
||||
"label": "锁定视图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleMinimap": {
|
||||
"label": "畫布切換小地圖"
|
||||
"label": "画布切换小地图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
||||
"label": "忽略/取消忽略选中节点"
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "联系支持"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "顯示模型選擇器(開發)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "复制当前工作流"
|
||||
},
|
||||
@@ -123,7 +126,7 @@
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "退出子圖"
|
||||
"label": "退出子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
@@ -132,7 +135,7 @@
|
||||
"label": "添加框到选中节点"
|
||||
},
|
||||
"Comfy_Graph_UnpackSubgraph": {
|
||||
"label": "解開所選子圖"
|
||||
"label": "解开所选子图"
|
||||
},
|
||||
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
|
||||
"label": "将选中节点转换为组节点"
|
||||
@@ -171,10 +174,10 @@
|
||||
"label": "切换进度对话框"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "減小 MaskEditor 中的筆刷大小"
|
||||
"label": "减小 MaskEditor 中的笔刷大小"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "增加 MaskEditor 畫筆大小"
|
||||
"label": "增加 MaskEditor 画笔大小"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "打开选中节点的遮罩编辑器"
|
||||
@@ -185,6 +188,9 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "打开剪贴板"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "管理器"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "打开工作流"
|
||||
},
|
||||
@@ -212,6 +218,12 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "显示设置对话框"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "画布性能"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "说明中心"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "切换主题"
|
||||
},
|
||||
@@ -246,13 +258,13 @@
|
||||
"label": "切换日志底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "切換基本下方面板"
|
||||
"label": "切换基础底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "切換檢視控制底部面板"
|
||||
"label": "切换视图控制底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "顯示快捷鍵對話框"
|
||||
"label": "显示快捷键对话框"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "切换焦点模式"
|
||||
@@ -265,6 +277,10 @@
|
||||
"label": "切换节点库侧边栏",
|
||||
"tooltip": "节点库"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_output-explorer": {
|
||||
"label": "切换输出资源管理器侧边栏",
|
||||
"tooltip": "输出资源管理器"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_queue": {
|
||||
"label": "切换执行队列侧边栏",
|
||||
"tooltip": "执行队列"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,10 @@
|
||||
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。"
|
||||
},
|
||||
"Comfy_Canvas_NavigationMode": {
|
||||
"name": "畫布導航模式",
|
||||
"name": "画布导航模式",
|
||||
"options": {
|
||||
"Left-Click Pan (Legacy)": "左鍵拖曳(舊版)",
|
||||
"Standard (New)": "標準(新)"
|
||||
"Left-Click Pan (Legacy)": "左键拖曳(旧版)",
|
||||
"Standard (New)": "标准(新)"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
@@ -120,8 +120,8 @@
|
||||
}
|
||||
},
|
||||
"Comfy_Load3D_3DViewerEnable": {
|
||||
"name": "啟用 3D 檢視器(測試版)",
|
||||
"tooltip": "為所選節點啟用 3D 檢視器(測試版)。此功能可讓您直接在全尺寸 3D 檢視器中瀏覽並互動 3D 模型。"
|
||||
"name": "启用3D查看器(测试版)",
|
||||
"tooltip": "为选定节点启用3D查看器(测试版)。此功能允许你在全尺寸3D查看器中直接可视化和交互3D模型。"
|
||||
},
|
||||
"Comfy_Load3D_BackgroundColor": {
|
||||
"name": "初始背景颜色",
|
||||
@@ -338,7 +338,7 @@
|
||||
"Disabled": "禁用",
|
||||
"Top": "顶部"
|
||||
},
|
||||
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
|
||||
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。"
|
||||
},
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "校验工作流"
|
||||
|
||||
100
src/renderer/core/spatial/boundsCalculator.ts
Normal file
100
src/renderer/core/spatial/boundsCalculator.ts
Normal 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
|
||||
}
|
||||
251
src/renderer/extensions/minimap/composables/useMinimap.ts
Normal file
251
src/renderer/extensions/minimap/composables/useMinimap.ts
Normal 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
|
||||
}
|
||||
}
|
||||
166
src/renderer/extensions/minimap/composables/useMinimapGraph.ts
Normal file
166
src/renderer/extensions/minimap/composables/useMinimapGraph.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
238
src/renderer/extensions/minimap/minimapCanvasRenderer.ts
Normal file
238
src/renderer/extensions/minimap/minimapCanvasRenderer.ts
Normal 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)
|
||||
}
|
||||
68
src/renderer/extensions/minimap/types.ts
Normal file
68
src/renderer/extensions/minimap/types.ts
Normal 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'
|
||||
@@ -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)
|
||||
64
src/renderer/thumbnail/graphThumbnailRenderer.ts
Normal file
64
src/renderer/thumbnail/graphThumbnailRenderer.ts
Normal 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
|
||||
}
|
||||
@@ -699,6 +699,19 @@ export class ComfyApi extends EventTarget {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of output folder items (eg ['output', 'output/images', 'output/videos', ...])
|
||||
* @param {string} folder The folder to list items from, such as 'output'
|
||||
* @returns The list of output folder items within the specified folder
|
||||
*/
|
||||
async getOutputFolderItems(folder: string) {
|
||||
const res = await this.fetchApi(`/output${folder}`)
|
||||
if (res.status === 404) {
|
||||
return []
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the metadata for a model
|
||||
* @param {string} folder The folder containing the model
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
@@ -63,6 +64,7 @@ export const useLitegraphService = () => {
|
||||
const toastStore = useToastStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
function registerSubgraphNodeDef(
|
||||
@@ -762,15 +764,8 @@ export const useLitegraphService = () => {
|
||||
options.push({
|
||||
content: 'Bypass',
|
||||
callback: () => {
|
||||
const mode =
|
||||
this.mode === LGraphEventMode.BYPASS
|
||||
? LGraphEventMode.ALWAYS
|
||||
: LGraphEventMode.BYPASS
|
||||
for (const item of app.canvas.selectedItems) {
|
||||
if (item instanceof LGraphNode) item.mode = mode
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.graph.change()
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
||||
source?: string
|
||||
active?: () => boolean // Getter to check if the command is active/toggled on
|
||||
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string
|
||||
source?: string
|
||||
active?: () => boolean
|
||||
category?: 'essentials' | 'view-controls'
|
||||
|
||||
constructor(command: ComfyCommand) {
|
||||
@@ -42,6 +44,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
this.versionAdded = command.versionAdded
|
||||
this.confirmation = command.confirmation
|
||||
this.source = command.source
|
||||
this.active = command.active
|
||||
this.category = command.category
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
src/stores/helpCenterStore.ts
Normal file
25
src/stores/helpCenterStore.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useHelpCenterStore = defineStore('helpCenter', () => {
|
||||
const isVisible = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
isVisible.value = !isVisible.value
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
isVisible.value = true
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
toggle,
|
||||
show,
|
||||
hide
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { useCommandStore } from './commandStore'
|
||||
export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
const commandStore = useCommandStore()
|
||||
const menuItems = ref<MenuItem[]>([])
|
||||
const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({})
|
||||
|
||||
const registerMenuGroup = (path: string[], items: MenuItem[]) => {
|
||||
let currentLevel = menuItems.value
|
||||
@@ -45,6 +46,14 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
}
|
||||
// Add the new items to the last level
|
||||
currentLevel.push(...items)
|
||||
|
||||
// Store if any of the children have active state as we will hide the icon if they do
|
||||
const parentPath = path.join('.')
|
||||
if (!menuItemHasActiveStateChildren.value[parentPath]) {
|
||||
menuItemHasActiveStateChildren.value[parentPath] = items.some(
|
||||
(item) => item.comfyCommand?.active
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const registerCommands = (path: string[], commandIds: string[]) => {
|
||||
@@ -57,7 +66,8 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
label: command.menubarLabel,
|
||||
icon: command.icon,
|
||||
tooltip: command.tooltip,
|
||||
comfyCommand: command
|
||||
comfyCommand: command,
|
||||
parentPath: path.join('.')
|
||||
}) as MenuItem
|
||||
)
|
||||
registerMenuGroup(path, items)
|
||||
@@ -92,6 +102,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
registerMenuGroup,
|
||||
registerCommands,
|
||||
loadExtensionMenuCommands,
|
||||
registerCoreMenuCommands
|
||||
registerCoreMenuCommands,
|
||||
menuItemHasActiveStateChildren
|
||||
}
|
||||
})
|
||||
|
||||
@@ -226,6 +226,14 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fetching if API nodes are disabled via argv
|
||||
if (
|
||||
systemStatsStore.systemStats?.system?.argv?.includes(
|
||||
'--disable-api-nodes'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useOutputExplorerSidebarTab } from '@/composables/sidebarTabs/outputExplorerSidebarTab'
|
||||
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||
import { useWorkflowsSidebarTab } from '@/composables/sidebarTabs/useWorkflowsSidebarTab'
|
||||
import { t, te } from '@/i18n'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
@@ -38,16 +40,34 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
: String(tab.tooltip)
|
||||
: undefined
|
||||
|
||||
const menubarLabelFunction = () => {
|
||||
const menubarLabelKeys: Record<string, string> = {
|
||||
queue: 'menu.queue',
|
||||
'node-library': 'sideToolbar.nodeLibrary',
|
||||
'model-library': 'sideToolbar.modelLibrary',
|
||||
workflows: 'sideToolbar.workflows'
|
||||
}
|
||||
|
||||
const key = menubarLabelKeys[tab.id]
|
||||
if (key && te(key)) {
|
||||
return t(key)
|
||||
}
|
||||
|
||||
return tab.title
|
||||
}
|
||||
|
||||
useCommandStore().registerCommand({
|
||||
id: `Workspace.ToggleSidebarTab.${tab.id}`,
|
||||
icon: typeof tab.icon === 'string' ? tab.icon : undefined,
|
||||
label: labelFunction,
|
||||
menubarLabel: menubarLabelFunction,
|
||||
tooltip: tooltipFunction,
|
||||
versionAdded: '1.3.9',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
toggleSidebarTab(tab.id)
|
||||
},
|
||||
active: () => activeSidebarTab.value?.id === tab.id,
|
||||
source: 'System'
|
||||
})
|
||||
}
|
||||
@@ -73,6 +93,26 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
registerSidebarTab(useNodeLibrarySidebarTab())
|
||||
registerSidebarTab(useModelLibrarySidebarTab())
|
||||
registerSidebarTab(useWorkflowsSidebarTab())
|
||||
registerSidebarTab(useOutputExplorerSidebarTab())
|
||||
|
||||
const menuStore = useMenuItemStore()
|
||||
|
||||
menuStore.registerCommands(
|
||||
['View'],
|
||||
[
|
||||
'Workspace.ToggleBottomPanel',
|
||||
'Comfy.BrowseTemplates',
|
||||
'Workspace.ToggleFocusMode',
|
||||
'Comfy.ToggleCanvasInfo',
|
||||
'Comfy.Canvas.ToggleMinimap',
|
||||
'Comfy.Canvas.ToggleLinkVisibility'
|
||||
]
|
||||
)
|
||||
|
||||
menuStore.registerCommands(
|
||||
['View'],
|
||||
['Comfy.Canvas.ZoomIn', 'Comfy.Canvas.ZoomOut', 'Comfy.Canvas.FitView']
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
2
src/types/custom_components/index.d.ts
vendored
Normal file
2
src/types/custom_components/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './navTypes'
|
||||
export * from './widgetTypes'
|
||||
9
src/types/custom_components/navTypes.ts
Normal file
9
src/types/custom_components/navTypes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface NavItemData {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface NavGroupData {
|
||||
title: string
|
||||
items: NavItemData[]
|
||||
}
|
||||
3
src/types/custom_components/widgetTypes.ts
Normal file
3
src/types/custom_components/widgetTypes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { InjectionKey } from 'vue'
|
||||
|
||||
export const OnCloseKey: InjectionKey<() => void> = Symbol()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@ export const whileMouseDown = (
|
||||
callback(iteration++)
|
||||
}, interval)
|
||||
|
||||
const dispose = useEventListener(element, 'mouseup', () => {
|
||||
const dispose = () => {
|
||||
clearInterval(intervalId)
|
||||
dispose()
|
||||
})
|
||||
disposeGlobal()
|
||||
disposeLocal()
|
||||
}
|
||||
|
||||
// Listen for mouseup globally to catch cases where user drags out of element
|
||||
const disposeGlobal = useEventListener(document, 'mouseup', dispose)
|
||||
const disposeLocal = useEventListener(element, 'mouseup', dispose)
|
||||
|
||||
return {
|
||||
dispose
|
||||
dispose: dispose
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user