Compare commits

..

39 Commits

Author SHA1 Message Date
bymyself
a84b06a34a refactor: use hasCategory for model cache invalidation
- Add hasCategory() to assetsStore for checking if a category exists in cache
- Replace modelToNodeStore dependency with direct cache lookup in deleteAssets
- Fix bug in removeAssetFromCache: modelStateByKey -> modelStateByCategory
- Add comprehensive tests for hasCategory behavior

This simplifies the cache invalidation logic by keeping it entirely within
the assets domain, removing unnecessary coupling to modelToNodeStore.

Amp-Thread-ID: https://ampcode.com/threads/T-019c0c9f-e47b-7715-945a-4a77c9460029
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:53:57 -08:00
Subagent 5
6761964788 fix: invalidate loader node dropdown cache after model asset deletion
When deleting a model asset (checkpoint, lora, etc.), the loader node
dropdowns now update correctly by invalidating the category-keyed cache.

- After deletion, check asset tags for model categories (checkpoints, loras, etc.)
- Call invalidateCategory() for each affected category
- Add tests for deletion invalidation behavior

Fixes the bug where deleted models still appeared in loader node dropdowns.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c08a2-6a9f-770f-994c-ad79d515f6a1
2026-01-29 17:37:55 -08:00
Subagent 5
94928c4dd3 fix: add guard for concurrent updateModelsForCategory calls
Short-circuit concurrent calls for the same category to prevent redundant
parallel work. The pendingRequestByCategory map now tracks all in-progress
requests (not just those with existing data), allowing subsequent calls to
return immediately instead of starting duplicate fetches.

Amp-Thread-ID: https://ampcode.com/threads/T-019c0bac-2144-73cb-a8d5-45146d0c2db9
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:36:58 -08:00
Subagent 5
e10a127c93 fix: address CodeRabbit review comments
- Add early return in updateAssetInCache when cacheKey resolves to undefined
- Fix test to use mapped node type to properly validate array cache behavior

Amp-Thread-ID: https://ampcode.com/threads/T-019c0bac-2144-73cb-a8d5-45146d0c2db9
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:36:57 -08:00
GitHub Action
0310dd6ec3 [automated] Apply ESLint and Oxfmt fixes 2026-01-29 17:36:57 -08:00
Subagent 5
89250846f4 refactor: change asset cache from nodeType-keyed to category-keyed
- Rename modelStateByKey to modelStateByCategory
- Add resolveCategory() to translate nodeType -> category for cache lookup
- Multiple node types sharing the same category now share one cache entry
- Add invalidateCategory() method for cache invalidation
- Maintain backwards-compatible public API accepting nodeType
- Update tests for new category-keyed behavior

This architectural change enables simple cache invalidation by category
(e.g., 'checkpoints', 'loras') which will be used for deletion invalidation.

Amp-Thread-ID: https://ampcode.com/threads/T-019c08a2-6a9f-770f-994c-ad79d515f6a1
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:36:57 -08:00
Alexander Brown
68b23f8fe3 test: add Playwright test tags for filtering (@smoke, @slow, @screenshot, domains) (#8441)
## Summary

Adds structured test tags to all 54 Playwright test files to enable
flexible test filtering during development and CI.

## Tags Added

| Tag | Count | Purpose |
|-----|-------|---------|
| `@screenshot` | 32 files | Tests with visual assertions
(`toHaveScreenshot`) |
| `@smoke` | 5 files | Quick essential tests for fast validation |
| `@slow` | 5 files | Long-running tests (templates, subgraph,
featureFlags) |
| `@canvas` | 15 files | Canvas/graph rendering tests |
| `@node` | 10 files | Node behavior tests |
| `@ui` | 8 files | UI component tests |
| `@widget` | 5 files | Widget-specific tests |
| `@workflow` | 3 files | Workflow operations |
| `@subgraph` | 1 file | Subgraph functionality |
| `@keyboard` | 2 files | Keyboard shortcuts |
| `@settings` | 2 files | Settings/preferences |

## Usage Examples

```bash
# Quick validation (~16 tests, ~30s)
pnpm test:browser -- --grep @smoke

# Skip slow tests for faster CI feedback
pnpm test:browser -- --grep-invert @slow

# Skip visual tests (useful for local development without snapshots)
pnpm test:browser -- --grep-invert @screenshot

# Run only canvas-related tests
pnpm test:browser -- --grep @canvas

# Combine filters
pnpm test:browser -- --grep @smoke --grep-invert @screenshot
```

## Implementation Details

- Uses Playwright's native tag syntax: `test.describe('Name', { tag:
'@tag' }, ...)`
- Tags inherit from describe blocks to child tests
- Preserves existing project-level tags: `@mobile`, `@2x`, `@0.5x`
- Multiple tags supported: `{ tag: ['@screenshot', '@smoke'] }`

## Test Plan

- [x] All existing tests pass unchanged
- [x] Tag filtering works with `--grep` and `--grep-invert`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8441-test-add-Playwright-test-tags-for-filtering-smoke-slow-screenshot-domains-2f76d73d36508184990ec859c8fd7629)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 17:36:57 -08:00
Christian Byrne
b73d010f1d feat: add Chatterbox model support for Cloud asset browser (#8418)
## Summary

Adds support for creating Chatterbox TTS nodes when clicking Chatterbox
models in the Cloud asset browser.

## Changes

### modelToNodeStore.ts
- Add `findProvidersWithFallback()` helper for hierarchical model type
lookups (e.g., `parent/child` falls back to `parent`)
- Register 4 Chatterbox model directories with empty widget keys:
  - `chatterbox/chatterbox` → `FL_ChatterboxTTS`
  - `chatterbox/chatterbox_turbo` → `FL_ChatterboxTurboTTS`
- `chatterbox/chatterbox_multilingual` → `FL_ChatterboxMultilingualTTS`
  - `chatterbox/chatterbox_vc` → `FL_ChatterboxVC`

### createModelNodeFromAsset.ts
- Skip widget assignment when `provider.key` is empty (for nodes that
auto-load models without a widget selector)

### Tests
- Add tests for hierarchical fallback behavior
- Add tests for empty widget key (auto-load nodes)
- Add Chatterbox node types to mock data

## Notes

- Empty `key` convention: Chatterbox nodes auto-load their models and
don't have a model selector widget, so we register them with `key: ''`
and skip the widget assignment step
- Hierarchical fallback only goes one level deep (`a/b/c` → `a`, not
`a/b`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8418-feat-add-Chatterbox-model-support-for-Cloud-asset-browser-2f76d73d365081be822bc369b155f099)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 17:36:57 -08:00
AustinMroz
3b93e3103d Fix invalid keybind flash (#8435)
Previously the save keybind action would
- apply the new keybind
- wait for a network request to persist the change
- close the dialogue regardless of the results of the above changes

During this network request, the dialog would show a warning that the
keybind is invalid because the dialogue "contains a modified keybind
which conflicts with an existing keybind"
<img width="506" height="261" alt="image"
src="https://github.com/user-attachments/assets/e46150ce-9349-4f8e-b3b5-fb0b20dd3db9"
/>


This PR changes the order these actions are applied in.
- The dialogue is immediately closed
- The keybinding is updated if valid
- The keybinding is persisted.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8435-Fix-invalid-keybind-flash-2f76d73d3650815c9657f35e77d331fe)
by [Unito](https://www.unito.io)
2026-01-29 17:36:57 -08:00
AustinMroz
58888d6194 Fix paste-with-links breaking autogrow connections (#8442)
The extra `linf` check was made in 878c8c0f39. I'm no longer able to
replicate the cloning bug the check was introduced for.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8442-Fix-paste-with-links-breaking-autogrow-connections-2f76d73d3650817aa99cc3b9e4e6412c)
by [Unito](https://www.unito.io)
2026-01-29 17:36:57 -08:00
Alexander Brown
42c0631bb6 Fix: Hide Jobs in Assets Panel when Queue V2 is disabled. (#8450)
## Summary

See Title.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8450-Fix-Hide-Jobs-in-Assets-Panel-when-Queue-V2-is-disabled-2f86d73d3650810c8155c1fea92fc0aa)
by [Unito](https://www.unito.io)
2026-01-29 17:36:57 -08:00
Jin Yi
2dcc6b755f [bugfix] Fix shift+click deselection in asset panel (#8396)
## Summary
Fix shift+click range selection not properly deselecting assets when
selecting a smaller range, and improve selection performance.

## Changes
- **Bug Fix**: Shift+click now replaces selection with the new range
instead of combining with existing selection
- **Performance**: Remove unnecessary `.every()` check in `setSelection`
(O(n) → O(1))
- **Tests**: Add 23 unit tests for asset selection logic

## Test Plan
- [x] Click 1st asset → only 1st selected
- [x] Shift+click 3rd asset → items 1-3 selected
- [x] Shift+click 1st asset → only 1st selected (was broken, now fixed)
- [x] All 23 new unit tests pass

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8396-bugfix-Fix-shift-click-deselection-in-asset-panel-2f76d73d3650814ca060d1e6a40cf6d4)
by [Unito](https://www.unito.io)
2026-01-29 17:36:57 -08:00
Christian Byrne
ba91194bcf fix: garbage collect subgraph definitions when SubgraphNode is removed (#8187)
## Summary

When removing a SubgraphNode via `LGraph.remove()`:
- Fire `onRemoved` for all nodes inside the subgraph
- Fire `onNodeRemoved` callback on the subgraph for each inner node
- Remove subgraph definition from `rootGraph.subgraphs` when no other
nodes reference it (checks both root graph nodes and nodes inside other
subgraphs)

This ensures proper cleanup of subgraph definitions and lifecycle
callbacks for nested nodes when subgraph nodes are deleted.

## Changes

### LGraph.ts
Added SubgraphNode-specific cleanup in `remove()` method that:
1. Iterates inner nodes and fires their `onRemoved` callbacks
2. Fires `onNodeRemoved` on the subgraph for downstream listeners (e.g.,
minimap)
3. Garbage collects the subgraph definition when no other nodes
reference it

### SubgraphNode.ts
Fixed `graph` property to match `LGraphNode` lifecycle contract.
Previously it was declared as `override readonly graph` via constructor
parameter promotion, which prevented `LGraph.remove()` from setting
`node.graph = null`. Changed to a regular mutable property with null
guard in `rootGraph` getter.

### LGraph.test.ts
Added 4 tests:
- `removing SubgraphNode fires onRemoved for inner nodes`
- `removing SubgraphNode fires onNodeRemoved callback`
- `subgraph definition is removed when last referencing node is removed`
- `subgraph definition is retained when other nodes still reference it`

## Related

- Fixes #8145
- Part of the subgraph lifecycle cleanup plan (Slice 2: Definition
garbage collection)
2026-01-29 17:36:57 -08:00
Jin Yi
e9158361cf [feat] Show context-appropriate empty state messages in Manager tabs (#8415)
## Summary
Shows tab-specific empty state messages in Node Manager instead of
generic "No search results found" message.

## Changes
- Added computed properties to determine empty state messages based on
current tab and search state
- Display tab-specific messages when a tab is empty without active
search (e.g., "No Missing Nodes" for Missing tab)
- Fall back to search-related messages only when there's an active
search query
- Added Korean translations for empty state messages

| Tab | Empty State Title |
|-----|-------------------|
| All Installed | No Extensions Installed |
| Update Available | All Up to Date |
| Conflicting | No Conflicts Detected |
| Workflow | No Extensions in Workflow |
| Missing | No Missing Nodes |

## Review Focus
- Verify i18n key structure matches existing patterns

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8415-feat-Show-context-appropriate-empty-state-messages-in-Manager-tabs-2f76d73d3650817ab8a0d41b45df3411)
by [Unito](https://www.unito.io)
2026-01-29 17:36:57 -08:00
Comfy Org PR Bot
40143c6a76 1.39.2 (#8447)
Patch version increment to 1.39.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8447-1-39-2-2f86d73d3650819e8daccc9c5fbc3a3b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-01-29 17:36:57 -08:00
Christian Byrne
3b4378391e refactor: change asset cache from nodeType-keyed to category-keyed (#8433)
Refactors the model assets cache in `assetsStore.ts` to be keyed by
category (e.g., 'checkpoints', 'loras') instead of nodeType (e.g.,
'CheckpointLoaderSimple').

- Rename `modelStateByKey` to `modelStateByCategory`
- Add `resolveCategory()` helper to translate nodeType to category for
cache lookup
- Multiple node types sharing the same category now share one cache
entry
- Add `invalidateCategory()` method for cache invalidation
- Maintain backwards-compatible public API accepting nodeType
- Update tests for new category-keyed behavior

1. **Deduplication**: Same category = same cache entry = single API call
2. **Simple invalidation**: Delete asset with tag 'checkpoints' then
invalidate cache
3. **Cleaner mental model**: Store to View reactive flow works naturally

- All existing tests pass with updates
- Added new tests for category-keyed cache sharing, invalidateCategory,
and unknown node type handling

This is **PR 1 of 2** in a stacked PR series:
1. **This PR**: Refactor asset cache to category-keyed (architectural
improvement)
2. **[PR 2
deletion invalidation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8433-refactor-change-asset-cache-from-nodeType-keyed-to-category-keyed-2f76d73d365081999b7fda12c9706ab5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 17:36:57 -08:00
AustinMroz
7923ca052c Fix Help Center display in linear mode (#8438)
The Help Center popover wasn't working in linear mode because the
`#graph-canvas-container` is hidden. This is fixed by instead setting
the target to body. This matches the [default behaviour used by portals
in
rekai-ui](https://github.com/unovue/reka-ui/blob/v2/packages/core/src/Teleport/Teleport.vue)
<img width="476" height="677" alt="image"
src="https://github.com/user-attachments/assets/cca46648-3bce-4b72-a5be-3727e2358217"
/>

I've got a working branch for moving the Help Center to our reka-ui
Popover component, but that'll require a much larger surface area of
code changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8438-Fix-Help-Center-display-in-linear-mode-2f76d73d36508112abedf1160f7c3a90)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Christian Byrne
86c0d87c61 make new queue panel disabled by default (#8444)
## Summary

Was enabled by default for 1.38 during nightly, but should be reverted
back now.

In the future we can now just use `isNightly` flag for this (ref:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8149)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8444-make-new-queue-panel-disabled-by-default-2f76d73d365081139b54f76d9325101d)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Rizumu Ayaka
0f8547725d fix: default image input for the template is displayed as empty on dropdown selection (#8276)
The image input for nodes loaded from templates appears empty in the
Properties Panel.

When the widget's current value (saved in the template) is not in the
available file list returned by the server, the selectedSet is empty,
causing a placeholder to be displayed instead of the actual value.

Added a missingValueItem computed property in WidgetSelectDropdown.vue.
When the current value is not in inputItems or outputItems, it creates a
fallback item and adds it to dropdownItems.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8276-fix-default-image-input-for-the-template-is-displayed-as-empty-on-dropdown-selection-2f16d73d3650817eaad5e4e33637fb74)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 17:35:27 -08:00
Christian Byrne
9869ad9a92 feat: increase allowed batch count (on Run button) on cloud (from 4 to 32) (#8436)
## Summary

cloud.comfy.org now suports up to 100 queued jobs at a time
([details](https://x.com/ComfyUI/status/2016622139722572032?s=20)). We
can increase the batch count limit to 32. Possible downside is cloud
having to reject larger number of jobs over the 100 limit if someone go
to 32 and clicks 4+ times. This setting was configurable anyway before,
so this is mostly a QoL change.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8436-feat-increase-allowed-batch-count-on-Run-button-on-cloud-from-4-to-32-2f76d73d365081728650fabefc394046)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Christian Byrne
97deddb51f fix: use merge-multiple for snapshot artifact download (#8432)
## Summary

Fixes the snapshot merge failure introduced by PR #8377
(actions/download-artifact v4→v7 upgrade).

## Root Cause

The v5+ release of `download-artifact` changed behavior: when a
`pattern` matches only a **single artifact**, files are extracted
directly to `path/` without the artifact name subdirectory. When only
one shard had changes, the merge loop couldn't find the expected
`snapshots-shard-*/` directories.

## Fix

Use `merge-multiple: true` — the documented pattern for combining
sharded artifacts. This merges all matched artifacts directly into the
target path, eliminating directory structure assumptions.

## Testing

This fix can be validated by re-running the workflow on [PR
#8276](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8276) after
merge.

---
- Fixes snapshot update workflow regression from #8377

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8432-fix-use-merge-multiple-for-snapshot-artifact-download-2f76d73d3650810b97fdfe28cd3c7694)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:35:27 -08:00
AustinMroz
836040e85b Support widget specific contextmenu options in vue (#8431)
<img width="614" height="485" alt="image"
src="https://github.com/user-attachments/assets/2a635dec-8bed-4fab-9881-5e6057d482e1"
/>

These options were defined in `litegraphService`. While the existing
code for defining options is reused (to ensure there's no implementation
drift) these extra widget options use the litegraph format for context
menu options and do not belong in `useSelectionMenuOptions`. They have
been moved out of `useLitegraphService` (good), but left in
`litegraphService` (not great)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8431-Support-widget-specific-contextmenu-options-in-vue-2f76d73d3650814fb20fca352dc81e3b)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Johnpaul Chiwetelu
8e233d07b0 Road to No explicit any: Group 8 (part 6) test files (#8344)
## Summary

This PR removes unsafe type assertions ("as unknown as Type") from test
files and improves type safety across the codebase.

### Key Changes

#### Type Safety Improvements
- Removed all instances of "as unknown as" patterns from test files
- Used proper factory functions from litegraphTestUtils instead of
custom mocks
- Made incomplete mocks explicit using Partial<T> types
- Fixed DialogStore mocking with proper interface exports
- Improved type safety with satisfies operator where applicable

#### App Parameter Removal
- **Removed the unused `app` parameter from all ComfyExtension interface
methods**
- The app parameter was always undefined at runtime as it was never
passed from invokeExtensions
- Affected methods: init, setup, addCustomNodeDefs,
beforeRegisterNodeDef, beforeRegisterVueAppNodeDefs,
registerCustomNodes, loadedGraphNode, nodeCreated, beforeConfigureGraph,
afterConfigureGraph

##### Breaking Change Analysis
Verified via Sourcegraph that this is NOT a breaking change:
- Searched all 10 affected methods across GitHub repositories
- Only one external repository
([drawthingsai/draw-things-comfyui](https://github.com/drawthingsai/draw-things-comfyui))
declares the app parameter in their extension methods
- That repository never actually uses the app parameter (just declares
it in the function signature)
- All other repositories already omit the app parameter
- Search queries used:
- [init method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22init%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
- [setup method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22setup%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
  - Similar searches for all 10 methods confirmed no usage

### Files Changed

Test files:
-
src/components/settings/widgets/__tests__/WidgetInputNumberInput.test.ts
- src/services/keybindingService.escape.test.ts  
- src/services/keybindingService.forwarding.test.ts
- src/utils/__tests__/newUserService.test.ts →
src/utils/__tests__/useNewUserService.test.ts
- src/services/jobOutputCache.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts

Source files:
- src/types/comfy.ts - Removed app parameter from ComfyExtension
interface
- src/services/extensionService.ts - Improved type safety with
FunctionPropertyNames helper
- src/scripts/metadata/isobmff.ts - Fixed extractJson return type per
review
- src/extensions/core/*.ts - Updated extension implementations
- src/scripts/app.ts - Updated app initialization

### Testing
- All existing tests pass
- Type checking passes  
- ESLint/oxlint checks pass
- No breaking changes for external repositories

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344 (this PR)
2026-01-29 17:35:27 -08:00
AustinMroz
745d2536dc Disable logs button in sidebar on cloud (#8429)
Since cloud doesn't currently provide logs, this button was just causing
confusion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8429-Disable-logs-button-in-sidebar-on-cloud-2f76d73d365081a4b909dd9a105e381e)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Alexander Brown
9ac5ff3f5f Chore: Add workflow dispatch to E2E (#8422)
Also remove old branches from the ignore

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8422-Chore-Add-workflow-dispatch-to-E2E-2f76d73d365081d0940dceaa37231ca7)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Comfy Org PR Bot
c1138c2295 1.39.1 (#8382)
Patch version increment to 1.39.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8382-1-39-1-2f76d73d36508126840cdc74593952e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-29 17:35:27 -08:00
AustinMroz
a1ca0e3795 Implement clickable badges (#8401)
Adds an `onClick` handler to LGraphBadge

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8401-Implement-clickable-badges-2f76d73d365081b3b23fc1eaa3bc65b8)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
AustinMroz
d3669cf150 Fix flake hidream test (#8406)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8406-Fix-flake-hidream-test-2f76d73d3650814daae8fb7775fa3073)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Jin Yi
c9c11cd8bf [bugfix] Disable install button when already installed version is selected (#8412)
## Summary
Prevent re-installing an already installed version by disabling the
Install button and the version option in the selector.

## Changes
- Add `isVersionInstalled()` function to check if a version is already
installed
- Add `isInstallDisabled` computed to disable Install button when
selected version is installed
- Add `option-disabled="isInstalled"` to Listbox to prevent selecting
installed versions

Fixes the issue where users could trigger duplicate install operations
by selecting the currently installed version.

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8412-bugfix-Disable-install-button-when-already-installed-version-is-selected-2f76d73d365081cb859ff98a3d1c64e6)
by [Unito](https://www.unito.io)
2026-01-29 17:35:27 -08:00
Christian Byrne
0b97bf4b41 feat: add category support for blueprints and protect global blueprints (#8378)
## Summary

This PR adds two related features for subgraph blueprints:

### 1. Protect Global Blueprints from Deletion
- Added `isGlobalBlueprint()` helper that distinguishes blueprints by
`python_module` field
- User blueprints have `python_module: 'blueprint'`
- Global (installed) blueprints have `python_module` set to the node
pack name
- Guarded `deleteBlueprint()` to show a warning toast for global
blueprints
- Hidden delete menu in node library for global blueprints

### 2. Category Support for Blueprints
- User blueprints now use category `Subgraph Blueprints/User`
- Global blueprints use `Subgraph Blueprints/{category}` if category is
provided, otherwise `Subgraph Blueprints`
- Extended `GlobalSubgraphData.info` type with optional `category` field
- Added `category` to `zSubgraphDefinition` schema

## Files Changed
- `src/stores/subgraphStore.ts` - Core logic for both features
- `src/stores/subgraphStore.test.ts` - Tests for `isGlobalBlueprint`
- `src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue` - Hide
delete menu for global blueprints
- `src/scripts/api.ts` - Extended `GlobalSubgraphData` type
- `src/platform/workflow/validation/schemas/workflowSchema.ts` - Added
category to schema
- `src/locales/en/main.json` - Added translation key

## Testing
-  `pnpm typecheck` passed
-  `pnpm lint` passed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8378-feat-add-category-support-for-blueprints-and-protect-global-blueprints-2f66d73d36508137aa67c2d88c358b69)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 17:35:27 -08:00
Christian Byrne
f756fbe604 fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8413)
## Summary

When attempting to select text inside Vue node Markdown widget's
textarea (edit mode), the node would drag instead of text being
selected.

Root cause: WidgetMarkdown.vue's Textarea only had @click.stop and
@keydown.stop, but was missing pointer event modifiers. The pointerdown
event bubbled up to LGraphNode.vue which initiated node drag.

*Fix*: Add @pointerdown.capture.stop, @pointermove.capture.stop, and
@pointerup.capture.stop to match the pattern used in WidgetTextarea.vue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8413-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-node-to-drag-2f76d73d3650816dbf9bdf893775c3d4)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:35:27 -08:00
Jin Yi
40350c3c33 [bugfix] Fix manager missing node tab with shared composable (#8409) 2026-01-29 17:35:27 -08:00
Alexander Brown
e7e5457d64 test: use createTestingPinia instead of createPinia (#8376)
Replace \createPinia\ with \createTestingPinia({ stubActions: false })\
from \@pinia/testing\ across 45 test files for proper test isolation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8376-test-use-createTestingPinia-instead-of-createPinia-2f66d73d36508137a9f0daffcddc86f7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:35:27 -08:00
Christian Byrne
98cbc0dba8 fix: use getAuthHeader in createCustomer to support API key auth (#8408)
## Summary

Fixes authentication failure when using API key authentication on
staging server after frontend update to 1.33.10.

<img width="1160" height="709" alt="image"
src="https://github.com/user-attachments/assets/fe56866d-1819-419e-9f53-35a123d764c3"
/>


## Changes

- **What**: Changed `createCustomer()` to use `getAuthHeader()` instead
of `getFirebaseAuthHeader()`, allowing API key users to authenticate
successfully

## Review Focus

- Verify `getAuthHeader()` correctly falls back to API key when no
Firebase token exists
- Backend `/customers` endpoint supports `X-API-KEY` header (per cloud
PR #1766)

Fixes COM-12398

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8408-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d3650819994e3e6d3ed9f3dfa)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:35:26 -08:00
Alexander Brown
474ef3751b Chore: Actions updates and cleanup (#8377)
## Summary

...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8377-WIP-Chore-Actions-updates-and-cleanup-2f66d73d3650818483a8dffa32a6f245)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:35:26 -08:00
Jin Yi
9a97e6c21e fix: add null check in getCanvasCenter to prevent crash on asset insert (#8399)
## Summary
Adds null check in `getCanvasCenter()` to prevent crash when inserting
asset as node before canvas is fully initialized.

## Changes
- **What**: Added optional chaining for `app.canvas?.ds?.visible_area`
with fallback to `[0, 0]`

## Review Focus
- Simple defensive fix - returns origin position if canvas not ready

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

Co-Authored-By: Claude <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8399-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-insert-2f76d73d365081e88c08ef40ea9e7b78)
by [Unito](https://www.unito.io)
2026-01-29 17:35:26 -08:00
guill
050b153fb3 feat: support dev-only nodes (#8359)
## Summary

Support `dev_only` property to node definitions that hides nodes from
search and menus unless dev mode is enabled. Dev-only nodes display a
"DEV" badge when visible.

This functionality is primarily intended to support unit-testing nodes
on Comfy Cloud, but also has other uses.

## Changes

- **What**: Nodes flagged as dev_only in the node schema will only
appear in search and menus if Dev Mode is on.

## Screenshots (if applicable)

With Dev Mode off:
<img width="2189" height="1003" alt="image"
src="https://github.com/user-attachments/assets/a08e1fd7-dca9-4ce1-9964-5f4f3b7b95ac"
/>

With Dev Mode on:
<img width="2201" height="1066" alt="image"
src="https://github.com/user-attachments/assets/7fe6cd1f-f774-4f48-b604-a528e286b584"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8359-feat-support-dev-only-nodes-2f66d73d36508102839ee7cd66a26129)
by [Unito](https://www.unito.io)
2026-01-29 17:35:26 -08:00
Christian Byrne
30c6d282ef fix: increase Vue node resize handle size for better usability (#8391)
## Summary

Increases the resize handle size on Vue nodes to improve usability,
especially when nodes are selected.

## Changes

- **What**: Increased resize handle from 12px to 20px and offset it
slightly outside the node boundary to avoid overlap with selection
outline

## Review Focus

The resize handle was too small and became harder to grab when the node
was selected (the 2px outline rendered outside the box, visually
obscuring the corner). This fix increases the hit area and positions it
to extend beyond the node edge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8391-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d36508136b2aac51bc0d53551)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 17:35:26 -08:00
Subagent 5
90d681ad31 fix: invalidate loader node dropdown cache after model asset deletion
Amp-Thread-ID: https://ampcode.com/threads/T-019c0830-033a-7224-85ec-766b1765426d
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 21:47:18 -08:00
134 changed files with 2032 additions and 4532 deletions

View File

@@ -21,7 +21,6 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

@@ -17,8 +17,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.PR_GH_TOKEN }}
# Setup playwright environment
- name: Setup ComfyUI Frontend

View File

@@ -50,7 +50,6 @@ jobs:
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
pnpm build

View File

@@ -5,7 +5,7 @@ import * as fs from 'fs'
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
import type { KeyCombo } from '../../src/platform/keybindings'
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'

View File

@@ -1,7 +1,7 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -42,9 +42,7 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Unused experimental publish dialog
'src/components/actionbar/PublishToHubDialogContent.vue'
'src/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -46,8 +46,6 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
// Handle preload errors that occur during dynamic imports (e.g., stale chunks after deployment)
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef

View File

@@ -2,7 +2,7 @@
<div
class="w-full h-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
>
<slot v-if="shouldShowWorkflowTabs" name="workflow-tabs" />
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
@@ -25,9 +25,7 @@
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode &&
!isFullPageOverlayActive &&
(sidebarLocation === 'left' || anyRightPanelVisible)
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'left'
@@ -57,22 +55,9 @@
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<slot
v-if="!isFullPageOverlayActive"
name="topmenu"
:sidebar-panel-visible
/>
<!-- Full page content (replaces graph canvas when active) -->
<div
v-if="isFullPageOverlayActive"
class="pointer-events-auto flex-1 overflow-hidden bg-comfy-menu-bg"
>
<slot name="full-page-content" />
</div>
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
v-else
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
layout="vertical"
:pt:gutter="
@@ -100,9 +85,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode &&
!isFullPageOverlayActive &&
(sidebarLocation === 'right' || anyRightPanelVisible)
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'right'
@@ -142,9 +125,7 @@ import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -163,25 +144,11 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { activeSidebarTabId, activeSidebarTab, isFullPageTabActive } =
storeToRefs(sidebarTabStore)
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const { isOpen: homePanelOpen } = storeToRefs(useHomePanelStore())
const sharePanelStore = useSharePanelStore()
const { isOpen: sharePanelVisible } = storeToRefs(sharePanelStore)
const anyRightPanelVisible = computed(
() => rightSidePanelVisible.value || sharePanelVisible.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const isFullPageOverlayActive = computed(
() => isFullPageTabActive.value || homePanelOpen.value
)
const shouldShowWorkflowTabs = computed(
() => !isFullPageTabActive.value || homePanelOpen.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -202,7 +169,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${anyRightPanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -42,7 +42,6 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ShareButton />
</div>
</Panel>
@@ -81,7 +80,6 @@ import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
import ShareButton from './ShareButton.vue'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null

View File

@@ -1,239 +0,0 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('discover.share.publishToHubDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 p-4">
<!-- Thumbnail upload -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
</label>
<div
class="relative flex aspect-video w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-secondary-background transition-colors hover:border-border-hover"
@click="triggerThumbnailUpload"
>
<img
v-if="thumbnailPreview"
:src="thumbnailPreview"
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
class="size-full object-cover"
/>
<div v-else class="flex flex-col items-center gap-2 text-center">
<i class="icon-[lucide--image-plus] size-8 text-muted-foreground" />
<span class="text-sm text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.uploadThumbnail') }}
</span>
</div>
<input
ref="thumbnailInput"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailChange"
/>
</div>
</div>
<!-- Title -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('g.title') }}
</label>
<input
v-model="title"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('discover.share.publishToHubDialog.titlePlaceholder')
"
/>
</div>
<!-- Description -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('g.description') }}
</label>
<textarea
v-model="description"
rows="3"
class="w-full resize-none rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
"
/>
</div>
<!-- Tags -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('discover.filters.tags') }}
</label>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in tags"
:key="tag"
class="flex items-center gap-1 rounded-full bg-secondary-background px-2.5 py-1 text-xs text-base-foreground"
>
{{ tag }}
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] size-3" />
</button>
</span>
<input
v-model="newTag"
type="text"
class="min-w-24 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
@keydown.enter.prevent="addTag"
/>
</div>
</div>
<!-- Open source toggle -->
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-sm font-medium text-base-foreground">
{{ $t('discover.share.publishToHubDialog.openSource') }}
</span>
<span class="text-xs text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.openSourceDescription') }}
</span>
</div>
<button
:class="
cn(
'relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors',
isOpenSource ? 'bg-green-500' : 'bg-secondary-background'
)
"
@click="isOpenSource = !isOpenSource"
>
<span
:class="
cn(
'absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
isOpenSource && 'translate-x-5'
)
"
/>
</button>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-end gap-3 border-t border-border-default px-4 py-3"
>
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValid"
@click="onPublish"
>
{{ $t('discover.share.publishToHubDialog.publish') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { workflow } = defineProps<{
workflow: ComfyWorkflow
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toastStore = useToastStore()
const loading = ref(false)
const thumbnailInput = ref<HTMLInputElement>()
const thumbnailFile = ref<File | null>(null)
const thumbnailPreview = ref('')
const title = ref(workflow.filename ?? '')
const description = ref('')
const tags = ref<string[]>([])
const newTag = ref('')
const isOpenSource = ref(false)
const isValid = computed(
() => title.value.trim().length > 0 && description.value.trim().length > 0
)
function triggerThumbnailUpload() {
thumbnailInput.value?.click()
}
function handleThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
thumbnailFile.value = file
thumbnailPreview.value = URL.createObjectURL(file)
}
}
function addTag() {
const tag = newTag.value.trim()
if (tag && !tags.value.includes(tag)) {
tags.value.push(tag)
}
newTag.value = ''
}
function removeTag(tag: string) {
tags.value = tags.value.filter((t) => t !== tag)
}
function onCancel() {
dialogStore.closeDialog({ key: 'publish-to-hub' })
}
async function onPublish() {
loading.value = true
try {
toastStore.add({
severity: 'info',
summary: t('g.comingSoon'),
detail: t('discover.share.publishToHubDialog.notImplemented'),
life: 3000
})
onCancel()
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,37 +0,0 @@
<template>
<Button
:variant="isOpen ? 'primary' : 'secondary'"
size="md"
class="px-3 text-sm font-semibold"
:aria-label="$t('discover.share.share')"
:aria-pressed="isOpen"
@click="handleClick"
>
{{ $t('discover.share.share') }}
</Button>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const sharePanelStore = useSharePanelStore()
const toastStore = useToastStore()
const { isOpen } = storeToRefs(sharePanelStore)
function handleClick() {
if (!workflowStore.activeWorkflow) {
toastStore.addAlert(t('discover.share.noActiveWorkflow'))
return
}
sharePanelStore.togglePanel()
}
</script>

View File

@@ -1,533 +0,0 @@
<template>
<div class="flex size-full flex-col bg-comfy-menu-bg">
<!-- Panel Header -->
<section
class="sticky top-0 z-10 border-b border-border-default bg-comfy-menu-bg/95 pt-1 backdrop-blur"
>
<div class="flex items-center justify-between pl-4 pr-3">
<h3 class="my-3.5 text-base font-semibold tracking-tight">
{{ $t('discover.share.share') }}
</h3>
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.close')"
@click="closePanel"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<nav class="overflow-x-auto px-4 pb-3 pt-1">
<div
class="inline-flex rounded-full border border-border-default bg-secondary-background/70 p-1 shadow-sm"
>
<TabList v-model="activeTab" class="gap-1 pb-0">
<Tab
value="invite"
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
>
<i class="icon-[lucide--users] mr-1.5 size-3.5" />
{{ $t('discover.share.inviteTab.title') }}
</Tab>
<Tab
value="publish"
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
>
<i class="icon-[lucide--upload] mr-1.5 size-3.5" />
{{ $t('discover.share.publishTab.title') }}
</Tab>
</TabList>
</div>
</nav>
</section>
<!-- Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<!-- Share Tab -->
<div
v-if="activeTab === 'invite'"
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
>
<!-- People with access -->
<div v-if="invitedUsers.length > 0" class="flex flex-col gap-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.inviteByEmail.peopleWithAccess') }}
</span>
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-2"
>
<div
v-for="user in invitedUsers"
:key="user.email"
class="flex items-center gap-3 rounded-lg border border-border-default/70 bg-comfy-menu-bg px-3 py-2.5 shadow-sm transition-colors hover:bg-secondary-background"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary-500/20 text-sm font-medium text-primary-500 ring-1 ring-primary-500/30"
>
{{ user.email.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-base-foreground">
{{ user.email }}
</div>
<div class="text-xs text-muted-foreground">
{{
user.role === 'edit'
? $t('discover.share.inviteByEmail.roles.edit')
: $t('discover.share.inviteByEmail.roles.viewOnly')
}}
</div>
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.delete')"
@click="removeUser(user.email)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
<!-- Invite section header -->
<div v-if="invitedUsers.length > 0" class="h-px bg-border-default" />
<!-- Email + role -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.inviteByEmail.emailLabel') }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<input
v-model="email"
type="email"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="$t('discover.share.inviteByEmail.emailPlaceholder')"
/>
<select
v-model="selectedRole"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover sm:w-40"
:aria-label="$t('discover.share.inviteByEmail.roleLabel')"
>
<option value="view">
{{ $t('discover.share.inviteByEmail.roles.viewOnly') }}
</option>
<option value="edit">
{{ $t('discover.share.inviteByEmail.roles.edit') }}
</option>
</select>
</div>
</div>
<Button
variant="primary"
size="md"
class="w-full"
:loading="sendingInvite"
:disabled="!isEmailValid"
@click="handleSendInvite"
>
<i class="icon-[lucide--send] size-4" />
{{ $t('discover.share.inviteByEmail.sendInvite') }}
</Button>
<!-- Public URL Section -->
<div
class="rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<i class="icon-[lucide--globe] size-4 text-muted-foreground" />
<span class="text-sm font-semibold text-base-foreground">
{{ $t('discover.share.publicUrl.title') }}
</span>
</div>
<p class="text-sm leading-5 text-muted-foreground">
{{ $t('discover.share.publicUrl.description') }}
</p>
<div class="flex flex-col gap-2">
<Button
variant="secondary"
size="md"
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
:aria-label="$t('discover.share.publicUrl.copyLink')"
@click="copyToClipboard(shareUrl)"
>
<span class="min-w-0 truncate">
{{ $t('discover.share.publicUrl.copyLink') }}
</span>
<i
:class="
cn(
'size-3.5',
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
)
"
/>
</Button>
<Button
variant="secondary"
size="md"
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
:aria-label="$t('discover.share.publicUrl.copyLinkAppMode')"
@click="copyToClipboard(appModeShareUrl)"
>
<span class="min-w-0 truncate">
{{ $t('discover.share.publicUrl.copyLinkAppMode') }}
</span>
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- Publish to Comfy Hub Tab -->
<div
v-else-if="activeTab === 'publish'"
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
>
<div
v-if="publishSuccessUrl"
class="flex flex-col gap-4 rounded-xl border border-border-default bg-secondary-background/40 p-4"
>
<div class="flex items-center gap-2 text-base font-semibold">
<i class="icon-[lucide--check-circle] size-5 text-success" />
{{ $t('discover.share.publishToHubDialog.successTitle') }}
</div>
<p class="text-sm text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.successDescription') }}
</p>
<div
class="rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground"
>
{{ publishSuccessUrl }}
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<Button variant="primary" size="md" @click="openPublishedWorkflow">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.share.publishToHubDialog.successOpen') }}
</Button>
<Button
variant="secondary"
size="md"
@click="copyToClipboard(publishSuccessUrl)"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('discover.share.publishToHubDialog.successCopy') }}
</Button>
</div>
</div>
<template v-else>
<p
class="rounded-xl border border-border-default bg-secondary-background/40 p-3 text-sm leading-5 text-muted-foreground"
>
{{ $t('discover.share.publishTab.description') }}
</p>
<!-- Thumbnail upload -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
</span>
<div
class="relative flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-comfy-menu-bg transition-colors hover:border-border-hover"
@click="triggerThumbnailUpload"
>
<img
v-if="thumbnailPreview"
:src="thumbnailPreview"
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
class="size-full object-cover"
/>
<div v-else class="flex flex-col items-center gap-1 text-center">
<i
class="icon-[lucide--image-plus] size-6 text-muted-foreground"
/>
</div>
<input
ref="thumbnailInput"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailChange"
/>
</div>
</div>
<!-- Title -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('g.title') }}
</span>
<input
v-model="publishTitle"
type="text"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="
$t('discover.share.publishToHubDialog.titlePlaceholder')
"
/>
</div>
<!-- Description -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('g.description') }}
</span>
<textarea
v-model="publishDescription"
rows="3"
class="w-full resize-none rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
"
/>
</div>
<!-- Tags -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.filters.tags') }}
</span>
<div
class="flex flex-wrap gap-2 rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2"
>
<span
v-for="tag in publishTags"
:key="tag"
class="flex items-center gap-1 rounded-full bg-secondary-background px-2 py-0.5 text-xs text-base-foreground"
>
{{ tag }}
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
:aria-label="$t('g.removeTag')"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] size-3" />
</button>
</span>
<input
v-model="newTag"
type="text"
class="min-w-20 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
@keydown.enter.prevent="addTag"
/>
</div>
</div>
<Button
variant="primary"
size="md"
class="w-full"
:loading="publishing"
:disabled="!isPublishValid"
@click="handlePublishToHub"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('discover.share.publishToHubDialog.publish') }}
</Button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { cn } from '@/utils/tailwindUtil'
type ShareTab = 'invite' | 'publish'
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const sharePanelStore = useSharePanelStore()
interface InvitedUser {
email: string
role: 'view' | 'edit'
}
const activeTab = ref<ShareTab>('invite')
const email = ref('')
const selectedRole = ref<'view' | 'edit'>('view')
const sendingInvite = ref(false)
const publishing = ref(false)
const copied = ref(false)
const invitedUsers = ref<InvitedUser[]>([])
// Publish to Hub state
const thumbnailInput = ref<HTMLInputElement>()
const thumbnailPreview = ref('')
const publishTitle = ref('')
const publishDescription = ref('')
const publishTags = ref<string[]>([])
const newTag = ref('')
const publishSuccessUrl = ref<string | null>(null)
const isEmailValid = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
const isPublishValid = computed(
() =>
publishTitle.value.trim().length > 0 &&
publishDescription.value.trim().length > 0
)
const shareUrl = computed(() => {
const baseUrl = window.location.origin
const workflow = workflowStore.activeWorkflow
if (!workflow) return baseUrl
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
return new URL(`/app/${workflowId}`, baseUrl).toString()
})
const appModeShareUrl = computed(() => {
const baseUrl = window.location.origin
const workflow = workflowStore.activeWorkflow
if (!workflow) return baseUrl
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
const url = new URL(`/app/${workflowId}`, baseUrl)
url.searchParams.set('mode', 'linear')
return url.toString()
})
function closePanel() {
sharePanelStore.closePanel()
}
async function handleSendInvite() {
sendingInvite.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
const existingUser = invitedUsers.value.find((u) => u.email === email.value)
if (existingUser) {
existingUser.role = selectedRole.value
} else {
invitedUsers.value.push({
email: email.value,
role: selectedRole.value
})
}
toastStore.add({
severity: 'success',
summary: t('discover.share.inviteByEmail.inviteSent'),
detail: t('discover.share.inviteByEmail.inviteSentDetail', {
email: email.value
}),
life: 3000
})
email.value = ''
} finally {
sendingInvite.value = false
}
}
function removeUser(emailToRemove: string) {
invitedUsers.value = invitedUsers.value.filter(
(u) => u.email !== emailToRemove
)
}
async function copyToClipboard(url: string) {
try {
await navigator.clipboard.writeText(url)
copied.value = true
toastStore.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
toastStore.addAlert(t('discover.share.copyFailed'))
}
}
function triggerThumbnailUpload() {
thumbnailInput.value?.click()
}
function handleThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
thumbnailPreview.value = URL.createObjectURL(file)
}
}
function addTag() {
const tag = newTag.value.trim()
if (tag && !publishTags.value.includes(tag)) {
publishTags.value.push(tag)
}
newTag.value = ''
}
function removeTag(tag: string) {
publishTags.value = publishTags.value.filter((t) => t !== tag)
}
async function handlePublishToHub() {
publishing.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
const title = publishTitle.value.trim()
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
const fakeId = slug ? `${slug}-preview` : 'workflow-preview'
publishSuccessUrl.value = `https://comfy-hub.vercel.app/workflows/${fakeId}`
} finally {
publishing.value = false
}
}
function openPublishedWorkflow() {
if (!publishSuccessUrl.value) return
window.open(publishSuccessUrl.value, '_blank')
}
</script>

View File

@@ -772,7 +772,7 @@ useIntersectionObserver(loadTrigger, () => {
// Reset pagination when filters change
watch(
[
filteredTemplates,
searchQuery,
selectedNavItem,
sortBy,
selectedModels,

View File

@@ -150,11 +150,13 @@ import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'

View File

@@ -13,7 +13,7 @@
import Tag from 'primevue/tag'
import { computed } from 'vue'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import type { KeyComboImpl } from '@/stores/keybindingStore'
const { keyCombo, isModified = false } = defineProps<{
keyCombo: KeyComboImpl

View File

@@ -1,369 +0,0 @@
<template>
<div
class="flex size-full flex-col overflow-y-auto bg-comfy-menu-bg text-base-foreground"
>
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke bg-comfy-menu-bg px-6 py-4 text-base-foreground"
>
<Button variant="secondary" size="md" @click="emit('back')">
<i class="icon-[lucide--arrow-left]" />
{{ $t('g.back') }}
</Button>
<div class="flex-1" />
</div>
<section class="px-6 pt-6">
<div
class="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-2xl border border-border-default bg-comfy-menu-secondary-bg p-6 shadow-sm text-base-foreground"
>
<div class="flex flex-wrap items-start gap-6">
<img
:src="authorAvatar"
:alt="authorName"
class="size-20 rounded-2xl bg-secondary-background object-cover ring-2 ring-border-subtle"
/>
<div class="min-w-0 flex-1">
<div
class="text-xs font-semibold uppercase text-base-foreground/60"
>
{{ $t('discover.author.title') }}
</div>
<div class="flex flex-wrap items-center gap-3">
<h1 class="truncate text-3xl font-semibold text-base-foreground">
{{ authorName }}
</h1>
<Button variant="secondary" size="md" @click="openHubProfile">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.author.openInHub') }}
</Button>
<div
class="flex items-center gap-2 rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-xs text-base-foreground/80"
>
<span class="text-base-foreground">
#{{ $t('discover.author.rankValue') }}
</span>
<span class="text-base-foreground/60">
{{ $t('discover.author.rankLabel') }}
</span>
<span class="text-base-foreground/60"></span>
<span class="text-base-foreground/60">
{{ $t('discover.author.rankCaption') }}
</span>
</div>
</div>
<p class="mt-2 max-w-2xl text-sm text-base-foreground/80">
{{ $t('discover.author.tagline') }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeCreator') }}
</div>
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeOpenSource') }}
</div>
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeTemplates') }}
</div>
</div>
</div>
<div class="flex w-full flex-col gap-3 md:w-64">
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.aboutTitle') }}
</div>
<p class="mt-2 text-sm text-base-foreground/80">
{{ $t('discover.author.aboutDescription') }}
</p>
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.runsLabel') }}
</div>
<div class="mt-2 text-2xl font-semibold text-base-foreground">
{{ formattedRuns }}
</div>
</div>
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.copiesLabel') }}
</div>
<div class="mt-2 text-2xl font-semibold text-base-foreground">
{{ formattedCopies }}
</div>
</div>
</div>
</div>
</section>
<div class="px-6 py-4 bg-comfy-menu-bg">
<div
class="mx-auto mb-4 flex w-full max-w-5xl flex-wrap items-center gap-3 border-b border-interface-stroke pb-4 text-base-foreground"
>
<div class="flex items-center gap-2">
<i
class="icon-[lucide--layout-grid] size-4 text-base-foreground/60"
/>
<span class="text-sm font-semibold text-base-foreground">
{{ $t('discover.author.workflowsTitle') }}
</span>
</div>
<div
class="flex items-center gap-2 rounded-full bg-secondary-background px-3 py-1 text-xs"
>
<i class="icon-[lucide--layers] size-3.5" />
{{
$t(
'discover.author.workflowsCount',
{ count: totalWorkflows },
totalWorkflows
)
}}
</div>
<div class="flex-1" />
<SearchBox
v-model="searchQuery"
:placeholder="$t('discover.author.searchPlaceholder')"
size="lg"
class="max-w-md flex-1"
show-border
@search="handleSearch"
/>
</div>
<div
v-if="isLoading"
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="n in 12"
:key="`author-skeleton-${n}`"
size="compact"
variant="ghost"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="landscape">
<div class="size-full animate-pulse bg-dialog-surface" />
</CardTop>
</template>
<template #bottom>
<div class="p-3">
<div
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
/>
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
</div>
</template>
</CardContainer>
</div>
<div
v-else-if="results && results.templates.length === 0"
class="mx-auto flex h-64 w-full max-w-5xl flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg">{{ $t('discover.author.noResults') }}</p>
<p class="text-sm">{{ $t('discover.author.noResultsHint') }}</p>
</div>
<div
v-else-if="results"
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4"
>
<CardContainer
v-for="template in results.templates"
:key="template.objectID"
size="compact"
custom-aspect-ratio="2/3"
variant="ghost"
rounded="lg"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.objectID"
@mouseleave="hoveredTemplate = null"
@click="emit('selectWorkflow', template)"
>
<template #top>
<div class="shrink-0">
<CardTop ratio="square">
<LazyImage
:src="template.thumbnail_url"
:alt="template.title"
class="size-full rounded-lg object-cover transition-transform duration-300"
:class="hoveredTemplate === template.objectID && 'scale-105'"
/>
<template #bottom-right>
<SquareChip
v-for="tag in template.tags.slice(0, 2)"
:key="tag"
:label="tag"
/>
</template>
</CardTop>
</div>
</template>
<template #bottom>
<div class="flex flex-col gap-1.5 p-3">
<h3
class="line-clamp-1 text-sm font-medium"
:title="template.title"
>
{{ template.title }}
</h3>
<p
class="line-clamp-2 text-xs text-muted-foreground"
:title="template.description"
>
{{ template.description }}
</p>
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="model in template.models.slice(0, 2)"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
<span
v-if="template.models.length > 2"
class="text-xs text-muted-foreground"
>
+{{ template.models.length - 2 }}
</span>
</div>
</div>
</template>
</CardContainer>
</div>
</div>
<div
v-if="results && results.totalPages > 1"
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
>
<Button
variant="secondary"
size="md"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
<i class="icon-[lucide--chevron-left]" />
</Button>
<span class="px-4 text-sm text-muted-foreground">
{{ currentPage + 1 }} / {{ results.totalPages }}
</span>
<Button
variant="secondary"
size="md"
:disabled="currentPage >= results.totalPages - 1"
@click="goToPage(currentPage + 1)"
>
<i class="icon-[lucide--chevron-right]" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { authorName, authorAvatarUrl, stats } = defineProps<{
authorName: string
authorAvatarUrl?: string
stats?: { runs: number; copies: number }
}>()
const emit = defineEmits<{
back: []
selectWorkflow: [workflow: AlgoliaWorkflowTemplate]
}>()
const { search, isLoading, results } = useWorkflowTemplateSearch()
const searchQuery = ref('')
const currentPage = ref(0)
const hoveredTemplate = ref<string | null>(null)
const hubProfileUrl = 'https://pr-2289.testenvs.comfy.org/profile/Comfy%20Org'
const authorAvatar = computed(
() => authorAvatarUrl ?? '/assets/images/comfy-logo-single.svg'
)
const formattedRuns = computed(() =>
stats?.runs ? stats.runs.toLocaleString() : '--'
)
const formattedCopies = computed(() =>
stats?.copies ? stats.copies.toLocaleString() : '--'
)
const totalWorkflows = computed(() => results.value?.totalHits ?? 0)
const authorFacetFilters = computed(() => [[`author_name:${authorName}`]])
function openHubProfile() {
window.location.assign(hubProfileUrl)
}
async function performSearch() {
await search({
query: searchQuery.value,
pageSize: 24,
pageNumber: currentPage.value,
facetFilters: authorFacetFilters.value
})
}
function handleSearch() {
currentPage.value = 0
performSearch()
}
function goToPage(page: number) {
currentPage.value = page
performSearch()
}
watch(
() => authorName,
() => {
currentPage.value = 0
performSearch()
}
)
watch(
() => searchQuery.value,
() => {
currentPage.value = 0
performSearch()
}
)
onMounted(() => {
performSearch()
})
</script>

View File

@@ -1,387 +0,0 @@
<template>
<WorkflowDetailView
v-if="selectedWorkflow"
:workflow="selectedWorkflow"
@back="selectedWorkflow = null"
@run-workflow="handleRunWorkflow"
@make-copy="handleMakeCopy"
@author-selected="handleAuthorSelected"
/>
<div v-else class="flex size-full flex-col">
<!-- Header with search -->
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
>
<h1 class="text-xl font-semibold text-base-foreground">
{{ $t('sideToolbar.discover') }}
</h1>
<SearchBox
v-model="searchQuery"
:placeholder="$t('discover.searchPlaceholder')"
size="lg"
class="max-w-md flex-1"
show-border
@search="handleSearch"
/>
</div>
<!-- Filters -->
<div class="flex shrink-0 flex-wrap items-center gap-3 px-6 py-3">
<!-- Tags filter -->
<MultiSelect
v-model="selectedTags"
:label="$t('discover.filters.tags')"
:options="tagOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
class="w-48"
>
<template #icon>
<i class="icon-[lucide--tag]" />
</template>
</MultiSelect>
<!-- Models filter -->
<MultiSelect
v-model="selectedModels"
:label="$t('discover.filters.models')"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
class="w-48"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<!-- Open source toggle -->
<Button
:variant="openSourceOnly ? 'primary' : 'secondary'"
size="md"
@click="openSourceOnly = !openSourceOnly"
>
<i class="icon-[lucide--unlock]" />
{{ $t('discover.filters.openSource') }}
</Button>
<!-- Cloud only toggle -->
<Button
:variant="cloudOnly ? 'primary' : 'secondary'"
size="md"
@click="cloudOnly = !cloudOnly"
>
<i class="icon-[lucide--cloud]" />
{{ $t('discover.filters.cloudOnly') }}
</Button>
<div class="flex-1" />
<!-- Results count -->
<span v-if="!isLoading && results" class="text-sm text-muted-foreground">
{{
$t(
'discover.resultsCount',
{ count: results.totalHits },
results.totalHits
)
}}
</span>
</div>
<!-- Content area -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Loading state -->
<div
v-if="isLoading"
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="n in 12"
:key="`skeleton-${n}`"
size="compact"
variant="ghost"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="landscape">
<div class="size-full animate-pulse bg-dialog-surface" />
</CardTop>
</template>
<template #bottom>
<div class="p-3">
<div
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
/>
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
</div>
</template>
</CardContainer>
</div>
<!-- No results state -->
<div
v-else-if="!cloudOnly || (results && results.templates.length === 0)"
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg">{{ $t('discover.noResults') }}</p>
<p class="text-sm">{{ $t('discover.noResultsHint') }}</p>
</div>
<!-- Results grid -->
<div
v-else-if="results"
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="template in results.templates"
:key="template.objectID"
size="compact"
custom-aspect-ratio="2/3"
variant="ghost"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.objectID"
@mouseleave="hoveredTemplate = null"
@click="handleTemplateClick(template)"
>
<template #top>
<div class="shrink-0">
<CardTop ratio="square">
<LazyImage
:src="template.thumbnail_url"
:alt="template.title"
class="size-full rounded-lg object-cover transition-transform duration-300"
:class="hoveredTemplate === template.objectID && 'scale-105'"
/>
<template #bottom-right>
<SquareChip
v-for="tag in template.tags.slice(0, 2)"
:key="tag"
:label="tag"
/>
</template>
</CardTop>
</div>
</template>
<template #bottom>
<div class="flex flex-col gap-1.5 p-3">
<h3
class="line-clamp-1 text-sm font-medium"
:title="template.title"
>
{{ template.title }}
</h3>
<button
v-if="template.author_name"
type="button"
class="flex items-center gap-1.5 rounded px-1 -mx-1 border border-transparent bg-transparent text-muted-foreground transition-colors hover:bg-secondary-background hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle"
@click.stop="handleAuthorClick(template)"
>
<img
:src="
template.author_avatar_url ??
'/assets/images/comfy-logo-single.svg'
"
:alt="template.author_name"
class="size-4 rounded-full bg-secondary-background object-cover"
/>
<span class="text-xs text-muted-foreground">
{{ template.author_name }}
</span>
</button>
<p
class="line-clamp-2 text-xs text-muted-foreground"
:title="template.description"
>
{{ template.description }}
</p>
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="model in template.models.slice(0, 2)"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
<span
v-if="template.models.length > 2"
class="text-xs text-muted-foreground"
>
+{{ template.models.length - 2 }}
</span>
</div>
</div>
</template>
</CardContainer>
</div>
<!-- Initial state -->
<div
v-else
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--compass] mb-4 size-16 opacity-50" />
<p class="text-lg">{{ $t('sideToolbar.discoverPlaceholder') }}</p>
</div>
</div>
<!-- Pagination (inside v-else block) -->
<div
v-if="results && results.totalPages > 1"
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
>
<Button
variant="secondary"
size="md"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
<i class="icon-[lucide--chevron-left]" />
</Button>
<span class="px-4 text-sm text-muted-foreground">
{{ currentPage + 1 }} / {{ results.totalPages }}
</span>
<Button
variant="secondary"
size="md"
:disabled="currentPage >= results.totalPages - 1"
@click="goToPage(currentPage + 1)"
>
<i class="icon-[lucide--chevron-right]" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import WorkflowDetailView from '@/components/discover/WorkflowDetailView.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { search, isLoading, results } = useWorkflowTemplateSearch()
const searchQuery = ref('')
const currentPage = ref(0)
const hoveredTemplate = ref<string | null>(null)
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
const selectedTags = ref<Array<{ name: string; value: string }>>([])
const selectedModels = ref<Array<{ name: string; value: string }>>([])
const openSourceOnly = ref(false)
const cloudOnly = ref(true)
// Store initial facet values to preserve filter options
const initialFacets = ref<Record<string, Record<string, number>> | null>(null)
const tagOptions = computed(() => {
const facets = initialFacets.value?.tags ?? results.value?.facets?.tags
if (!facets) return []
return Object.entries(facets).map(([tag, count]) => ({
name: `${tag} (${count})`,
value: tag
}))
})
const modelOptions = computed(() => {
const facets = initialFacets.value?.models ?? results.value?.facets?.models
if (!facets) return []
return Object.entries(facets).map(([model, count]) => ({
name: `${model} (${count})`,
value: model
}))
})
function buildFacetFilters(): string[][] {
const filters: string[][] = []
if (selectedTags.value.length > 0) {
filters.push(selectedTags.value.map((t) => `tags:${t.value}`))
}
if (selectedModels.value.length > 0) {
filters.push(selectedModels.value.map((m) => `models:${m.value}`))
}
if (openSourceOnly.value) {
filters.push(['open_source:true'])
}
return filters
}
async function performSearch() {
const result = await search({
query: searchQuery.value,
pageSize: 24,
pageNumber: currentPage.value,
facetFilters: buildFacetFilters()
})
// Store initial facets on first search (no filters applied)
if (!initialFacets.value && result.facets) {
initialFacets.value = result.facets
}
}
function handleSearch() {
currentPage.value = 0
performSearch()
}
function goToPage(page: number) {
currentPage.value = page
performSearch()
}
function handleTemplateClick(template: AlgoliaWorkflowTemplate) {
selectedWorkflow.value = template
}
function handleAuthorClick(template: AlgoliaWorkflowTemplate) {
if (!template.author_name) return
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
template.author_name
)}`,
'_blank'
)
}
function handleAuthorSelected(author: { name: string; avatarUrl?: string }) {
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(author.name)}`,
'_blank'
)
}
function handleRunWorkflow(_workflow: AlgoliaWorkflowTemplate) {
// TODO: Implement workflow run
}
function handleMakeCopy(_workflow: AlgoliaWorkflowTemplate) {
// TODO: Implement make a copy
}
watch(
[selectedTags, selectedModels, openSourceOnly, cloudOnly],
() => {
currentPage.value = 0
performSearch()
},
{ deep: true }
)
onMounted(() => {
performSearch()
})
</script>

View File

@@ -1,268 +0,0 @@
<template>
<div class="flex size-full flex-col">
<!-- Header with back button and actions -->
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
>
<Button variant="secondary" size="md" @click="emit('back')">
<i class="icon-[lucide--arrow-left]" />
{{ $t('g.back') }}
</Button>
<h1 class="flex-1 truncate text-xl font-semibold text-base-foreground">
{{ workflow.title }}
</h1>
<div class="flex items-center gap-2">
<Button variant="primary" size="md" @click="handleMakeCopy">
<i class="icon-[lucide--copy]" />
{{ $t('discover.detail.makeCopy') }}
</Button>
<Button variant="secondary" size="md" @click="openHubWorkflow">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.detail.openInHub') }}
</Button>
</div>
</div>
<!-- Two-column layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Left column: Workflow info -->
<div class="flex w-80 shrink-0 flex-col border-r border-interface-stroke">
<div class="flex-1 space-y-5 overflow-y-auto p-4">
<!-- Thumbnail -->
<div
class="aspect-video overflow-hidden rounded-lg bg-dialog-surface"
>
<LazyImage
:src="workflow.thumbnail_url"
:alt="workflow.title"
class="size-full object-cover"
/>
</div>
<!-- Author -->
<button
type="button"
:disabled="!hasAuthor"
:class="
cn(
'flex w-full items-center gap-3 rounded-lg border border-transparent p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle',
hasAuthor
? 'hover:bg-secondary-background'
: 'cursor-default opacity-80'
)
"
@click="handleAuthorClick"
>
<img
:src="authorAvatar"
:alt="authorName"
class="size-8 rounded-full bg-secondary-background object-cover"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-base-foreground">
{{ authorName }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ $t('discover.detail.officialWorkflow') }}
</div>
</div>
</button>
<!-- Stats -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--play] size-3.5" />
<span>{{ formatCount(runCount) }}</span>
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--eye] size-3.5" />
<span>{{ formatCount(viewCount) }}</span>
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--copy] size-3.5" />
<span>{{ formatCount(copyCount) }}</span>
</div>
</div>
<!-- Description -->
<div class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('g.description') }}
</h3>
<p class="whitespace-pre-wrap text-sm text-base-foreground">
{{ workflow.description }}
</p>
</div>
<!-- Tags -->
<div v-if="workflow.tags.length > 0" class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.filters.tags') }}
</h3>
<div class="flex flex-wrap gap-1">
<SquareChip
v-for="tag in workflow.tags"
:key="tag"
:label="tag"
/>
</div>
</div>
<!-- Models -->
<div v-if="workflow.models.length > 0" class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.filters.models') }}
</h3>
<div class="flex flex-wrap gap-1">
<span
v-for="model in workflow.models"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
</div>
</div>
<!-- Open source badge -->
<div v-if="workflow.open_source" class="flex items-center gap-1.5">
<i class="icon-[lucide--unlock] size-4 text-green-500" />
<span class="text-xs text-green-500">
{{ $t('discover.detail.openSource') }}
</span>
</div>
<!-- Required custom nodes -->
<div
v-if="workflow.requires_custom_nodes.length > 0"
class="space-y-1"
>
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.detail.requiredNodes') }}
</h3>
<div class="flex flex-wrap gap-1">
<span
v-for="node in workflow.requires_custom_nodes"
:key="node"
class="rounded bg-warning-background px-1.5 py-0.5 text-xs text-warning-foreground"
>
{{ node }}
</span>
</div>
</div>
</div>
</div>
<!-- Right column: Workflow preview -->
<div class="min-h-0 min-w-0 flex-1">
<WorkflowPreviewCanvas :workflow-url="workflow.workflow_url" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SquareChip from '@/components/chip/SquareChip.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import WorkflowPreviewCanvas from '@/components/discover/WorkflowPreviewCanvas.vue'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { cn } from '@/utils/tailwindUtil'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { t } = useI18n()
const { workflow } = defineProps<{
workflow: AlgoliaWorkflowTemplate
}>()
const emit = defineEmits<{
back: []
makeCopy: [workflow: AlgoliaWorkflowTemplate]
}>()
const hasAuthor = computed(() => !!workflow.author_name)
const authorName = computed(
() => workflow.author_name ?? t('discover.detail.author')
)
const authorAvatar = computed(
() => workflow.author_avatar_url ?? '/assets/images/comfy-logo-single.svg'
)
const hubWorkflowBaseUrl = 'https://comfy-hub.vercel.app/workflows'
const runCount = computed(() => workflow.run_count ?? 1_234)
const viewCount = computed(() => workflow.view_count ?? 5_678)
const copyCount = computed(() => workflow.copy_count ?? 890)
function formatCount(count: number): string {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`
}
return count.toString()
}
const handleAuthorClick = () => {
if (!workflow.author_name) return
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
workflow.author_name
)}`,
'_blank'
)
}
const openHubWorkflow = () => {
window.open(`${hubWorkflowBaseUrl}/${workflow.objectID}`, '_blank')
}
const loadWorkflowFromUrl = async () => {
if (!workflow.workflow_url) return false
// Check that app canvas and graph are initialized
if (!app.canvas?.graph) {
useToastStore().addAlert(t('discover.detail.appNotReady'))
return false
}
try {
const response = await fetch(workflow.workflow_url)
if (!response.ok) {
throw new Error(`Failed to fetch workflow: ${response.status}`)
}
const workflowData = await response.json()
await app.loadGraphData(workflowData, true, true, workflow.title, {
openSource: 'template'
})
// Close overlay panels to show the new workflow
useSidebarTabStore().activeSidebarTabId = null
useHomePanelStore().closePanel()
return true
} catch (error) {
useToastStore().addAlert(
t('discover.detail.makeCopyFailed', { error: String(error) })
)
return false
}
}
async function handleMakeCopy() {
const didLoad = await loadWorkflowFromUrl()
if (didLoad) {
emit('makeCopy', workflow)
}
}
</script>

View File

@@ -1,188 +0,0 @@
<template>
<div ref="containerRef" class="relative size-full min-h-0 bg-graph-canvas">
<canvas ref="canvasRef" class="absolute left-0 top-0" />
<!-- Loading state -->
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center"
>
<i
class="icon-[lucide--loader-2] size-8 animate-spin text-muted-foreground"
/>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground"
>
<i class="icon-[lucide--alert-circle] size-8" />
<span class="text-sm">{{ $t('discover.detail.previewError') }}</span>
<span class="text-xs opacity-50">{{ error.message }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="!workflowUrl"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
>
<i class="icon-[lucide--workflow] size-16 opacity-30" />
<span class="text-sm">{{ $t('discover.detail.workflowPreview') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import {
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch
} from 'vue'
import { LGraph } from '@/lib/litegraph/src/LGraph'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const { workflowUrl } = defineProps<{
workflowUrl?: string
}>()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const graph = shallowRef<LGraph>()
const canvas = shallowRef<LGraphCanvas>()
const isLoading = ref(false)
const error = ref<Error | null>(null)
const isInitialized = ref(false)
function updateCanvasSize() {
if (!canvasRef.value || !containerRef.value || !canvas.value) return
const rect = containerRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const dpr = Math.max(window.devicePixelRatio, 1)
canvas.value.resize(
Math.round(rect.width * dpr),
Math.round(rect.height * dpr)
)
}
function initCanvas() {
if (!canvasRef.value || !containerRef.value || isInitialized.value) return
const rect = containerRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const dpr = Math.max(window.devicePixelRatio, 1)
canvasRef.value.width = Math.round(rect.width * dpr)
canvasRef.value.height = Math.round(rect.height * dpr)
graph.value = new LGraph()
canvas.value = new LGraphCanvas(canvasRef.value, graph.value, {
skip_render: true
})
canvas.value.startRendering()
isInitialized.value = true
}
function fitGraphToCanvas() {
if (!graph.value || !canvas.value) return
const nodes = graph.value.nodes
if (nodes.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of 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])
}
const graphWidth = maxX - minX
const graphHeight = maxY - minY
const dpr = Math.max(window.devicePixelRatio, 1)
const canvasWidth = canvas.value.canvas.width / dpr
const canvasHeight = canvas.value.canvas.height / dpr
const padding = 50
if (graphWidth <= 0 || graphHeight <= 0) return
if (canvasWidth <= 0 || canvasHeight <= 0) return
const scaleX = (canvasWidth - padding * 2) / graphWidth
const scaleY = (canvasHeight - padding * 2) / graphHeight
const scale = Math.min(scaleX, scaleY, 1)
canvas.value.ds.scale = scale
canvas.value.ds.offset[0] = -minX + padding / scale
canvas.value.ds.offset[1] = -minY + padding / scale
canvas.value.setDirty(true, true)
}
async function loadWorkflow() {
if (!workflowUrl) return
// Wait for canvas to be initialized
if (!isInitialized.value) {
await nextTick()
initCanvas()
}
if (!graph.value || !canvas.value) return
isLoading.value = true
error.value = null
try {
const response = await fetch(workflowUrl)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const data = await response.json()
// Check if node types are registered
const registeredTypes = Object.keys(LiteGraph.registered_node_types)
if (registeredTypes.length === 0) {
throw new Error('No node types registered yet')
}
graph.value.configure(data)
await nextTick()
updateCanvasSize()
fitGraphToCanvas()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}
useResizeObserver(containerRef, () => {
updateCanvasSize()
fitGraphToCanvas()
})
watch(
() => workflowUrl,
() => {
loadWorkflow()
}
)
onMounted(async () => {
await nextTick()
initCanvas()
await loadWorkflow()
})
onBeforeUnmount(() => {
canvas.value?.stopRendering()
})
</script>

View File

@@ -21,23 +21,16 @@
</div>
</div>
</template>
<template v-if="showUI && !isHomePanelOpen" #side-toolbar>
<template v-if="showUI" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI && !isFullPageOverlayActive" #side-bar-panel>
<template v-if="showUI" #side-bar-panel>
<div
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto"
>
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && isFullPageOverlayActive" #full-page-content>
<HomePanel v-if="isHomePanelOpen" />
<ExtensionSlot
v-else-if="activeSidebarTab"
:extension="activeSidebarTab"
/>
</template>
<template v-if="showUI" #topmenu>
<TopMenuSection />
</template>
@@ -45,8 +38,7 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<SharePanel v-if="sharePanelStore.isOpen" />
<NodePropertiesPanel v-else />
<NodePropertiesPanel />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
@@ -113,8 +105,6 @@ import {
watchEffect
} from 'vue'
import SharePanel from '@/components/actionbar/SharePanel.vue'
import HomePanel from '@/components/home/HomePanel.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -167,9 +157,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -194,8 +182,6 @@ const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const sharePanelStore = useSharePanelStore()
const homePanelStore = useHomePanelStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
@@ -219,13 +205,6 @@ const selectionToolboxEnabled = computed(() =>
const activeSidebarTab = computed(() => {
return workspaceStore.sidebarTab.activeSidebarTab
})
const isFullPageTabActive = computed(() => {
return workspaceStore.sidebarTab.isFullPageTabActive
})
const isHomePanelOpen = computed(() => homePanelStore.isOpen)
const isFullPageOverlayActive = computed(
() => isFullPageTabActive.value || isHomePanelOpen.value
)
const showUI = computed(
() => !workspaceStore.focusMode && betaMenuEnabled.value
)

View File

@@ -1,70 +0,0 @@
<template>
<div class="flex h-full w-full bg-comfy-menu-bg">
<aside
class="flex w-56 shrink-0 flex-col gap-2 border-r border-border-default bg-comfy-menu-bg p-3"
>
<div
class="px-2 pb-2 pt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('home.title') }}
</div>
<button
:class="
cn(
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'recents'
? 'bg-secondary-background text-base-foreground border-border-hover'
: 'text-base-foreground/80 hover:bg-secondary-background'
)
"
@click="activeTab = 'recents'"
>
<i class="icon-[lucide--clock] size-4" />
{{ $t('home.tabs.recents') }}
</button>
<button
:class="
cn(
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'discover'
? 'bg-secondary-background text-base-foreground border-border-hover'
: 'text-base-foreground/80 hover:bg-secondary-background'
)
"
@click="activeTab = 'discover'"
>
<i class="icon-[lucide--compass] size-4" />
{{ $t('home.tabs.discover') }}
</button>
</aside>
<main class="flex min-w-0 flex-1 flex-col">
<div
v-if="activeTab === 'recents'"
class="flex flex-1 items-center justify-center"
>
<div class="flex max-w-md flex-col items-center gap-2 text-center">
<i class="icon-[lucide--clock] size-10 text-muted-foreground" />
<h2 class="text-lg font-semibold text-base-foreground">
{{ $t('home.recentsStubTitle') }}
</h2>
<p class="text-sm text-muted-foreground">
{{ $t('home.recentsStubDescription') }}
</p>
</div>
</div>
<DiscoverView v-else class="flex-1" />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DiscoverView from '@/components/discover/DiscoverView.vue'
import { cn } from '@/utils/tailwindUtil'
type HomeTab = 'recents' | 'discover'
const activeTab = ref<HomeTab>('discover')
</script>

View File

@@ -19,7 +19,6 @@
>
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton />
<SidebarTemplatesButton :is-small="isSmall" />
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
@@ -33,6 +32,7 @@
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
@@ -64,14 +64,13 @@ import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -81,6 +80,7 @@ import { cn } from '@/utils/tailwindUtil'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -116,8 +116,6 @@
icon="pi pi-folder"
:title="$t('g.empty')"
:message="$t('g.noWorkflowsFound')"
:button-label="$t('sideToolbar.browseTemplates')"
@action="openTemplates"
/>
</div>
</div>
@@ -157,7 +155,6 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -306,12 +303,6 @@ const selectionKeys = computed(() => ({
}))
const workflowBookmarkStore = useWorkflowBookmarkStore()
const { show } = useWorkflowTemplateSelectorDialog()
function openTemplates() {
show('sidebar')
}
onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()

View File

@@ -4,25 +4,6 @@
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-tooltip.bottom="{ value: $t('home.title'), showDelay: 300 }"
class="no-drag shrink-0 rounded-none h-full w-auto aspect-square"
:class="
cn(
'transition-colors',
homePanelStore.isOpen
? 'bg-secondary-background text-base-foreground'
: 'text-muted-foreground hover:bg-secondary-background'
)
"
variant="muted-textonly"
size="icon"
:aria-label="$t('home.title')"
:aria-pressed="homePanelStore.isOpen"
@click="toggleHomePanel"
>
<i class="icon-[lucide--home] size-4" />
</Button>
<Button
v-if="showOverflowArrows"
variant="muted-textonly"
@@ -132,14 +113,10 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { cn } from '@/utils/tailwindUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
@@ -158,9 +135,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const homePanelStore = useHomePanelStore()
const sidebarTabStore = useSidebarTabStore()
const canvasStore = useCanvasStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
@@ -200,20 +174,9 @@ const onWorkflowChange = async (option: WorkflowOption) => {
return
}
homePanelStore.closePanel()
await workflowService.openWorkflow(option.workflow)
}
const toggleHomePanel = () => {
if (homePanelStore.isOpen) {
homePanelStore.closePanel()
return
}
sidebarTabStore.activeSidebarTabId = null
canvasStore.linearMode = false
homePanelStore.openPanel()
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (

View File

@@ -276,7 +276,7 @@ describe('useSelectedLiteGraphItems', () => {
expect(selectedNodes).toContainEqual(subNode2)
})
it('toggleSelectedNodesMode should not apply state to subgraph children', () => {
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
@@ -294,8 +294,9 @@ describe('useSelectedLiteGraphItems', () => {
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
// Subgraph children do not change state
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS) // was ALWAYS, stays ALWAYS
// Subgraph children get unified state (same as their parent):
// Both children should now be NEVER, regardless of their previous states
expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
})
@@ -316,9 +317,9 @@ describe('useSelectedLiteGraphItems', () => {
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
// All children should be unchanged
// All children should also get ALWAYS (unified with parent's new state)
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.BYPASS)
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
})
})

View File

@@ -2,7 +2,10 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import {
collectFromNodes,
traverseNodesDepthFirst
} from '@/utils/graphTraversalUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -94,10 +97,16 @@ export function useSelectedLiteGraphItems() {
}
/**
* Toggle the execution mode of all selected nodes
* Toggle the execution mode of all selected nodes with unified subgraph behavior.
*
* - If any nodes are not already the specified node mode → all are set to specified mode
* - Otherwise → set all nodes to ALWAYS
* Top-level behavior (selected nodes): Standard toggle logic
* - If the selected node is already in the specified mode → set to ALWAYS
* - Otherwise → set to the specified mode
*
* Subgraph behavior (children of selected subgraph nodes): Unified state application
* - All children inherit the same mode that their parent subgraph node was set to
* - This creates predictable behavior: if you toggle a subgraph to "mute",
* ALL nodes inside become muted, regardless of their previous individual states
*
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
@@ -115,8 +124,27 @@ export function useSelectedLiteGraphItems() {
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
for (const selectedNode of selectedNodeArray)
// Process each selected node independently to determine its target state and apply to children
selectedNodeArray.forEach((selectedNode) => {
// Apply standard toggle logic to the selected node itself
selectedNode.mode = newModeForSelectedNode
// If this selected node is a subgraph, apply the same mode uniformly to all its children
// This ensures predictable behavior: all children get the same state as their parent
if (selectedNode.isSubgraphNode?.() && selectedNode.subgraph) {
traverseNodesDepthFirst([selectedNode], {
visitor: (node) => {
// Skip the parent node since we already handled it above
if (node === selectedNode) return undefined
// Apply the parent's new mode to all children uniformly
node.mode = newModeForSelectedNode
return undefined
}
})
}
})
}
return {

View File

@@ -1,100 +0,0 @@
import type { SearchResponse } from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { ref } from 'vue'
import type {
AlgoliaWorkflowTemplate,
WorkflowTemplateSearchParams,
WorkflowTemplateSearchResult
} from '@/types/discoverTypes'
const ALGOLIA_APP_ID = 'CSTOFZ5FPH'
const ALGOLIA_SEARCH_KEY = 'bbb304247bd251791b8653e9a816b322'
const INDEX_NAME = 'workflow_templates'
const RETRIEVE_ATTRIBUTES = [
'objectID',
'name',
'title',
'description',
'thumbnail_url',
'thumbnail_urls',
'thumbnail_count',
'thumbnail_variant',
'media_type',
'media_subtype',
'tags',
'models',
'open_source',
'requires_custom_nodes',
'author_name',
'author_avatar_url',
'run_count',
'view_count',
'copy_count',
'workflow_url'
] as const
export function useWorkflowTemplateSearch() {
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY)
const isLoading = ref(false)
const error = ref<Error | null>(null)
const results = ref<WorkflowTemplateSearchResult | null>(null)
async function search(
params: WorkflowTemplateSearchParams
): Promise<WorkflowTemplateSearchResult> {
isLoading.value = true
error.value = null
try {
const response = await searchClient.search<AlgoliaWorkflowTemplate>({
requests: [
{
indexName: INDEX_NAME,
query: params.query,
attributesToRetrieve: [...RETRIEVE_ATTRIBUTES],
hitsPerPage: params.pageSize,
page: params.pageNumber,
filters: params.filters,
facetFilters: params.facetFilters,
facets: ['tags', 'models', 'media_type', 'open_source']
}
]
})
const searchResponse = response
.results[0] as SearchResponse<AlgoliaWorkflowTemplate>
const result: WorkflowTemplateSearchResult = {
templates: searchResponse.hits,
totalHits: searchResponse.nbHits ?? 0,
totalPages: searchResponse.nbPages ?? 0,
page: searchResponse.page ?? 0,
facets: searchResponse.facets
}
results.value = result
return result
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
throw error.value
} finally {
isLoading.value = false
}
}
function clearResults() {
results.value = null
error.value = null
}
return {
search,
clearResults,
isLoading,
error,
results
}
}

View File

@@ -1,4 +1,4 @@
import { refDebounced, watchDebounced } from '@vueuse/core'
import { refThrottled, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
)
})
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,4 +1,4 @@
import type { Keybinding } from './types'
import type { Keybinding } from '@/schemas/keyBindingSchema'
export const CORE_KEYBINDINGS: Keybinding[] = [
{
@@ -76,6 +76,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.ShowSettingsDialog'
},
// For '=' both holding shift and not holding shift
{
combo: {
key: '=',
@@ -93,6 +94,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
commandId: 'Comfy.Canvas.ZoomIn',
targetElementId: 'graph-canvas'
},
// For number pad '+'
{
combo: {
key: '+',

View File

@@ -292,10 +292,10 @@ export class GroupNodeConfig {
this.processNode(node, seenInputs, seenOutputs)
}
for (const p of this._convertedToProcess) {
for (const p of this.#convertedToProcess) {
p()
}
this._convertedToProcess = []
this.#convertedToProcess = []
if (!this.nodeDef) return
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(this.nodeDef)
@@ -773,7 +773,7 @@ export class GroupNodeConfig {
}
}
private _convertedToProcess: (() => void)[] = []
#convertedToProcess: (() => void)[] = []
processNodeInputs(
node: GroupNodeData,
seenInputs: Record<string, number>,
@@ -804,7 +804,7 @@ export class GroupNodeConfig {
)
// Converted inputs have to be processed after all other nodes as they'll be at the end of the list
this._convertedToProcess.push(() =>
this.#convertedToProcess.push(() =>
this.processConvertedWidgets(
inputs,
node,

View File

@@ -103,7 +103,7 @@ export class PrimitiveNode extends LGraphNode {
override onAfterGraphConfigured() {
if (this.outputs[0].links?.length && !this.widgets?.length) {
this._onFirstConnection()
this.#onFirstConnection()
// Populate widget values from config data
if (this.widgets && this.widgets_values) {
@@ -116,7 +116,7 @@ export class PrimitiveNode extends LGraphNode {
}
// Merge values if required
this._mergeWidgetConfig()
this.#mergeWidgetConfig()
}
}
@@ -133,11 +133,11 @@ export class PrimitiveNode extends LGraphNode {
const links = this.outputs[0].links
if (connected) {
if (links?.length && !this.widgets?.length) {
this._onFirstConnection()
this.#onFirstConnection()
}
} else {
// We may have removed a link that caused the constraints to change
this._mergeWidgetConfig()
this.#mergeWidgetConfig()
if (!links?.length) {
this.onLastDisconnect()
@@ -159,7 +159,7 @@ export class PrimitiveNode extends LGraphNode {
}
if (this.outputs[slot].links?.length) {
const valid = this._isValidConnection(input)
const valid = this.#isValidConnection(input)
if (valid) {
// On connect of additional outputs, copy our value to their widget
this.applyToGraph([{ target_id: target_node.id, target_slot } as LLink])
@@ -170,7 +170,7 @@ export class PrimitiveNode extends LGraphNode {
return true
}
private _onFirstConnection(recreating?: boolean) {
#onFirstConnection(recreating?: boolean) {
// First connection can fire before the graph is ready on initial load so random things can be missing
if (!this.outputs[0].links || !this.graph) {
this.onLastDisconnect()
@@ -204,7 +204,7 @@ export class PrimitiveNode extends LGraphNode {
this.outputs[0].name = type
this.outputs[0].widget = widget
this._createWidget(
this.#createWidget(
widget[CONFIG] ?? config,
theirNode,
widget.name,
@@ -213,7 +213,7 @@ export class PrimitiveNode extends LGraphNode {
)
}
private _createWidget(
#createWidget(
inputData: InputSpec,
node: LGraphNode,
widgetName: string,
@@ -307,8 +307,8 @@ export class PrimitiveNode extends LGraphNode {
recreateWidget() {
const values = this.widgets?.map((w) => w.value)
this._removeWidgets()
this._onFirstConnection(true)
this.#removeWidgets()
this.#onFirstConnection(true)
if (values?.length && this.widgets) {
for (let i = 0; i < this.widgets.length; i++)
this.widgets[i].value = values[i]
@@ -316,7 +316,7 @@ export class PrimitiveNode extends LGraphNode {
return this.widgets?.[0]
}
private _mergeWidgetConfig() {
#mergeWidgetConfig() {
// Merge widget configs if the node has multiple outputs
const output = this.outputs[0]
const links = output.links ?? []
@@ -348,11 +348,11 @@ export class PrimitiveNode extends LGraphNode {
const theirInput = theirNode.inputs[link.target_slot]
// Call is valid connection so it can merge the configs when validating
this._isValidConnection(theirInput, hasConfig)
this.#isValidConnection(theirInput, hasConfig)
}
}
private _isValidConnection(input: INodeInputSlot, forceUpdate?: boolean) {
#isValidConnection(input: INodeInputSlot, forceUpdate?: boolean) {
// Only allow connections where the configs match
const output = this.outputs?.[0]
const config2 = (input.widget?.[GET_CONFIG] as () => InputSpec)?.()
@@ -367,7 +367,7 @@ export class PrimitiveNode extends LGraphNode {
)
}
private _removeWidgets() {
#removeWidgets() {
if (this.widgets) {
// Allow widgets to cleanup
for (const w of this.widgets) {
@@ -398,7 +398,7 @@ export class PrimitiveNode extends LGraphNode {
this.outputs[0].name = 'connect to widget input'
delete this.outputs[0].widget
this._removeWidgets()
this.#removeWidgets()
}
}

View File

@@ -31,17 +31,17 @@ export class CanvasPointer {
/** Maximum offset from click location */
static get maxClickDrift() {
return this._maxClickDrift
return this.#maxClickDrift
}
static set maxClickDrift(value) {
this._maxClickDrift = value
this._maxClickDrift2 = value * value
this.#maxClickDrift = value
this.#maxClickDrift2 = value * value
}
private static _maxClickDrift = 6
static #maxClickDrift = 6
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
private static _maxClickDrift2 = this._maxClickDrift ** 2
static #maxClickDrift2 = this.#maxClickDrift ** 2
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
static trackpadThreshold = 60
@@ -153,18 +153,18 @@ export class CanvasPointer {
* Therefore, simply setting this value twice will execute the first callback.
*/
get finally() {
return this._finally
return this.#finally
}
set finally(value) {
try {
this._finally?.()
this.#finally?.()
} finally {
this._finally = value
this.#finally = value
}
}
private _finally?: () => unknown
#finally?: () => unknown
constructor(element: Element) {
this.element = element
@@ -197,7 +197,7 @@ export class CanvasPointer {
// Primary button released - treat as pointerup.
if (!(e.buttons & eDown.buttons)) {
this._completeClick(e)
this.#completeClick(e)
this.reset()
return
}
@@ -209,8 +209,8 @@ export class CanvasPointer {
const longerThanBufferTime =
e.timeStamp - eDown.timeStamp > CanvasPointer.bufferTime
if (longerThanBufferTime || !this._hasSamePosition(e, eDown)) {
this._setDragStarted(e)
if (longerThanBufferTime || !this.#hasSamePosition(e, eDown)) {
this.#setDragStarted(e)
}
}
@@ -221,13 +221,13 @@ export class CanvasPointer {
up(e: CanvasPointerEvent): boolean {
if (e.button !== this.eDown?.button) return false
this._completeClick(e)
this.#completeClick(e)
const { dragStarted } = this
this.reset()
return !dragStarted
}
private _completeClick(e: CanvasPointerEvent): void {
#completeClick(e: CanvasPointerEvent): void {
const { eDown } = this
if (!eDown) return
@@ -236,11 +236,11 @@ export class CanvasPointer {
if (this.dragStarted) {
// A move event already started drag
this.onDragEnd?.(e)
} else if (!this._hasSamePosition(e, eDown)) {
} else if (!this.#hasSamePosition(e, eDown)) {
// Teleport without a move event (e.g. tab out, move, tab back)
this._setDragStarted()
this.#setDragStarted()
this.onDragEnd?.(e)
} else if (this.onDoubleClick && this._isDoubleClick()) {
} else if (this.onDoubleClick && this.#isDoubleClick()) {
// Double-click event
this.onDoubleClick(e)
this.eLastDown = undefined
@@ -258,10 +258,10 @@ export class CanvasPointer {
* @param tolerance2 The maximum distance (squared) before the positions are considered different
* @returns `true` if the two events were no more than {@link maxClickDrift} apart, otherwise `false`
*/
private _hasSamePosition(
#hasSamePosition(
a: PointerEvent,
b: PointerEvent,
tolerance2 = CanvasPointer._maxClickDrift2
tolerance2 = CanvasPointer.#maxClickDrift2
): boolean {
const drift = dist2(a.clientX, a.clientY, b.clientX, b.clientY)
return drift <= tolerance2
@@ -271,21 +271,21 @@ export class CanvasPointer {
* Checks whether the pointer is currently past the max click drift threshold.
* @returns `true` if the latest pointer event is past the the click drift threshold
*/
private _isDoubleClick(): boolean {
#isDoubleClick(): boolean {
const { eDown, eLastDown } = this
if (!eDown || !eLastDown) return false
// Use thrice the drift distance for double-click gap
const tolerance2 = (3 * CanvasPointer._maxClickDrift) ** 2
const tolerance2 = (3 * CanvasPointer.#maxClickDrift) ** 2
const diff = eDown.timeStamp - eLastDown.timeStamp
return (
diff > 0 &&
diff < CanvasPointer.doubleClickTime &&
this._hasSamePosition(eDown, eLastDown, tolerance2)
this.#hasSamePosition(eDown, eLastDown, tolerance2)
)
}
private _setDragStarted(eMove?: CanvasPointerEvent): void {
#setDragStarted(eMove?: CanvasPointerEvent): void {
this.dragStarted = true
this.onDragStart?.(this, eMove)
delete this.onDragStart
@@ -303,14 +303,14 @@ export class CanvasPointer {
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
this.lastWheelEventTime = now
if (this._isHighResWheelEvent(e, now)) {
if (this.#isHighResWheelEvent(e, now)) {
this.detectedDevice = 'mouse'
} else if (this._isWithinCooldown(timeSinceLastEvent)) {
if (this._shouldBufferLinuxEvent(e)) {
this._bufferLinuxEvent(e, now)
} else if (this.#isWithinCooldown(timeSinceLastEvent)) {
if (this.#shouldBufferLinuxEvent(e)) {
this.#bufferLinuxEvent(e, now)
}
} else {
this._updateDeviceMode(e, now)
this.#updateDeviceMode(e, now)
this.hasReceivedWheelEvent = true
}
@@ -321,7 +321,7 @@ export class CanvasPointer {
* Validates buffered high res wheel events and switches to mouse mode if pattern matches.
* @returns `true` if switched to mouse mode
*/
private _isHighResWheelEvent(event: WheelEvent, now: number): boolean {
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) {
return false
}
@@ -329,15 +329,15 @@ export class CanvasPointer {
const timeSinceBuffer = now - this.bufferedLinuxEventTime
if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) {
this._clearLinuxBuffer()
this.#clearLinuxBuffer()
return false
}
if (
event.deltaX === 0 &&
this._isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
) {
this._clearLinuxBuffer()
this.#clearLinuxBuffer()
return true
}
@@ -347,7 +347,7 @@ export class CanvasPointer {
/**
* Checks if we're within the cooldown period where mode switching is disabled.
*/
private _isWithinCooldown(timeSinceLastEvent: number): boolean {
#isWithinCooldown(timeSinceLastEvent: number): boolean {
const isFirstEvent = !this.hasReceivedWheelEvent
const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap
return !isFirstEvent && !cooldownExpired
@@ -356,23 +356,23 @@ export class CanvasPointer {
/**
* Updates the device mode based on event patterns.
*/
private _updateDeviceMode(event: WheelEvent, now: number): void {
if (this._isTrackpadPattern(event)) {
#updateDeviceMode(event: WheelEvent, now: number): void {
if (this.#isTrackpadPattern(event)) {
this.detectedDevice = 'trackpad'
} else if (this._isMousePattern(event)) {
} else if (this.#isMousePattern(event)) {
this.detectedDevice = 'mouse'
} else if (
this.detectedDevice === 'trackpad' &&
this._shouldBufferLinuxEvent(event)
this.#shouldBufferLinuxEvent(event)
) {
this._bufferLinuxEvent(event, now)
this.#bufferLinuxEvent(event, now)
}
}
/**
* Clears the buffered Linux wheel event and associated timer.
*/
private _clearLinuxBuffer(): void {
#clearLinuxBuffer(): void {
this.bufferedLinuxEvent = undefined
this.bufferedLinuxEventTime = 0
if (this.linuxBufferTimeoutId !== undefined) {
@@ -385,7 +385,7 @@ export class CanvasPointer {
* Checks if the event matches trackpad input patterns.
* @param event The wheel event to check
*/
private _isTrackpadPattern(event: WheelEvent): boolean {
#isTrackpadPattern(event: WheelEvent): boolean {
// Two-finger panning: non-zero deltaX AND deltaY
if (event.deltaX !== 0 && event.deltaY !== 0) return true
@@ -399,7 +399,7 @@ export class CanvasPointer {
* Checks if the event matches mouse wheel input patterns.
* @param event The wheel event to check
*/
private _isMousePattern(event: WheelEvent): boolean {
#isMousePattern(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
// Primary threshold for switching from trackpad to mouse
@@ -417,7 +417,7 @@ export class CanvasPointer {
* Checks if the event should be buffered as a potential Linux wheel event.
* @param event The wheel event to check
*/
private _shouldBufferLinuxEvent(event: WheelEvent): boolean {
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60
const isVerticalOnly = event.deltaX === 0
@@ -436,7 +436,7 @@ export class CanvasPointer {
* @param event The event to buffer
* @param now The current timestamp
*/
private _bufferLinuxEvent(event: WheelEvent, now: number): void {
#bufferLinuxEvent(event: WheelEvent, now: number): void {
if (this.linuxBufferTimeoutId !== undefined) {
clearTimeout(this.linuxBufferTimeoutId)
}
@@ -446,7 +446,7 @@ export class CanvasPointer {
// Set timeout to clear buffer after 10ms
this.linuxBufferTimeoutId = setTimeout(() => {
this._clearLinuxBuffer()
this.#clearLinuxBuffer()
}, CanvasPointer.maxHighResBufferTime)
}
@@ -455,7 +455,7 @@ export class CanvasPointer {
* @param deltaY1 The first deltaY value
* @param deltaY2 The second deltaY value
*/
private _isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
const absolute1 = Math.abs(deltaY1)
const absolute2 = Math.abs(deltaY2)

View File

@@ -81,7 +81,7 @@ export class DragAndScale {
* Returns `true` if the current state has changed from the previous state.
* @returns `true` if the current state has changed from the previous state, otherwise `false`.
*/
private _stateHasChanged(): boolean {
#stateHasChanged(): boolean {
const current = this.state
const previous = this.lastState
@@ -95,7 +95,7 @@ export class DragAndScale {
computeVisibleArea(viewport: Rect | undefined): void {
const { scale, offset, visible_area } = this
if (this._stateHasChanged()) {
if (this.#stateHasChanged()) {
this.onChanged?.(scale, offset)
copyState(this.state, this.lastState)
}

View File

@@ -244,7 +244,7 @@ export class LGraph
}
/** Internal only. Not required for serialisation; calculated on deserialise. */
private _lastFloatingLinkId: number = 0
#lastFloatingLinkId: number = 0
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
@@ -365,7 +365,7 @@ export class LGraph
this.reroutes.clear()
this.floatingLinksInternal.clear()
this._lastFloatingLinkId = 0
this.#lastFloatingLinkId = 0
// other scene stuff
this._groups = []
@@ -1304,7 +1304,7 @@ export class LGraph
addFloatingLink(link: LLink): LLink {
if (link.id === -1) {
link.id = ++this._lastFloatingLinkId
link.id = ++this.#lastFloatingLinkId
}
this.floatingLinksInternal.set(link.id, link)
@@ -2175,16 +2175,8 @@ export class LGraph
}
}
/**
* Custom JSON serialization to prevent circular reference errors.
* Called automatically by JSON.stringify().
*/
toJSON(): ISerialisedGraph {
return this.serialize()
}
/** @returns The drag and scale state of the first attached canvas, otherwise `undefined`. */
private _getDragAndScale(): DragAndScaleState | undefined {
#getDragAndScale(): DragAndScaleState | undefined {
const ds = this.list_of_graphcanvas?.at(0)?.ds
if (ds) return { scale: ds.scale, offset: ds.offset }
}
@@ -2224,7 +2216,7 @@ export class LGraph
// Save scale and offset
const extra = { ...this.extra }
if (LiteGraph.saveViewportWithGraph) extra.ds = this._getDragAndScale()
if (LiteGraph.saveViewportWithGraph) extra.ds = this.#getDragAndScale()
if (!extra.ds) delete extra.ds
const data: ReturnType<typeof this.asSerialisable> = {
@@ -2414,8 +2406,8 @@ export class LGraph
const floatingLink = LLink.create(linkData)
this.addFloatingLink(floatingLink)
if (floatingLink.id > this._lastFloatingLinkId)
this._lastFloatingLinkId = floatingLink.id
if (floatingLink.id > this.#lastFloatingLinkId)
this.#lastFloatingLinkId = floatingLink.id
}
}
@@ -2466,13 +2458,13 @@ export class LGraph
}
}
private _canvas?: LGraphCanvas
#canvas?: LGraphCanvas
get primaryCanvas(): LGraphCanvas | undefined {
return this.rootGraph._canvas
return this.rootGraph.#canvas
}
set primaryCanvas(canvas: LGraphCanvas) {
this.rootGraph._canvas = canvas
this.rootGraph.#canvas = canvas
}
load(url: string | Blob | URL | File, callback: () => void) {
@@ -2541,9 +2533,9 @@ export class Subgraph
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
readonly widgets: ExposedWidget[] = []
private _rootGraph: LGraph
#rootGraph: LGraph
override get rootGraph(): LGraph {
return this._rootGraph
return this.#rootGraph
}
constructor(rootGraph: LGraph, data: ExportedSubgraph) {
@@ -2551,11 +2543,11 @@ export class Subgraph
super()
this._rootGraph = rootGraph
this.#rootGraph = rootGraph
const cloned = structuredClone(data)
this._configureBase(cloned)
this._configureSubgraph(cloned)
this.#configureSubgraph(cloned)
}
getIoNodeOnPos(
@@ -2567,7 +2559,7 @@ export class Subgraph
if (outputNode.containsPoint([x, y])) return outputNode
}
private _configureSubgraph(
#configureSubgraph(
data:
| (ISerialisedGraph & ExportedSubgraph)
| (SerialisableGraph & ExportedSubgraph)
@@ -2611,7 +2603,7 @@ export class Subgraph
): boolean | undefined {
const r = super.configure(data, keep_old)
this._configureSubgraph(data)
this.#configureSubgraph(data)
return r
}

View File

@@ -316,17 +316,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
selectionChanged: false
}
private _subgraph?: Subgraph
#subgraph?: Subgraph
get subgraph(): Subgraph | undefined {
return this._subgraph
return this.#subgraph
}
set subgraph(value: Subgraph | undefined) {
if (value !== this._subgraph) {
this._subgraph = value
if (value !== this.#subgraph) {
this.#subgraph = value
if (value)
this.dispatch('litegraph:set-graph', {
oldGraph: this._subgraph,
oldGraph: this.#subgraph,
newGraph: value
})
}
@@ -361,7 +361,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
}
private _updateCursorStyle() {
#updateCursorStyle() {
if (!this.state.shouldSetCursor) return
const crosshairItems =
@@ -398,7 +398,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
set read_only(value: boolean) {
this.state.readOnly = value
this._updateCursorStyle()
this.#updateCursorStyle()
}
get isDragging(): boolean {
@@ -415,7 +415,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
set hoveringOver(value: CanvasItem) {
this.state.hoveringOver = value
this._updateCursorStyle()
this.#updateCursorStyle()
}
/** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDown isDown}. */
@@ -435,7 +435,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
set dragging_canvas(value: boolean) {
this.state.draggingCanvas = value
this._updateCursorStyle()
this.#updateCursorStyle()
}
/**
@@ -450,16 +450,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px ${LiteGraph.NODE_FONT}`
}
private _maximumFrameGap = 0
#maximumFrameGap = 0
/** Maximum frames per second to render. 0: unlimited. Default: 0 */
public get maximumFps() {
return this._maximumFrameGap > Number.EPSILON
? this._maximumFrameGap / 1000
return this.#maximumFrameGap > Number.EPSILON
? this.#maximumFrameGap / 1000
: 0
}
public set maximumFps(value) {
this._maximumFrameGap = value > Number.EPSILON ? 1000 / value : 0
this.#maximumFrameGap = value > Number.EPSILON ? 1000 / value : 0
}
/**
@@ -660,12 +660,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* The IDs of the nodes that are currently visible on the canvas. More
* performant than {@link visible_nodes} for visibility checks.
*/
private _visible_node_ids: Set<NodeId> = new Set()
#visible_node_ids: Set<NodeId> = new Set()
node_over?: LGraphNode
node_capturing_input?: LGraphNode | null
highlighted_links: Dictionary<boolean> = {}
private _visibleReroutes: Set<Reroute> = new Set()
#visibleReroutes: Set<Reroute> = new Set()
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
@@ -725,9 +725,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
NODEPANEL_IS_OPEN?: boolean
/** Once per frame check of snap to grid value. @todo Update on change. */
private _snapToGrid?: number
#snapToGrid?: number
/** Set on keydown, keyup. @todo */
private _shiftDown: boolean = false
#shiftDown: boolean = false
/** Link rendering adapter for litegraph-to-canvas integration */
linkRenderer: LitegraphLinkAdapter | null = null
@@ -735,11 +735,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
dragZoomEnabled: boolean = false
/** The start position of the drag zoom and original read-only state. */
private _dragZoomStart: {
pos: Point
scale: number
readOnly: boolean
} | null = null
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
liveSelection: boolean = false
@@ -814,7 +810,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
this.linkConnector.events.addEventListener('link-created', () =>
this._dirty()
this.#dirty()
)
// @deprecated Workaround: Keep until connecting_links is removed.
@@ -1812,7 +1808,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.dragging_canvas = false
this._dirty()
this.#dirty()
this.dirty_area = null
this.node_in_panel = null
@@ -1840,7 +1836,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.linkRenderer = new LitegraphLinkAdapter(false)
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this._dirty()
this.#dirty()
}
openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
@@ -1877,7 +1873,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @returns The canvas element
* @throws If {@link canvas} is an element ID that does not belong to a valid HTML canvas element
*/
private _validateCanvas(
#validateCanvas(
canvas: string | HTMLCanvasElement
): HTMLCanvasElement & { data?: LGraphCanvas } {
if (typeof canvas === 'string') {
@@ -1896,7 +1892,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation.
*/
setCanvas(canvas: string | HTMLCanvasElement, skip_events?: boolean) {
const element = this._validateCanvas(canvas)
const element = this.#validateCanvas(canvas)
if (element === this.canvas) return
// maybe detach events from old_canvas
if (!element && this.canvas && !skip_events) this.unbindEvents()
@@ -1909,12 +1905,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// TODO: classList.add
element.className += ' lgraphcanvas'
Object.defineProperty(element, 'data', {
value: this,
writable: true,
configurable: true,
enumerable: false
})
element.data = this
// Background canvas: To render objects behind nodes (background, links, groups)
this.bgcanvas = document.createElement('canvas')
@@ -2035,12 +2026,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/** Marks the entire canvas as dirty. */
private _dirty(): void {
#dirty(): void {
this.dirty_canvas = true
this.dirty_bgcanvas = true
}
private _linkConnectorDrop(): void {
#linkConnectorDrop(): void {
const { graph, linkConnector, pointer } = this
if (!graph) throw new NullGraphError()
@@ -2079,10 +2070,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const window = this.getCanvasWindow()
if (this.is_rendering) {
if (this._maximumFrameGap > 0) {
if (this.#maximumFrameGap > 0) {
// Manual FPS limit
const gap =
this._maximumFrameGap - (LiteGraph.getTime() - this.last_draw_time)
this.#maximumFrameGap - (LiteGraph.getTime() - this.last_draw_time)
setTimeout(renderFrame.bind(this), Math.max(1, gap))
} else {
// FPS limited by refresh rate
@@ -2170,7 +2161,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
!e.altKey &&
e.buttons
) {
this._dragZoomStart = {
this.#dragZoomStart = {
pos: [e.x, e.y],
scale: this.ds.scale,
readOnly: this.read_only
@@ -2217,9 +2208,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// left button mouse / single finger
if (e.button === 0 && !pointer.isDouble) {
this._processPrimaryButton(e, node)
this.#processPrimaryButton(e, node)
} else if (e.button === 1) {
this._processMiddleButton(e, node)
this.#processMiddleButton(e, node)
} else if (
(e.button === 2 || pointer.isDouble) &&
this.allow_interaction &&
@@ -2255,7 +2246,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this._visibleReroutes
this.#visibleReroutes
)
}
if (reroute) {
@@ -2311,24 +2302,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param y The y coordinate in canvas space
* @returns The positionable item or undefined
*/
private _getPositionableOnPos(
x: number,
y: number
): Positionable | undefined {
#getPositionableOnPos(x: number, y: number): Positionable | undefined {
const ioNode = this.subgraph?.getIoNodeOnPos(x, y)
if (ioNode) return ioNode
for (const reroute of this._visibleReroutes) {
for (const reroute of this.#visibleReroutes) {
if (reroute.containsPoint([x, y])) return reroute
}
return this.graph?.getGroupTitlebarOnPos(x, y)
}
private _processPrimaryButton(
e: CanvasPointerEvent,
node: LGraphNode | undefined
) {
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
const { pointer, graph, linkConnector, subgraph } = this
if (!graph) throw new NullGraphError()
@@ -2344,7 +2329,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
!e.altKey &&
LiteGraph.leftMouseClickBehavior === 'panning'
) {
this._setupNodeSelectionDrag(e, pointer, node)
this.#setupNodeSelectionDrag(e, pointer, node)
return
}
@@ -2375,16 +2360,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.allow_dragnodes) {
pointer.onDragStart = (pointer) => {
this._startDraggingItems(cloned, pointer)
this.#startDraggingItems(cloned, pointer)
}
pointer.onDragEnd = (e) => this._processDraggedItems(e)
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
}
return
}
// Node clicked
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
this._processNodeClick(e, ctrlOrMeta, node)
this.#processNodeClick(e, ctrlOrMeta, node)
} else {
// Subgraph IO nodes
if (subgraph) {
@@ -2402,8 +2387,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ioNode.onPointerDown(e, pointer, linkConnector)
pointer.onClick ??= () => canvas.processSelect(ioNode, e)
pointer.onDragStart ??= () =>
canvas._startDraggingItems(ioNode, pointer, true)
pointer.onDragEnd ??= (eUp) => canvas._processDraggedItems(eUp)
canvas.#startDraggingItems(ioNode, pointer, true)
pointer.onDragEnd ??= (eUp) => canvas.#processDraggedItems(eUp)
return true
}
}
@@ -2419,7 +2404,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// Fallback to checking visible reroutes directly
for (const reroute of this._visibleReroutes) {
for (const reroute of this.#visibleReroutes) {
const overReroute =
foundReroute === reroute || reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue
@@ -2428,19 +2413,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
pointer.onClick = () => this.processSelect(reroute, e)
if (!e.shiftKey) {
pointer.onDragStart = (pointer) =>
this._startDraggingItems(reroute, pointer, true)
pointer.onDragEnd = (e) => this._processDraggedItems(e)
this.#startDraggingItems(reroute, pointer, true)
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
}
}
if (reroute.isOutputHovered || (overReroute && e.shiftKey)) {
linkConnector.dragFromReroute(graph, reroute)
this._linkConnectorDrop()
this.#linkConnectorDrop()
}
if (reroute.isInputHovered) {
linkConnector.dragFromRerouteToOutput(graph, reroute)
this._linkConnectorDrop()
this.#linkConnectorDrop()
}
reroute.hideSlots()
@@ -2485,14 +2470,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (e.shiftKey && !e.altKey) {
linkConnector.dragFromLinkSegment(graph, linkSegment)
this._linkConnectorDrop()
this.#linkConnectorDrop()
return
} else if (e.altKey && !e.shiftKey) {
const newReroute = graph.createReroute([x, y], linkSegment)
pointer.onDragStart = (pointer) =>
this._startDraggingItems(newReroute, pointer)
pointer.onDragEnd = (e) => this._processDraggedItems(e)
this.#startDraggingItems(newReroute, pointer)
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
return
}
} else if (
@@ -2534,7 +2519,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
eMove.canvasY - group.pos[1] - offsetY
]
// Unless snapping.
if (this._snapToGrid) snapPoint(pos, this._snapToGrid)
if (this.#snapToGrid) snapPoint(pos, this.#snapToGrid)
const resized = group.resize(pos[0], pos[1])
if (resized) this.dirty_bgcanvas = true
@@ -2557,9 +2542,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
pointer.onClick = () => this.processSelect(group, e)
pointer.onDragStart = (pointer) => {
group.recomputeInsideNodes()
this._startDraggingItems(group, pointer, true)
this.#startDraggingItems(group, pointer, true)
}
pointer.onDragEnd = (e) => this._processDraggedItems(e)
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
}
}
@@ -2597,12 +2582,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
pointer.finally = () => (this.dragging_canvas = false)
this.dragging_canvas = true
} else {
this._setupNodeSelectionDrag(e, pointer)
this.#setupNodeSelectionDrag(e, pointer)
}
}
}
private _setupNodeSelectionDrag(
#setupNodeSelectionDrag(
e: CanvasPointerEvent,
pointer: CanvasPointer,
node?: LGraphNode | undefined
@@ -2617,7 +2602,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
pointer.onClick = (eUp) => {
// Click, not drag
const clickedItem =
node ?? this._getPositionableOnPos(eUp.canvasX, eUp.canvasY)
node ?? this.#getPositionableOnPos(eUp.canvasX, eUp.canvasY)
this.processSelect(clickedItem, eUp)
}
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
@@ -2632,7 +2617,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else {
// Classic mode: select only when drag ends
pointer.onDragEnd = (upEvent) =>
this._handleMultiSelect(upEvent, dragRect)
this.#handleMultiSelect(upEvent, dragRect)
}
pointer.finally = () => (this.dragging_rectangle = null)
@@ -2644,7 +2629,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param ctrlOrMeta Ctrl or meta key is pressed
* @param node The node to process a click event for
*/
private _processNodeClick(
#processNodeClick(
e: CanvasPointerEvent,
ctrlOrMeta: boolean,
node: LGraphNode
@@ -2700,13 +2685,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
this._linkConnectorDrop()
this.#linkConnectorDrop()
return
}
// New output link
linkConnector.dragNewFromOutput(graph, node, output)
this._linkConnectorDrop()
this.#linkConnectorDrop()
if (LiteGraph.shift_click_do_break_link_from) {
if (e.shiftKey) {
@@ -2759,7 +2744,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
linkConnector.dragNewFromInput(graph, node, input)
}
this._linkConnectorDrop()
this.#linkConnectorDrop()
this.dirty_bgcanvas = true
return
@@ -2889,7 +2874,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// Apply snapping to position changes
if (this._snapToGrid) {
if (this.#snapToGrid) {
if (
resizeDirection.includes('N') ||
resizeDirection.includes('W')
@@ -2897,7 +2882,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const originalX = newBounds.x
const originalY = newBounds.y
snapPoint(newBounds.pos, this._snapToGrid)
snapPoint(newBounds.pos, this.#snapToGrid)
// Adjust size to compensate for snapped position
if (resizeDirection.includes('N')) {
@@ -2908,7 +2893,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
snapPoint(newBounds.size, this._snapToGrid)
snapPoint(newBounds.size, this.#snapToGrid)
}
// Apply snapping to size changes
@@ -2933,11 +2918,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node.pos = newBounds.pos
node.setSize(newBounds.size)
this._dirty()
this.#dirty()
}
pointer.onDragEnd = () => {
this._dirty()
this.#dirty()
graph.afterChange(node)
}
pointer.finally = () => {
@@ -2953,8 +2938,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Drag node
pointer.onDragStart = (pointer) =>
this._startDraggingItems(node, pointer, true)
pointer.onDragEnd = (e) => this._processDraggedItems(e)
this.#startDraggingItems(node, pointer, true)
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
}
this.dirty_canvas = true
@@ -3023,10 +3008,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param e The pointerdown event
* @param node The node to process a click event for
*/
private _processMiddleButton(
e: CanvasPointerEvent,
node: LGraphNode | undefined
) {
#processMiddleButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
const { pointer } = this
if (
@@ -3123,14 +3105,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
private _processDragZoom(e: PointerEvent): void {
#processDragZoom(e: PointerEvent): void {
// stop canvas zoom action
if (!e.buttons) {
this._finishDragZoom()
this.#finishDragZoom()
return
}
const start = this._dragZoomStart
const start = this.#dragZoomStart
if (!start) throw new TypeError('Drag-zoom state object was null')
if (!this.graph) throw new NullGraphError()
@@ -3144,10 +3126,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.graph.change()
}
private _finishDragZoom(): void {
const start = this._dragZoomStart
#finishDragZoom(): void {
const start = this.#dragZoomStart
if (!start) return
this._dragZoomStart = null
this.#dragZoomStart = null
this.read_only = start.readOnly
}
@@ -3159,9 +3141,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.dragZoomEnabled &&
e.ctrlKey &&
e.shiftKey &&
this._dragZoomStart
this.#dragZoomStart
) {
this._processDragZoom(e)
this.#processDragZoom(e)
return
}
@@ -3228,7 +3210,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else if (this.dragging_canvas) {
this.ds.offset[0] += delta[0] / this.ds.scale
this.ds.offset[1] += delta[1] / this.ds.scale
this._dirty()
this.#dirty()
} else if (
(this.allow_interaction || node?.flags.allow_interaction) &&
!this.read_only
@@ -3276,7 +3258,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_over = node
this.dirty_canvas = true
for (const reroute of this._visibleReroutes) {
for (const reroute of this.#visibleReroutes) {
reroute.hideSlots()
this.dirty_bgcanvas = true
}
@@ -3400,10 +3382,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
} else {
// Reroutes
underPointer = this._updateReroutes(underPointer)
underPointer = this.#updateReroutes(underPointer)
// Not over a node
const segment = this._getLinkCentreOnPos(e)
const segment = this.#getLinkCentreOnPos(e)
if (this.over_link_center !== segment) {
underPointer |= CanvasItem.Link
this.over_link_center = segment
@@ -3453,7 +3435,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
this._dirty()
this.#dirty()
}
}
@@ -3467,14 +3449,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* Updates the hover / snap state of all visible reroutes.
* @returns The original value of {@link underPointer}, with any found reroute items added.
*/
private _updateReroutes(underPointer: CanvasItem): CanvasItem {
#updateReroutes(underPointer: CanvasItem): CanvasItem {
const { graph, pointer, linkConnector } = this
if (!graph) throw new NullGraphError()
// Update reroute hover state
if (!pointer.isDown) {
let anyChanges = false
for (const reroute of this._visibleReroutes) {
for (const reroute of this.#visibleReroutes) {
anyChanges ||= reroute.updateVisibility(this.graph_mouse)
if (reroute.isSlotHovered) underPointer |= CanvasItem.RerouteSlot
@@ -3482,7 +3464,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (anyChanges) this.dirty_bgcanvas = true
} else if (linkConnector.isConnecting) {
// Highlight the reroute that the mouse is over
for (const reroute of this._visibleReroutes) {
for (const reroute of this.#visibleReroutes) {
if (reroute.containsPoint(this.graph_mouse)) {
if (linkConnector.isRerouteValidDrop(reroute)) {
linkConnector.overReroute = reroute
@@ -3507,7 +3489,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param pointer The pointer event that initiated the drag, e.g. pointerdown
* @param sticky If `true`, the item is added to the selection - see {@link processSelect}
*/
private _startDraggingItems(
#startDraggingItems(
item: Positionable,
pointer: CanvasPointer,
sticky = false
@@ -3529,7 +3511,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* Handles shared clean up and placement after items have been dragged.
* @param e The event that completed the drag, e.g. pointerup, pointermove
*/
private _processDraggedItems(e: CanvasPointerEvent): void {
#processDraggedItems(e: CanvasPointerEvent): void {
const { graph } = this
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
graph?.snapToGrid(this.selectedItems)
@@ -3551,7 +3533,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const { graph, pointer } = this
if (!graph) return
this._finishDragZoom()
this.#finishDragZoom()
LGraphCanvas.active_canvas = this
@@ -3695,7 +3677,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
private _noItemsSelected(): void {
#noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
@@ -3706,7 +3688,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* process a key event
*/
processKey(e: KeyboardEvent): void {
this._shiftDown = e.shiftKey
this.#shiftDown = e.shiftKey
const { graph } = this
if (!graph) return
@@ -3753,7 +3735,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
this.#noItemsSelected()
return
}
@@ -4115,7 +4097,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param dragRect The drag rectangle to normalize (modified in place)
* @returns The normalized rectangle
*/
private _normalizeDragRect(dragRect: Rect): Rect {
#normalizeDragRect(dragRect: Rect): Rect {
const w = Math.abs(dragRect[2])
const h = Math.abs(dragRect[3])
if (dragRect[2] < 0) dragRect[0] -= w
@@ -4130,7 +4112,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param rect The rectangle to check against
* @returns Set of positionable items that overlap with the rectangle
*/
private _getItemsInRect(rect: Rect): Set<Positionable> {
#getItemsInRect(rect: Rect): Set<Positionable> {
const { graph, subgraph } = this
if (!graph) throw new NullGraphError()
@@ -4184,9 +4166,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
dragRect[2],
dragRect[3]
]
this._normalizeDragRect(normalizedRect)
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this._getItemsInRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const desired = new Set<Positionable>()
if (e.shiftKey && !e.altKey) {
@@ -4233,16 +4215,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param e The pointer up event
* @param dragRect The drag rectangle
*/
private _handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
const normalizedRect: Rect = [
dragRect[0],
dragRect[1],
dragRect[2],
dragRect[3]
]
this._normalizeDragRect(normalizedRect)
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this._getItemsInRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const { selectedItems } = this
if (e.shiftKey) {
@@ -4606,7 +4588,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
*/
setZoom(value: number, zooming_center: Point) {
this.ds.changeScale(value, zooming_center)
this._dirty()
this.#dirty()
}
/**
@@ -4689,7 +4671,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @returns `true` if the node is visible, otherwise `false`
*/
isNodeVisible(node: LGraphNode): boolean {
return this._visible_node_ids.has(node.id)
return this.#visible_node_ids.has(node.id)
}
/**
@@ -4710,7 +4692,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.dirty_canvas || force_canvas) {
this.computeVisibleNodes(undefined, this.visible_nodes)
// Update visible node IDs
this._visible_node_ids = new Set(
this.#visible_node_ids = new Set(
this.visible_nodes.map((node) => node.id)
)
@@ -4764,8 +4746,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// TODO: Set snapping value when changed instead of once per frame
this._snapToGrid =
this._shiftDown || LiteGraph.alwaysSnapToGrid
this.#snapToGrid =
this.#shiftDown || LiteGraph.alwaysSnapToGrid
? this.graph?.getSnapToGridSize()
: undefined
@@ -4807,7 +4789,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// draw nodes
const { visible_nodes } = this
const drawSnapGuides =
this._snapToGrid &&
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const node of visible_nodes) {
@@ -4847,7 +4829,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (linkConnector.isConnecting) {
// current connection (the one being dragged by the mouse)
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()
const highlightPos = this.#getHighlightPosition()
ctx.lineWidth = this.connections_width
for (const renderLink of renderLinks) {
@@ -4901,7 +4883,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// Gradient half-border over target node
this._renderSnapHighlight(ctx, highlightPos)
this.#renderSnapHighlight(ctx, highlightPos)
}
// on top of link center
@@ -4927,7 +4909,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
private _getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
// Skip hit detection if center markers are disabled
if (this.linkMarkerShape === LinkMarkerShape.None) {
return undefined
@@ -4946,7 +4928,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/** Get the target snap / highlight point in graph space */
private _getHighlightPosition(): Readonly<Point> {
#getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy
? (this.linkConnector.state.snapLinksPos ??
this._highlight_pos ??
@@ -4959,7 +4941,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* Partial border over target node and a highlight over the slot itself.
* @param ctx Canvas 2D context
*/
private _renderSnapHighlight(
#renderSnapHighlight(
ctx: CanvasRenderingContext2D,
highlightPos: Readonly<Point>
): void {
@@ -5610,7 +5592,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Normalise boundingRect to pos to snap
snapGuide[0] += offsetX
snapGuide[1] += offsetY
if (this._snapToGrid) snapPoint(snapGuide, this._snapToGrid)
if (this.#snapToGrid) snapPoint(snapGuide, this.#snapToGrid)
snapGuide[0] -= offsetX
snapGuide[1] -= offsetY
@@ -5690,7 +5672,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const output = start_node.outputs[outputId]
if (!output) continue
this._renderAllLinkSegments(
this.#renderAllLinkSegments(
ctx,
link,
startPos,
@@ -5719,7 +5701,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
? getSlotPosition(inputNode, link.target_slot, true)
: inputNode.getInputPos(link.target_slot)
this._renderAllLinkSegments(
this.#renderAllLinkSegments(
ctx,
link,
output.pos,
@@ -5746,7 +5728,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
? getSlotPosition(outputNode, link.origin_slot, false)
: outputNode.getOutputPos(link.origin_slot)
this._renderAllLinkSegments(
this.#renderAllLinkSegments(
ctx,
link,
startPos,
@@ -5760,10 +5742,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
if (graph.floatingLinks.size > 0) {
this._renderFloatingLinks(ctx, graph, visibleReroutes, now)
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
}
const rerouteSet = this._visibleReroutes
const rerouteSet = this.#visibleReroutes
rerouteSet.clear()
// Render reroutes, ordered by number of non-floating links
@@ -5772,7 +5754,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
rerouteSet.add(reroute)
if (
this._snapToGrid &&
this.#snapToGrid &&
this.isDragging &&
this.selectedItems.has(reroute)
) {
@@ -5794,7 +5776,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
: this.editor_alpha
}
private _renderFloatingLinks(
#renderFloatingLinks(
ctx: CanvasRenderingContext2D,
graph: LGraph,
visibleReroutes: Reroute[],
@@ -5823,7 +5805,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
this._renderAllLinkSegments(
this.#renderAllLinkSegments(
ctx,
link,
startPos,
@@ -5845,7 +5827,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const startDirection = node.outputs[link.origin_slot]?.dir
link._dragging = true
this._renderAllLinkSegments(
this.#renderAllLinkSegments(
ctx,
link,
startPos,
@@ -5861,7 +5843,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = globalAlpha
}
private _renderAllLinkSegments(
#renderAllLinkSegments(
ctx: CanvasRenderingContext2D,
link: LLink,
startPos: Point,
@@ -6164,7 +6146,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.save()
ctx.globalAlpha = 0.5 * this.editor_alpha
const drawSnapGuides =
this._snapToGrid &&
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const group of groups) {
@@ -6531,7 +6513,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
},
optPass || {}
)
const dirty = () => this._dirty()
const dirty = () => this.#dirty()
const that = this
const { graph } = this
@@ -7540,7 +7522,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner() {
setValue(input?.value)
}
const dirty = () => this._dirty()
const dirty = () => this.#dirty()
function setValue(value: string | number | undefined) {
if (
@@ -8374,7 +8356,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this._visibleReroutes
this.#visibleReroutes
)
}
if (reroute) {
@@ -8664,17 +8646,4 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const mutations = this.initLayoutMutations()
this.applyNodePositionUpdates(nodesToReposition, mutations)
}
/**
* Custom JSON serialization to prevent circular reference errors.
* LGraphCanvas should not be serialized directly - serialize the graph instead.
*/
toJSON(): { ds: { scale: number; offset: [number, number] } } {
return {
ds: {
scale: this.ds.scale,
offset: [...this.ds.offset] as [number, number]
}
}
}
}

View File

@@ -273,8 +273,8 @@ export class LGraphNode
inputs: INodeInputSlot[] = []
outputs: INodeOutputSlot[] = []
private _concreteInputs: NodeInputSlot[] = []
private _concreteOutputs: NodeOutputSlot[] = []
#concreteInputs: NodeInputSlot[] = []
#concreteOutputs: NodeOutputSlot[] = []
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
@@ -438,24 +438,24 @@ export class LGraphNode
}
/** @inheritdoc {@link renderArea} */
private _renderArea = new Rectangle()
#renderArea = new Rectangle()
/**
* Rect describing the node area, including shadows and any protrusions.
* Determines if the node is visible. Calculated once at the start of every frame.
*/
get renderArea(): ReadOnlyRect {
return this._renderArea
return this.#renderArea
}
/** @inheritdoc {@link boundingRect} */
private _boundingRect: Rectangle = new Rectangle()
#boundingRect: Rectangle = new Rectangle()
/**
* Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present.
*
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
*/
get boundingRect(): ReadOnlyRectangle {
return this._boundingRect
return this.#boundingRect
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
@@ -753,9 +753,7 @@ export class LGraphNode
onPropertyChange?(this: LGraphNode): void
updateOutputData?(this: LGraphNode, origin_slot: number): void
private _getErrorStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
#getErrorStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
if (this.has_errors) {
return {
padding: 12,
@@ -765,9 +763,7 @@ export class LGraphNode
}
}
private _getSelectedStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
#getSelectedStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
if (this.selected) {
return {
padding: this.has_errors ? 20 : undefined
@@ -782,8 +778,8 @@ export class LGraphNode
this.size = [LiteGraph.NODE_WIDTH, 60]
this.pos = [10, 10]
this.strokeStyles = {
error: this._getErrorStrokeStyle,
selected: this._getSelectedStrokeStyle
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle
}
// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
@@ -2071,11 +2067,11 @@ export class LGraphNode
* Called automatically at the start of every frame.
*/
updateArea(ctx?: CanvasRenderingContext2D): void {
const bounds = this._boundingRect
const bounds = this.#boundingRect
this.measure(bounds, ctx)
this.onBounding?.(bounds)
const renderArea = this._renderArea
const renderArea = this.#renderArea
renderArea.set(bounds)
// 4 offset for collapsed node connection points
renderArea[0] -= 4
@@ -2297,7 +2293,7 @@ export class LGraphNode
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeInputSlot | -1
findInputSlotFree(optsIn?: FindFreeSlotOptions) {
return this._findFreeSlot(this.inputs, optsIn)
return this.#findFreeSlot(this.inputs, optsIn)
}
/**
@@ -2312,14 +2308,14 @@ export class LGraphNode
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeOutputSlot | -1
findOutputSlotFree(optsIn?: FindFreeSlotOptions) {
return this._findFreeSlot(this.outputs, optsIn)
return this.#findFreeSlot(this.outputs, optsIn)
}
/**
* Finds the next free slot
* @param slots The slots to search, i.e. this.inputs or this.outputs
*/
private _findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
#findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
options?: FindFreeSlotOptions
): TSlot | number {
@@ -2361,7 +2357,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this._findSlotByType(
return this.#findSlotByType(
this.inputs,
type,
returnObj,
@@ -2391,7 +2387,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this._findSlotByType(
return this.#findSlotByType(
this.outputs,
type,
returnObj,
@@ -2437,14 +2433,14 @@ export class LGraphNode
doNotUseOccupied?: boolean
): number | INodeOutputSlot | INodeInputSlot {
return input
? this._findSlotByType(
? this.#findSlotByType(
this.inputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
: this._findSlotByType(
: this.#findSlotByType(
this.outputs,
type,
returnObj,
@@ -2465,7 +2461,7 @@ export class LGraphNode
* @see {findInputSlotByType}
* @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1
*/
private _findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
#findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
type: ISlotType,
returnObj?: boolean,
@@ -3314,8 +3310,8 @@ export class LGraphNode
// default vertical slots
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotIndex = is_input
? this._defaultVerticalInputs.indexOf(this.inputs[slot_number])
: this._defaultVerticalOutputs.indexOf(this.outputs[slot_number])
? this.#defaultVerticalInputs.indexOf(this.inputs[slot_number])
: this.#defaultVerticalOutputs.indexOf(this.outputs[slot_number])
out[0] = is_input ? nodeX + offset : nodeX + this.size[0] + 1 - offset
out[1] =
@@ -3328,7 +3324,7 @@ export class LGraphNode
/**
* @internal The inputs that are not positioned with absolute coordinates.
*/
private get _defaultVerticalInputs() {
get #defaultVerticalInputs() {
return this.inputs.filter(
(slot) => !slot.pos && !(this.widgets?.length && isWidgetInputSlot(slot))
)
@@ -3337,7 +3333,7 @@ export class LGraphNode
/**
* @internal The outputs that are not positioned with absolute coordinates.
*/
private get _defaultVerticalOutputs() {
get #defaultVerticalOutputs() {
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
@@ -3345,7 +3341,7 @@ export class LGraphNode
* Get the context needed for slot position calculations
* @internal
*/
private _getSlotPositionContext(): SlotPositionContext {
#getSlotPositionContext(): SlotPositionContext {
return {
nodeX: this.pos[0],
nodeY: this.pos[1],
@@ -3377,7 +3373,7 @@ export class LGraphNode
* @returns Position of the centre of the input slot in graph co-ordinates.
*/
getInputSlotPos(input: INodeInputSlot): Point {
return calculateInputSlotPosFromSlot(this._getSlotPositionContext(), input)
return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
}
/**
@@ -3920,13 +3916,13 @@ export class LGraphNode
*/
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
// Render the first connected slot only.
for (const slot of this._concreteInputs) {
for (const slot of this.#concreteInputs) {
if (slot.link != null) {
slot.drawCollapsed(ctx)
break
}
}
for (const slot of this._concreteOutputs) {
for (const slot of this.#concreteOutputs) {
if (slot.links?.length) {
slot.drawCollapsed(ctx)
break
@@ -3938,7 +3934,7 @@ export class LGraphNode
return [...this.inputs, ...this.outputs]
}
private _measureSlot(
#measureSlot(
slot: NodeInputSlot | NodeOutputSlot,
slotIndex: number,
isInput: boolean
@@ -3955,27 +3951,27 @@ export class LGraphNode
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
private _measureSlots(): ReadOnlyRect | null {
#measureSlots(): ReadOnlyRect | null {
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
for (const [slotIndex, slot] of this._concreteInputs.entries()) {
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {
// Unrecognized nodes (Nodes with error) has inputs but no widgets. Treat
// converted inputs as normal inputs.
/** Widget input slots are handled in {@link layoutWidgetInputSlots} */
if (this.widgets?.length && isWidgetInputSlot(slot)) continue
this._measureSlot(slot, slotIndex, true)
this.#measureSlot(slot, slotIndex, true)
slots.push(slot)
}
for (const [slotIndex, slot] of this._concreteOutputs.entries()) {
this._measureSlot(slot, slotIndex, false)
for (const [slotIndex, slot] of this.#concreteOutputs.entries()) {
this.#measureSlot(slot, slotIndex, false)
slots.push(slot)
}
return slots.length ? createBounds(slots, 0) : null
}
private _getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
#getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
const isInput = isINodeInputSlot(slot)
const mouseOverId = this.mouseOver?.[isInput ? 'inputId' : 'outputId'] ?? -1
if (mouseOverId === -1) {
@@ -3984,11 +3980,11 @@ export class LGraphNode
return isInput ? this.inputs[mouseOverId] : this.outputs[mouseOverId]
}
private _isMouseOverSlot(slot: INodeSlot): boolean {
return this._getMouseOverSlot(slot) === slot
#isMouseOverSlot(slot: INodeSlot): boolean {
return this.#getMouseOverSlot(slot) === slot
}
private _isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
#isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
if (!widget) return false
return this.mouseOver?.overWidget === widget
}
@@ -4020,9 +4016,9 @@ export class LGraphNode
ctx: CanvasRenderingContext2D,
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
) {
for (const slot of [...this._concreteInputs, ...this._concreteOutputs]) {
for (const slot of [...this.#concreteInputs, ...this.#concreteOutputs]) {
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
const isMouseOverSlot = this._isMouseOverSlot(slot)
const isMouseOverSlot = this.#isMouseOverSlot(slot)
// change opacity of incompatible slots when dragging a connection
const isValid = !fromSlot || isValidTarget
@@ -4037,7 +4033,7 @@ export class LGraphNode
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
@@ -4058,7 +4054,7 @@ export class LGraphNode
* - {@link IBaseWidget.y}
* @param widgetStartY The y-coordinate of the first widget
*/
private _arrangeWidgets(widgetStartY: number): void {
#arrangeWidgets(widgetStartY: number): void {
if (!this.widgets || !this.widgets.length) return
const bodyHeight = this.bodyHeight
@@ -4136,7 +4132,7 @@ export class LGraphNode
/**
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
#arrangeWidgetInputSlots(): void {
if (!this.widgets) return
const slotByWidgetName = new Map<
@@ -4158,10 +4154,10 @@ export class LGraphNode
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this._concreteInputs[slot.index]
const actualSlot = this.#concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this._measureSlot(actualSlot, slot.index, true)
this.#measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
@@ -4169,7 +4165,7 @@ export class LGraphNode
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
}
}
}
@@ -4182,10 +4178,10 @@ export class LGraphNode
* have been removed from the ecosystem.
*/
_setConcreteSlots(): void {
this._concreteInputs = this.inputs.map((slot) =>
this.#concreteInputs = this.inputs.map((slot) =>
toClass(NodeInputSlot, slot, this)
)
this._concreteOutputs = this.outputs.map((slot) =>
this.#concreteOutputs = this.outputs.map((slot) =>
toClass(NodeOutputSlot, slot, this)
)
}
@@ -4194,12 +4190,12 @@ export class LGraphNode
* Arranges node elements in preparation for rendering (slots & widgets).
*/
arrange(): void {
const slotsBounds = this._measureSlots()
const slotsBounds = this.#measureSlots()
const widgetStartY = slotsBounds
? slotsBounds[1] + slotsBounds[3] - this.pos[1]
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this.#arrangeWidgets(widgetStartY)
this.#arrangeWidgetInputSlots()
}
/**

View File

@@ -25,24 +25,24 @@ export class LGraphNodeProperties {
node: LGraphNode
/** Set of property paths that have been instrumented */
private _instrumentedPaths = new Set<string>()
#instrumentedPaths = new Set<string>()
constructor(node: LGraphNode) {
this.node = node
this._setupInstrumentation()
this.#setupInstrumentation()
}
/**
* Sets up property instrumentation for all tracked properties
*/
private _setupInstrumentation(): void {
#setupInstrumentation(): void {
for (const path of DEFAULT_TRACKED_PROPERTIES) {
this._instrumentProperty(path)
this.#instrumentProperty(path)
}
}
private _resolveTargetObject(parts: string[]): {
#resolveTargetObject(parts: string[]): {
targetObject: Record<string, unknown>
propertyName: string
} {
@@ -73,14 +73,14 @@ export class LGraphNodeProperties {
/**
* Instruments a single property to track changes
*/
private _instrumentProperty(path: string): void {
#instrumentProperty(path: string): void {
const parts = path.split('.')
if (parts.length > 1) {
this._ensureNestedPath(path)
this.#ensureNestedPath(path)
}
const { targetObject, propertyName } = this._resolveTargetObject(parts)
const { targetObject, propertyName } = this.#resolveTargetObject(parts)
const hasProperty = Object.prototype.hasOwnProperty.call(
targetObject,
@@ -96,7 +96,7 @@ export class LGraphNodeProperties {
set: (newValue: unknown) => {
const oldValue = value
value = newValue
this._emitPropertyChange(path, oldValue, newValue)
this.#emitPropertyChange(path, oldValue, newValue)
// Update enumerable: true for non-undefined values, false for undefined
const shouldBeEnumerable = newValue !== undefined
@@ -121,24 +121,24 @@ export class LGraphNodeProperties {
Object.defineProperty(
targetObject,
propertyName,
this._createInstrumentedDescriptor(path, currentValue)
this.#createInstrumentedDescriptor(path, currentValue)
)
}
this._instrumentedPaths.add(path)
this.#instrumentedPaths.add(path)
}
/**
* Creates a property descriptor that emits change events
*/
private _createInstrumentedDescriptor(
#createInstrumentedDescriptor(
propertyPath: string,
initialValue: unknown
): PropertyDescriptor {
return this._createInstrumentedDescriptorTyped(propertyPath, initialValue)
return this.#createInstrumentedDescriptorTyped(propertyPath, initialValue)
}
private _createInstrumentedDescriptorTyped<TValue>(
#createInstrumentedDescriptorTyped<TValue>(
propertyPath: string,
initialValue: TValue
): PropertyDescriptor {
@@ -149,7 +149,7 @@ export class LGraphNodeProperties {
set: (newValue: TValue) => {
const oldValue = value
value = newValue
this._emitPropertyChange(propertyPath, oldValue, newValue)
this.#emitPropertyChange(propertyPath, oldValue, newValue)
},
enumerable: true,
configurable: true
@@ -159,15 +159,15 @@ export class LGraphNodeProperties {
/**
* Emits a property change event if the node is connected to a graph
*/
private _emitPropertyChange(
#emitPropertyChange(
propertyPath: string,
oldValue: unknown,
newValue: unknown
): void {
this._emitPropertyChangeTyped(propertyPath, oldValue, newValue)
this.#emitPropertyChangeTyped(propertyPath, oldValue, newValue)
}
private _emitPropertyChangeTyped<TValue>(
#emitPropertyChangeTyped<TValue>(
propertyPath: string,
oldValue: TValue,
newValue: TValue
@@ -183,7 +183,7 @@ export class LGraphNodeProperties {
/**
* Ensures parent objects exist for nested properties
*/
private _ensureNestedPath(path: string): void {
#ensureNestedPath(path: string): void {
const parts = path.split('.')
// LGraphNode supports dynamic property access at runtime
let current: Record<string, unknown> = this.node as unknown as Record<
@@ -208,7 +208,7 @@ export class LGraphNodeProperties {
* Checks if a property is being tracked
*/
isTracked(path: string): boolean {
return this._instrumentedPaths.has(path)
return this.#instrumentedPaths.has(path)
}
/**

View File

@@ -119,14 +119,14 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/** @inheritdoc */
_dragging?: boolean
private _color?: CanvasColour | null
#color?: CanvasColour | null
/** Custom colour for this link only */
public get color(): CanvasColour | null | undefined {
return this._color
return this.#color
}
public set color(value: CanvasColour) {
this._color = value === '' ? null : value
this.#color = value === '' ? null : value
}
public get isFloatingOutput(): boolean {

View File

@@ -1,43 +1,301 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
{
"config": {},
"definitions": undefined,
"extra": {
"reroutes": undefined,
},
"floatingLinks": undefined,
"groups": [
{
"bounding": [
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Rectangle [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float64Array [
20,
20,
],
"_size": Float64Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
"last_link_id": 0,
"last_node_id": 1,
"links": [],
"nodes": [
{
"id": 1,
"mode": 0,
"pos": [
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"floatingLinksInternal": Map {},
"globaltime": 0,
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
"reroutesInternal": Map {},
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 0.4,
}
`;

View File

@@ -51,11 +51,11 @@ export class InputIndicators implements Disposable {
const element = canvas.canvas
const options = { capture: true, signal } satisfies AddEventListenerOptions
element.addEventListener('pointerdown', this._onPointerDownOrMove, options)
element.addEventListener('pointermove', this._onPointerDownOrMove, options)
element.addEventListener('pointerup', this._onPointerUp, options)
element.addEventListener('keydown', this._onKeyDownOrUp, options)
document.addEventListener('keyup', this._onKeyDownOrUp, options)
element.addEventListener('pointerdown', this.#onPointerDownOrMove, options)
element.addEventListener('pointermove', this.#onPointerDownOrMove, options)
element.addEventListener('pointerup', this.#onPointerUp, options)
element.addEventListener('keydown', this.#onKeyDownOrUp, options)
document.addEventListener('keyup', this.#onKeyDownOrUp, options)
const origDrawFrontCanvas = canvas.drawFrontCanvas.bind(canvas)
signal.addEventListener('abort', () => {
@@ -68,7 +68,7 @@ export class InputIndicators implements Disposable {
}
}
private _onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
#onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
onPointerDownOrMove(e: MouseEvent): void {
this.mouse0Down = (e.buttons & 1) === 1
this.mouse1Down = (e.buttons & 4) === 4
@@ -80,14 +80,14 @@ export class InputIndicators implements Disposable {
this.canvas.setDirty(true)
}
private _onPointerUp = this.onPointerUp.bind(this)
#onPointerUp = this.onPointerUp.bind(this)
onPointerUp(): void {
this.mouse0Down = false
this.mouse1Down = false
this.mouse2Down = false
}
private _onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
#onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
onKeyDownOrUp(e: KeyboardEvent): void {
this.ctrlDown = e.ctrlKey
this.altDown = e.altKey

View File

@@ -115,10 +115,10 @@ export class LinkConnector {
/** The reroute beneath the pointer, if it is a valid connection target. */
overReroute?: Reroute
private readonly _setConnectingLinks: (value: ConnectingLink[]) => void
readonly #setConnectingLinks: (value: ConnectingLink[]) => void
constructor(setConnectingLinks: (value: ConnectingLink[]) => void) {
this._setConnectingLinks = setConnectingLinks
this.#setConnectingLinks = setConnectingLinks
}
get isConnecting() {
@@ -253,7 +253,7 @@ export class LinkConnector {
state.connectingTo = 'input'
state.draggingExistingLinks = true
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
}
/** Drag all links from an output to a new output. */
@@ -364,7 +364,7 @@ export class LinkConnector {
state.multi = true
state.connectingTo = 'output'
this._setLegacyLinks(true)
this.#setLegacyLinks(true)
}
/**
@@ -387,7 +387,7 @@ export class LinkConnector {
state.connectingTo = 'input'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
}
/**
@@ -410,7 +410,7 @@ export class LinkConnector {
state.connectingTo = 'output'
this._setLegacyLinks(true)
this.#setLegacyLinks(true)
}
dragNewFromSubgraphInput(
@@ -431,7 +431,7 @@ export class LinkConnector {
this.state.connectingTo = 'input'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
}
dragNewFromSubgraphOutput(
@@ -452,7 +452,7 @@ export class LinkConnector {
this.state.connectingTo = 'output'
this._setLegacyLinks(true)
this.#setLegacyLinks(true)
}
/**
@@ -489,7 +489,7 @@ export class LinkConnector {
this.state.connectingTo = 'input'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
return
}
@@ -516,7 +516,7 @@ export class LinkConnector {
this.state.connectingTo = 'input'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
}
/**
@@ -553,7 +553,7 @@ export class LinkConnector {
this.state.connectingTo = 'output'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
return
}
@@ -581,7 +581,7 @@ export class LinkConnector {
this.state.connectingTo = 'output'
this._setLegacyLinks(true)
this.#setLegacyLinks(true)
}
dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void {
@@ -603,7 +603,7 @@ export class LinkConnector {
state.connectingTo = 'input'
this._setLegacyLinks(false)
this.#setLegacyLinks(false)
}
/**
@@ -754,7 +754,7 @@ export class LinkConnector {
const output = node.getOutputOnPos([canvasX, canvasY])
if (output) {
this._dropOnOutput(node, output)
this.#dropOnOutput(node, output)
} else {
this.connectToNode(node, event)
}
@@ -765,7 +765,7 @@ export class LinkConnector {
// Input slot
if (inputOrSocket) {
this._dropOnInput(node, inputOrSocket)
this.#dropOnInput(node, inputOrSocket)
} else {
// Node background / title
this.connectToNode(node, event)
@@ -911,7 +911,7 @@ export class LinkConnector {
return
}
this._dropOnOutput(node, output)
this.#dropOnOutput(node, output)
} else if (connectingTo === 'input') {
// Dropping new input link
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
@@ -922,11 +922,11 @@ export class LinkConnector {
return
}
this._dropOnInput(node, input)
this.#dropOnInput(node, input)
}
}
private _dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
#dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
for (const link of this.renderLinks) {
if (!link.canConnectToInput(node, input)) continue
@@ -934,7 +934,7 @@ export class LinkConnector {
}
}
private _dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
#dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
for (const link of this.renderLinks) {
if (!link.canConnectToOutput(node, output)) {
if (
@@ -1014,7 +1014,7 @@ export class LinkConnector {
}
/** Sets connecting_links, used by some extensions still. */
private _setLegacyLinks(fromSlotIsInput: boolean): void {
#setLegacyLinks(fromSlotIsInput: boolean): void {
const links = this.renderLinks.map((link) => {
const input = fromSlotIsInput ? (link.fromSlot as INodeInputSlot) : null
const output = fromSlotIsInput ? null : (link.fromSlot as INodeOutputSlot)
@@ -1033,7 +1033,7 @@ export class LinkConnector {
afterRerouteId
} satisfies ConnectingLink
})
this._setConnectingLinks(links)
this.#setConnectingLinks(links)
}
/**

View File

@@ -10,10 +10,10 @@ import type { ReadOnlyRect, Size } from '@/lib/litegraph/src/interfaces'
* - Width and height are then updated, clamped to min/max values
*/
export class ConstrainedSize {
private _width: number = 0
private _height: number = 0
private _desiredWidth: number = 0
private _desiredHeight: number = 0
#width: number = 0
#height: number = 0
#desiredWidth: number = 0
#desiredHeight: number = 0
minWidth: number = 0
minHeight: number = 0
@@ -21,29 +21,29 @@ export class ConstrainedSize {
maxHeight: number = Infinity
get width() {
return this._width
return this.#width
}
get height() {
return this._height
return this.#height
}
get desiredWidth() {
return this._desiredWidth
return this.#desiredWidth
}
set desiredWidth(value: number) {
this._desiredWidth = value
this._width = clamp(value, this.minWidth, this.maxWidth)
this.#desiredWidth = value
this.#width = clamp(value, this.minWidth, this.maxWidth)
}
get desiredHeight() {
return this._desiredHeight
return this.#desiredHeight
}
set desiredHeight(value: number) {
this._desiredHeight = value
this._height = clamp(value, this.minHeight, this.maxHeight)
this.#desiredHeight = value
this.#height = clamp(value, this.minHeight, this.maxHeight)
}
constructor(width: number, height: number) {
@@ -70,6 +70,6 @@ export class ConstrainedSize {
}
toSize(): Size {
return [this._width, this._height]
return [this.#width, this.#height]
}
}

View File

@@ -19,8 +19,8 @@ import { isInRectangle } from '@/lib/litegraph/src/measure'
* - {@link size}: The size of the rectangle.
*/
export class Rectangle extends Float64Array {
private _pos: Float64Array<ArrayBuffer> | undefined
private _size: Float64Array<ArrayBuffer> | undefined
#pos: Float64Array<ArrayBuffer> | undefined
#size: Float64Array<ArrayBuffer> | undefined
constructor(
x: number = 0,
@@ -78,8 +78,8 @@ export class Rectangle extends Float64Array {
* Updating the values of the returned object will update this rectangle.
*/
get pos(): Point {
this._pos ??= this.subarray(0, 2)
return this._pos! as unknown as Point
this.#pos ??= this.subarray(0, 2)
return this.#pos! as unknown as Point
}
set pos(value: Readonly<Point>) {
@@ -93,8 +93,8 @@ export class Rectangle extends Float64Array {
* Updating the values of the returned object will update this rectangle.
*/
get size(): Size {
this._size ??= this.subarray(2, 4)
return this._size! as unknown as Size
this.#size ??= this.subarray(2, 4)
return this.#size! as unknown as Size
}
set size(value: Readonly<Size>) {

View File

@@ -23,15 +23,15 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
return !!this.widget
}
private _widgetRef: WeakRef<IBaseWidget> | undefined
#widget: WeakRef<IBaseWidget> | undefined
/** Internal use only; API is not finalised and may change at any time. */
get _widget(): IBaseWidget | undefined {
return this._widgetRef?.deref()
return this.#widget?.deref()
}
set _widget(widget: IBaseWidget | undefined) {
this._widgetRef = widget ? new WeakRef(widget) : undefined
this.#widget = widget ? new WeakRef(widget) : undefined
}
get collapsedPos(): Readonly<Point> {
@@ -79,12 +79,4 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
ctx.textAlign = textAlign
}
override toJSON(): INodeInputSlot {
return {
...super.toJSON(),
link: this.link,
widget: this.widget
}
}
}

View File

@@ -15,6 +15,8 @@ import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput
import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
#node: LGraphNode
links: LinkId[] | null
_data?: unknown
slot_index?: number
@@ -25,7 +27,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
get collapsedPos(): Readonly<Point> {
return [
this._node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
LiteGraph.NODE_TITLE_HEIGHT * -0.5
]
}
@@ -38,6 +40,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
this.links = slot.links
this._data = slot._data
this.slot_index = slot.slot_index
this.#node = node
}
override isValidTarget(
@@ -75,12 +78,4 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
ctx.textAlign = textAlign
ctx.strokeStyle = strokeStyle
}
override toJSON(): INodeOutputSlot {
return {
...super.toJSON(),
links: this.links,
slot_index: this.slot_index
}
}
}

View File

@@ -37,7 +37,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
pos?: Point
/** The offset from the parent node to the centre point of this slot. */
private get _centreOffset(): Readonly<Point> {
get #centreOffset(): Readonly<Point> {
const nodePos = this.node.pos
const { boundingRect } = this
@@ -55,9 +55,9 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
/** The center point of this slot when the node is collapsed. */
abstract get collapsedPos(): Readonly<Point>
protected _node: LGraphNode
#node: LGraphNode
get node(): LGraphNode {
return this._node
return this.#node
}
get highlightColor(): CanvasColour {
@@ -89,7 +89,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
super(name, type, rectangle)
Object.assign(this, rest)
this._node = node
this.#node = node
}
/**
@@ -126,7 +126,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
? this.highlightColor
: LiteGraph.NODE_TEXT_COLOR
const pos = this._centreOffset
const pos = this.#centreOffset
const slot_type = this.type
const slot_shape = (
slot_type === SlotType.Array ? SlotShape.Grid : this.shape
@@ -260,25 +260,6 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
ctx.lineWidth = originalLineWidth
}
/**
* Custom JSON serialization to prevent circular reference errors.
* Returns only serializable slot properties without the node back-reference.
*/
toJSON(): INodeSlot {
return {
name: this.name,
type: this.type,
label: this.label,
color_on: this.color_on,
color_off: this.color_off,
shape: this.shape,
dir: this.dir,
localized_name: this.localized_name,
pos: this.pos,
boundingRect: [...this.boundingRect] as [number, number, number, number]
}
}
drawCollapsed(ctx: CanvasRenderingContext2D) {
const [x, y] = this.collapsedPos

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from './litegraph'
import type { NodeInputSlot } from './node/NodeInputSlot'
import type { NodeOutputSlot } from './node/NodeOutputSlot'
class TestNode extends LGraphNode {
static override title = 'TestNode'
constructor() {
super('TestNode')
}
}
LiteGraph.registerNodeType('test/TestNode', TestNode)
describe('Serialization - Circular Reference Prevention', () => {
describe('LGraph.toJSON()', () => {
it('should serialize without circular reference errors', () => {
const graph = new LGraph()
expect(() => JSON.stringify(graph)).not.toThrow()
})
it('should return serialize() output from toJSON()', () => {
const graph = new LGraph()
const serialized = graph.serialize()
const jsonOutput = graph.toJSON()
expect(jsonOutput).toEqual(serialized)
})
it('should not include list_of_graphcanvas in JSON output', () => {
const graph = new LGraph()
const json = JSON.stringify(graph)
const parsed = JSON.parse(json)
expect(parsed.list_of_graphcanvas).toBeUndefined()
})
})
describe('NodeSlot.toJSON()', () => {
it('NodeInputSlot should serialize without circular reference errors', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/TestNode')!
graph.add(node)
node.addInput('test_input', 'TEST')
const inputSlot = node.inputs[0]
expect(() => JSON.stringify(inputSlot)).not.toThrow()
})
it('NodeOutputSlot should serialize without circular reference errors', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/TestNode')!
graph.add(node)
node.addOutput('test_output', 'TEST')
const outputSlot = node.outputs[0]
expect(() => JSON.stringify(outputSlot)).not.toThrow()
})
it('NodeInputSlot.toJSON() should not include _node reference', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/TestNode')!
graph.add(node)
node.addInput('test_input', 'TEST')
const inputSlot = node.inputs[0] as NodeInputSlot
const json = inputSlot.toJSON()
expect('_node' in json).toBe(false)
expect('node' in json).toBe(false)
expect(json.name).toBe('test_input')
expect(json.type).toBe('TEST')
})
it('NodeOutputSlot.toJSON() should not include _node reference', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/TestNode')!
graph.add(node)
node.addOutput('test_output', 'TEST')
const outputSlot = node.outputs[0] as NodeOutputSlot
const json = outputSlot.toJSON()
expect('_node' in json).toBe(false)
expect('node' in json).toBe(false)
expect(json.name).toBe('test_output')
expect(json.type).toBe('TEST')
})
})
describe('Full graph with nodes - no circular references', () => {
it('should serialize a graph with connected nodes', () => {
const graph = new LGraph()
const node1 = LiteGraph.createNode('test/TestNode')!
const node2 = LiteGraph.createNode('test/TestNode')!
graph.add(node1)
graph.add(node2)
node1.addOutput('out', 'TEST')
node2.addInput('in', 'TEST')
node1.connect(0, node2, 0)
expect(() => JSON.stringify(graph)).not.toThrow()
})
it('should serialize graph.serialize() output without errors', () => {
const graph = new LGraph()
const node1 = LiteGraph.createNode('test/TestNode')!
const node2 = LiteGraph.createNode('test/TestNode')!
graph.add(node1)
graph.add(node2)
node1.addOutput('out', 'TEST')
node2.addInput('in', 'TEST')
node1.connect(0, node2, 0)
const serialized = graph.serialize()
expect(() => JSON.stringify(serialized)).not.toThrow()
expect(() => JSON.parse(JSON.stringify(serialized))).not.toThrow()
})
})
})

View File

@@ -50,7 +50,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
inputs: { linkId: number | null; name: string; type: ISlotType }[]
/** Backing field for {@link id}. */
private _id: ExecutionId
#id: ExecutionId
/**
* The path to the actual node through subgraph instances, represented as a list of all subgraph node IDs (instances),
@@ -62,7 +62,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* - `3` is the node ID of the actual node in the subgraph definition
*/
get id() {
return this._id
return this.#id
}
get type() {
@@ -106,7 +106,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
if (!node.graph) throw new NullGraphError()
// Set the internal ID of the DTO
this._id = [...this.subgraphNodePath, this.node.id].join(':')
this.#id = [...this.subgraphNodePath, this.node.id].join(':')
this.graph = node.graph
this.inputs = this.node.inputs.map((x) => ({
linkId: x.link,
@@ -265,7 +265,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
// Bypass nodes by finding first input with matching type
const matchingIndex = this._getBypassSlotIndex(slot, type)
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match - bypass not possible
if (matchingIndex === -1) {
@@ -281,7 +281,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
const { node } = this
if (node.isSubgraphNode())
return this._resolveSubgraphOutput(slot, type, visited)
return this.#resolveSubgraphOutput(slot, type, visited)
if (node.isVirtualNode) {
const virtualLink = this.node.getInputLink(slot)
@@ -321,7 +321,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `-1`.
*/
private _getBypassSlotIndex(slot: number, type: ISlotType) {
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
const oppositeInput = inputs[slot]
const outputType = this.node.outputs[slot].type
@@ -358,7 +358,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* @param visited A set of unique IDs to guard against infinite recursion. See {@link resolveInput}.
* @returns A DTO for the node, and the origin ID / slot index of the output.
*/
private _resolveSubgraphOutput(
#resolveSubgraphOutput(
slot: number,
type: ISlotType,
visited: Set<string>

View File

@@ -38,12 +38,12 @@ export abstract class SubgraphIONodeBase<
static minWidth = 100
static roundedRadius = 10
private readonly _boundingRect: Rectangle = new Rectangle()
readonly #boundingRect: Rectangle = new Rectangle()
abstract readonly id: NodeId
get boundingRect(): Rectangle {
return this._boundingRect
return this.#boundingRect
}
selected: boolean = false
@@ -181,7 +181,7 @@ export abstract class SubgraphIONodeBase<
): void {
// Only allow renaming non-empty slots
if (slot !== this.emptySlot) {
this._promptForSlotRename(slot, event)
this.#promptForSlotRename(slot, event)
}
}
@@ -191,14 +191,14 @@ export abstract class SubgraphIONodeBase<
* @param event The event that triggered the context menu.
*/
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
const options: (IContextMenuValue | null)[] = this._getSlotMenuOptions(slot)
const options: (IContextMenuValue | null)[] = this.#getSlotMenuOptions(slot)
if (!(options.length > 0)) return
new LiteGraph.ContextMenu(options, {
event,
title: slot.name || 'Subgraph Output',
callback: (item: IContextMenuValue) => {
this._onSlotMenuAction(item, slot, event)
this.#onSlotMenuAction(item, slot, event)
}
})
}
@@ -208,7 +208,7 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot to get the context menu options for.
* @returns The context menu options.
*/
private _getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
const options: (IContextMenuValue | null)[] = []
// Disconnect option if slot has connections
@@ -239,7 +239,7 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot
* @param event The event that triggered the context menu.
*/
private _onSlotMenuAction(
#onSlotMenuAction(
selectedItem: IContextMenuValue,
slot: TSlot,
event: CanvasPointerEvent
@@ -260,7 +260,7 @@ export abstract class SubgraphIONodeBase<
// Rename the slot
case 'rename':
if (slot !== this.emptySlot) {
this._promptForSlotRename(slot, event)
this.#promptForSlotRename(slot, event)
}
break
}
@@ -273,7 +273,7 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot to rename.
* @param event The event that triggered the rename.
*/
private _promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
this.subgraph.canvasAction((c) =>
c.prompt(
'Slot name',
@@ -362,7 +362,7 @@ export abstract class SubgraphIONodeBase<
}
configure(data: ExportedSubgraphIONode): void {
this._boundingRect.set(data.bounding)
this.#boundingRect.set(data.bounding)
this.pinned = data.pinned ?? false
}

View File

@@ -35,14 +35,14 @@ export class SubgraphInput extends SubgraphSlot {
events = new CustomEventTarget<SubgraphInputEventMap>()
/** The linked widget that this slot is connected to. */
private _widgetRef?: WeakRef<IBaseWidget>
#widgetRef?: WeakRef<IBaseWidget>
get _widget() {
return this._widgetRef?.deref()
return this.#widgetRef?.deref()
}
set _widget(widget) {
this._widgetRef = widget ? new WeakRef(widget) : undefined
this.#widgetRef = widget ? new WeakRef(widget) : undefined
}
override connect(
@@ -187,7 +187,7 @@ export class SubgraphInput extends SubgraphSlot {
* @returns `true` if the connection is valid, otherwise `false`.
*/
matchesWidget(otherWidget: IBaseWidget): boolean {
const widget = this._widgetRef?.deref()
const widget = this.#widgetRef?.deref()
if (!widget) return true
if (

View File

@@ -63,7 +63,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override widgets: IBaseWidget[] = []
/** Manages lifecycle of all subgraph event listeners */
private _eventAbortController = new AbortController()
#eventAbortController = new AbortController()
constructor(
/** The (sub)graph that contains this subgraph instance. */
@@ -76,7 +76,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Update this node when the subgraph input / output slots are changed
const subgraphEvents = this.subgraph.events
const { signal } = this._eventAbortController
const { signal } = this.#eventAbortController
subgraphEvents.addEventListener(
'input-added',
@@ -89,12 +89,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget)
this._setWidget(subgraphInput, existingInput, widget, input?.widget)
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
return
}
const input = this.addInput(name, type)
this._addSubgraphInputListeners(subgraphInput, input)
this.#addSubgraphInputListeners(subgraphInput, input)
},
{ signal }
)
@@ -179,7 +179,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
private _addSubgraphInputListeners(
#addSubgraphInputListeners(
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
) {
@@ -201,7 +201,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!widget) return
const widgetLocator = e.detail.input.widget
this._setWidget(subgraphInput, input, widget, widgetLocator)
this.#setWidget(subgraphInput, input, widget, widgetLocator)
},
{ signal }
)
@@ -288,7 +288,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
continue
}
this._addSubgraphInputListeners(subgraphInput, input)
this.#addSubgraphInputListeners(subgraphInput, input)
// Find the first widget that this slot is connected to
for (const linkId of subgraphInput.linkIds) {
@@ -318,13 +318,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(subgraphInput, input, widget, targetInput.widget)
this.#setWidget(subgraphInput, input, widget, targetInput.widget)
break
}
}
}
private _setWidget(
#setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>,
@@ -553,7 +553,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
// Clean up all subgraph event listeners
this._eventAbortController.abort()
this.#eventAbortController.abort()
// Clean up all promoted widgets
for (const widget of this.widgets) {

View File

@@ -46,7 +46,7 @@ export abstract class SubgraphSlot
return LiteGraph.NODE_SLOT_HEIGHT
}
private readonly _pos: Point = [0, 0]
readonly #pos: Point = [0, 0]
readonly measurement: ConstrainedSize = new ConstrainedSize(
SubgraphSlot.defaultHeight,
@@ -67,14 +67,14 @@ export abstract class SubgraphSlot
)
override get pos() {
return this._pos
return this.#pos
}
override set pos(value) {
if (!value || value.length < 2) return
this._pos[0] = value[0]
this._pos[1] = value[1]
this.#pos[0] = value[0]
this.#pos[1] = value[1]
}
/** Whether this slot is connected to another slot. */

View File

@@ -57,10 +57,10 @@ export abstract class BaseWidget<
maxWidth?: number
}
private _node: LGraphNode
#node: LGraphNode
/** The node that this widget belongs to. */
get node() {
return this._node
return this.#node
}
linkedWidgets?: IBaseWidget[]
@@ -97,20 +97,20 @@ export abstract class BaseWidget<
canvas: LGraphCanvas
): boolean
private _value?: TWidget['value']
#value?: TWidget['value']
get value(): TWidget['value'] {
return this._value
return this.#value
}
set value(value: TWidget['value']) {
this._value = value
this.#value = value
}
constructor(widget: TWidget & { node: LGraphNode })
constructor(widget: TWidget, node: LGraphNode)
constructor(widget: TWidget & { node: LGraphNode }, node?: LGraphNode) {
// Private fields
this._node = node ?? widget.node
this.#node = node ?? widget.node
// The set and get functions for DOM widget values are hacked on to the options object;
// attempting to set value before options will throw.

View File

@@ -1,115 +1,4 @@
{
"discover": {
"searchPlaceholder": "Search workflows...",
"filters": {
"tags": "Tags",
"models": "Models",
"mediaType": "Media Type",
"openSource": "Open Source models only",
"cloudOnly": "Cloud only"
},
"resultsCount": "{count} result | {count} results",
"noResults": "No templates found",
"noResultsHint": "Try adjusting your search or filters",
"detail": {
"officialWorkflow": "Official Workflow",
"mediaType": "Media Type",
"openSource": "Uses open source models only",
"requiredNodes": "Required Custom Nodes",
"gallery": "Gallery",
"author": "Comfy Org",
"runWorkflow": "Run Workflow",
"makeCopy": "Make a Copy",
"openInHub": "Open in Hub",
"appMode": "App Mode",
"workflowPreview": "Workflow Preview",
"previewError": "Failed to load preview",
"previewHint": "Make a copy to save your changes",
"runWorkflowNotImplemented": "Run workflow is not implemented yet",
"makeCopyFailed": "Failed to copy workflow: {error}",
"appNotReady": "ComfyUI is still loading. Please wait and try again."
},
"author": {
"title": "Creator",
"searchPlaceholder": "Search this creator's workflows",
"workflowsCount": "{count} workflow | {count} workflows",
"runsLabel": "runs",
"copiesLabel": "copies",
"rankLabel": "rank",
"rankValue": "31",
"rankCaption": "Last 30 days",
"workflowsTitle": "Workflows",
"tagline": "Designing workflows that feel polished, practical, and production-ready.",
"aboutTitle": "About",
"aboutDescription": "Focused on building clean, reliable workflows with thoughtful defaults and clear documentation.",
"badgeCreator": "Creator profile",
"badgeOpenSource": "Open-source friendly",
"badgeTemplates": "Workflow templates",
"openInHub": "Open in Hub",
"noResults": "No workflows found for this creator",
"noResultsHint": "Try a different search or check back later."
},
"share": {
"share": "Share",
"copyFailed": "Failed to copy link to clipboard",
"noActiveWorkflow": "No active workflow to share",
"inviteTab": {
"title": "Share"
},
"publishTab": {
"title": "Publish",
"description": "Publish your workflow to Comfy Hub to share it with the community. Your workflow will be publicly visible and discoverable."
},
"inviteByEmail": {
"emailLabel": "Email Address",
"emailPlaceholder": "colleague{'@'}example.com",
"roleLabel": "Permission Level",
"roles": {
"viewOnly": "View Only",
"viewOnlyDescription": "Can view and run the workflow, but cannot make changes",
"edit": "Can Edit",
"editDescription": "Can view, run, and make changes to the workflow"
},
"sendInvite": "Send Invite",
"inviteSent": "Invite Sent",
"inviteSentDetail": "An invitation has been sent to {email}",
"peopleWithAccess": "People with access",
"inviteAnother": "Invite another person"
},
"publicUrl": {
"title": "Share with public link",
"description": "Anyone with this link can view and run your workflow, but they cannot edit it.",
"copyLink": "Copy link",
"copyLinkAppMode": "Copy link to App mode"
},
"publishToHubDialog": {
"title": "Publish to Hub",
"thumbnail": "Thumbnail",
"thumbnailPreview": "Thumbnail preview",
"uploadThumbnail": "Click to upload a thumbnail image",
"titlePlaceholder": "Enter a title for your workflow",
"descriptionPlaceholder": "Describe what your workflow does...",
"addTag": "Add a tag and press Enter",
"openSource": "Open Source",
"openSourceDescription": "Uses only open source models",
"publish": "Publish",
"notImplemented": "Publishing to Hub is not implemented yet",
"successTitle": "Published to Hub",
"successDescription": "Your workflow is live on Comfy Hub.",
"successOpen": "Open in Hub",
"successCopy": "Copy link"
}
}
},
"home": {
"title": "Home",
"tabs": {
"recents": "Recents",
"discover": "Discover"
},
"recentsStubTitle": "No recent workflows yet",
"recentsStubDescription": "Your recently opened workflows will appear here."
},
"g": {
"user": "User",
"you": "You",
@@ -117,7 +6,6 @@
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon",
"appModePlaceholderDescription": "Will show the workflow in app mode and you will be able to run it",
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
@@ -830,8 +718,6 @@
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"discover": "Discover",
"discoverPlaceholder": "Discover content coming soon",
"mediaAssets": {
"title": "Media Assets",
"sortNewestFirst": "Newest first",
@@ -944,8 +830,7 @@
"browse": "Browse",
"bookmarks": "Bookmarks",
"open": "Open"
},
"discoverWorkflows": "Discover Workflows"
}
}
},
"helpCenter": {

View File

@@ -35,9 +35,38 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockShowDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
showDialog: mockShowDialog
})
}))
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
const mockUpdateHistory = vi.hoisted(() => vi.fn())
const mockUpdateInputs = vi.hoisted(() => vi.fn())
const mockHasCategory = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
setAssetDeleting: mockSetAssetDeleting,
updateHistory: mockUpdateHistory,
updateInputs: mockUpdateInputs,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
hasCategory: mockHasCategory
})
}))
const mockGetCategoryForNodeType = vi.hoisted(() => vi.fn())
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType,
getAllNodeProviders: vi.fn((category: string) => {
if (category === 'checkpoints' || category === 'loras') {
return [{ nodeDef: { name: 'TestLoader' }, key: 'model_name' }]
}
return []
})
})
}))
@@ -93,14 +122,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
isResultItemType: vi.fn().mockReturnValue(true)
}))
const mockGetAssetType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
getAssetType: vi.fn().mockReturnValue('input')
getAssetType: mockGetAssetType
}))
vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
}))
const mockDeleteAsset = vi.hoisted(() => vi.fn())
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset
}
}))
vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
user: 'test-user'
}
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -218,4 +266,114 @@ describe('useMediaAssetActions', () => {
})
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset.mockResolvedValue(undefined)
mockInvalidateModelsForCategory.mockClear()
mockSetAssetDeleting.mockClear()
mockUpdateHistory.mockClear()
mockUpdateInputs.mockClear()
mockHasCategory.mockClear()
// By default, hasCategory returns true for model categories
mockHasCategory.mockImplementation(
(tag: string) => tag === 'checkpoints' || tag === 'loras'
)
})
it('should invalidate model cache when deleting a model asset', async () => {
const actions = useMediaAssetActions()
const modelAsset = createMockAsset({
id: 'checkpoint-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(modelAsset)
// Only 'checkpoints' exists in cache; 'models' is excluded
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
it('should invalidate multiple categories for multiple assets', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'loras'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
})
it('should not invalidate model cache for non-model assets', async () => {
const actions = useMediaAssetActions()
const inputAsset = createMockAsset({
id: 'input-1',
name: 'image.png',
tags: ['input']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(inputAsset)
// 'input' tag is excluded, so no cache invalidation
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
})
it('should only invalidate categories that exist in cache', async () => {
const actions = useMediaAssetActions()
// hasCategory returns false for 'unknown-category'
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
// Only checkpoints should be invalidated (unknown-category not in cache)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
})
})

View File

@@ -586,6 +586,25 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
// Invalidate model caches for affected categories
const modelCategories = new Set<string>()
const excludedTags = ['models', 'input', 'output']
for (const asset of assetArray) {
for (const tag of asset.tags ?? []) {
if (excludedTags.includes(tag)) continue
if (assetsStore.hasCategory(tag)) {
modelCategories.add(tag)
}
}
}
await Promise.allSettled(
[...modelCategories].map((category) =>
assetsStore.invalidateModelsForCategory(category)
)
)
// Show appropriate feedback based on results
if (failed.length === 0) {
toast.add({

View File

@@ -1,86 +0,0 @@
import { toRaw } from 'vue'
import { RESERVED_BY_TEXT_INPUT } from './reserved'
import type { KeyCombo } from './types'
export class KeyComboImpl implements KeyCombo {
key: string
ctrl: boolean
alt: boolean
shift: boolean
constructor(obj: KeyCombo) {
this.key = obj.key
this.ctrl = obj.ctrl ?? false
this.alt = obj.alt ?? false
this.shift = obj.shift ?? false
}
static fromEvent(event: KeyboardEvent) {
return new KeyComboImpl({
key: event.key,
ctrl: event.ctrlKey || event.metaKey,
alt: event.altKey,
shift: event.shiftKey
})
}
equals(other: unknown): boolean {
const raw = toRaw(other)
return raw instanceof KeyComboImpl
? this.key.toUpperCase() === raw.key.toUpperCase() &&
this.ctrl === raw.ctrl &&
this.alt === raw.alt &&
this.shift === raw.shift
: false
}
serialize(): string {
return `${this.key.toUpperCase()}:${this.ctrl}:${this.alt}:${this.shift}`
}
toString(): string {
return this.getKeySequences().join(' + ')
}
get hasModifier(): boolean {
return this.ctrl || this.alt || this.shift
}
get isModifier(): boolean {
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
get modifierCount(): number {
const modifiers = [this.ctrl, this.alt, this.shift]
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
}
get isShiftOnly(): boolean {
return this.shift && this.modifierCount === 1
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
)
}
getKeySequences(): string[] {
const sequences: string[] = []
if (this.ctrl) {
sequences.push('Ctrl')
}
if (this.alt) {
sequences.push('Alt')
}
if (this.shift) {
sequences.push('Shift')
}
sequences.push(this.key)
return sequences
}
}

View File

@@ -1,26 +0,0 @@
import { toRaw } from 'vue'
import { KeyComboImpl } from './keyCombo'
import type { Keybinding } from './types'
export class KeybindingImpl implements Keybinding {
commandId: string
combo: KeyComboImpl
targetElementId?: string
constructor(obj: Keybinding) {
this.commandId = obj.commandId
this.combo = new KeyComboImpl(obj.combo)
this.targetElementId = obj.targetElementId
}
equals(other: unknown): boolean {
const raw = toRaw(other)
return raw instanceof KeybindingImpl
? this.commandId === raw.commandId &&
this.combo.equals(raw.combo) &&
this.targetElementId === raw.targetElementId
: false
}
}

View File

@@ -1,179 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_KEYBINDINGS } from '@/platform/keybindings/defaults'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('keybindingService - Escape key handling', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
const commandStore = useCommandStore()
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [] as DialogInstance[]
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
function createKeyboardEvent(
key: string,
options: {
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
shiftKey?: boolean
} = {}
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key,
ctrlKey: options.ctrlKey ?? false,
altKey: options.altKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
return event
}
it('should execute Escape keybinding when no dialogs are open', async () => {
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [] as DialogInstance[]
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
const event = createKeyboardEvent('Escape')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
})
it('should NOT execute Escape keybinding when dialogs are open', async () => {
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' } as DialogInstance]
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
keybindingService = useKeybindingService()
const event = createKeyboardEvent('Escape')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' } as DialogInstance]
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
const keybindingStore = useKeybindingStore()
keybindingStore.addDefaultKeybinding(
new KeybindingImpl({
commandId: 'Test.CtrlEscape',
combo: { key: 'Escape', ctrl: true }
})
)
keybindingService = useKeybindingService()
const event = createKeyboardEvent('Escape', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape')
})
it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => {
const escapeBinding = CORE_KEYBINDINGS.find(
(kb) => kb.combo.key === 'Escape' && !kb.combo.ctrl && !kb.combo.alt
)
expect(escapeBinding).toBeDefined()
expect(escapeBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
})
it('should create correct KeyComboImpl from Escape event', () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false
})
const keyCombo = KeyComboImpl.fromEvent(event)
expect(keyCombo.key).toBe('Escape')
expect(keyCombo.ctrl).toBe(false)
expect(keyCombo.alt).toBe(false)
expect(keyCombo.shift).toBe(false)
})
it('should still close legacy modals on Escape when no keybinding matched', async () => {
setActivePinia(createPinia())
keybindingService = useKeybindingService()
const mockModal = document.createElement('div')
mockModal.className = 'comfy-modal'
mockModal.style.display = 'block'
document.body.appendChild(mockModal)
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = vi.fn().mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue('block')
})
try {
const event = createKeyboardEvent('Escape')
await keybindingService.keybindHandler(event)
expect(mockModal.style.display).toBe('none')
} finally {
document.body.removeChild(mockModal)
window.getComputedStyle = originalGetComputedStyle
}
})
})

View File

@@ -3,7 +3,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
import type { Keybinding } from '@/platform/keybindings/types'
import type { Keybinding } from '@/schemas/keyBindingSchema'
import { NodeBadgeMode } from '@/types/nodeSource'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { breakpointsTailwind } from '@vueuse/core'

View File

@@ -158,15 +158,7 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
(newUrls) => {
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -177,7 +169,7 @@ watch(
videoError.value = false
showLoader.value = newUrls.length > 0
},
{ immediate: true }
{ deep: true, immediate: true }
)
// Event handlers

View File

@@ -308,80 +308,4 @@ describe('ImagePreview', () => {
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden after load
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Reassign with new array reference but same content
await wrapper.setProps({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// After 250ms timeout, loading state should be reset (aria-busy="true")
// We can check the internal state via the Skeleton appearing
// or wait for the timeout
await new Promise((resolve) => setTimeout(resolve, 300))
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})
})
})

View File

@@ -176,15 +176,7 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
(newUrls) => {
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -196,7 +188,7 @@ watch(
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
{ deep: true, immediate: true }
)
// Event handlers

View File

@@ -551,12 +551,6 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
)
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
@@ -566,9 +560,13 @@ const nodeMedia = computed(() => {
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
// Determine media type from previewMediaType or fallback to input slot types
// Note: Despite the field name "images", videos are also included in outputs
// TODO: fix the backend to return videos using the videos key instead of the images key
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput.value)
(!node.previewMediaType && hasVideoInput)
? 'video'
: 'image'

View File

@@ -9,7 +9,7 @@
>
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${input.name}`"
:key="`input-${index}`"
:slot-data="input"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
@@ -23,7 +23,7 @@
>
<OutputSlot
v-for="(output, index) in nodeData.outputs"
:key="`output-${output.name}`"
:key="`output-${index}`"
:slot-data="output"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"

View File

@@ -183,14 +183,6 @@ export function useSlotElementTracking(options: {
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
// Defensive cleanup: remove stale entry if it exists with different element
// This handles edge cases where Vue component reuse prevents proper unmount
const existingEntry = node.slots.get(slotKey)
if (existingEntry && existingEntry.el !== el) {
delete existingEntry.el.dataset.slotKey
layoutStore.deleteSlotLayout(slotKey)
}
el.dataset.slotKey = slotKey
node.slots.set(slotKey, { el, index, type })

View File

@@ -68,11 +68,6 @@ const router = createRouter({
path: 'user-select',
name: 'UserSelectView',
component: () => import('@/views/UserSelectView.vue')
},
{
path: 'profiles/:slug',
name: 'AuthorProfileView',
component: () => import('@/views/AuthorProfileView.vue')
}
]
}

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
import { zKeybinding } from '@/platform/keybindings/types'
import { zKeybinding } from '@/schemas/keyBindingSchema'
import { NodeBadgeMode } from '@/types/nodeSource'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
// KeyCombo schema
const zKeyCombo = z.object({
key: z.string(),
ctrl: z.boolean().optional(),
@@ -8,11 +9,17 @@ const zKeyCombo = z.object({
meta: z.boolean().optional()
})
// Keybinding schema
export const zKeybinding = z.object({
commandId: z.string(),
combo: zKeyCombo,
// Optional target element ID to limit keybinding to.
// Note: Currently only used to distinguish between global keybindings
// and litegraph canvas keybindings.
// Do NOT use this field in extensions as it has no effect.
targetElementId: z.string().optional()
})
// Infer types from schemas
export type KeyCombo = z.infer<typeof zKeyCombo>
export type Keybinding = z.infer<typeof zKeybinding>

View File

@@ -294,7 +294,7 @@ export class PromptExecutionError extends Error {
}
export class ComfyApi extends EventTarget {
private _registered = new Set()
#registered = new Set()
api_host: string
api_base: string
/**
@@ -451,7 +451,7 @@ export class ComfyApi extends EventTarget {
) {
// Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe.
super.addEventListener(type, callback as EventListener, options)
this._registered.add(type)
this.#registered.add(type)
}
override removeEventListener<TEvent extends keyof ApiEvents>(
@@ -492,7 +492,7 @@ export class ComfyApi extends EventTarget {
/**
* Poll status for colab and other things that don't support websockets.
*/
private _pollQueue() {
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi('/prompt')
@@ -568,7 +568,7 @@ export class ComfyApi extends EventTarget {
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this._pollQueue()
this.#pollQueue()
}
})
@@ -691,7 +691,7 @@ export class ComfyApi extends EventTarget {
)
break
default:
if (this._registered.has(msg.type)) {
if (this.#registered.has(msg.type)) {
// Fallback for custom types - calls super direct.
super.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
@@ -956,7 +956,7 @@ export class ComfyApi extends EventTarget {
* @param {*} type The endpoint to post to
* @param {*} body Optional POST data
*/
private async _postItem(type: string, body?: Record<string, unknown>) {
async #postItem(type: string, body?: Record<string, unknown>) {
try {
await this.fetchApi('/' + type, {
method: 'POST',
@@ -976,7 +976,7 @@ export class ComfyApi extends EventTarget {
* @param {number} id The id of the item to delete
*/
async deleteItem(type: string, id: string) {
await this._postItem(type, { delete: [id] })
await this.#postItem(type, { delete: [id] })
}
/**
@@ -984,7 +984,7 @@ export class ComfyApi extends EventTarget {
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type: string) {
await this._postItem(type, { clear: true })
await this.#postItem(type, { clear: true })
}
/**
@@ -993,7 +993,7 @@ export class ComfyApi extends EventTarget {
* @param {string | null} [runningPromptId] Optional Running Prompt ID to interrupt
*/
async interrupt(runningPromptId: string | null) {
await this._postItem(
await this.#postItem(
'interrupt',
runningPromptId ? { prompt_id: runningPromptId } : undefined
)

View File

@@ -59,8 +59,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'

View File

@@ -241,17 +241,17 @@ function dragElement(dragEl): () => void {
}
class ComfyList {
private _type
private _text
private _reverse
#type
#text
#reverse
element: HTMLDivElement
button?: HTMLButtonElement
// @ts-expect-error fixme ts strict error
constructor(text, type?, reverse?) {
this._text = text
this._type = type || text.toLowerCase()
this._reverse = reverse || false
this.#text = text
this.#type = type || text.toLowerCase()
this.#reverse = reverse || false
this.element = $el('div.comfy-list') as HTMLDivElement
this.element.style.display = 'none'
}
@@ -261,7 +261,7 @@ class ComfyList {
}
async load() {
const items = await api.getItems(this._type)
const items = await api.getItems(this.#type)
this.element.replaceChildren(
...Object.keys(items).flatMap((section) => [
$el('h4', {
@@ -269,12 +269,12 @@ class ComfyList {
}),
$el('div.comfy-list-items', [
// @ts-expect-error fixme ts strict error
...(this._reverse ? items[section].reverse() : items[section]).map(
...(this.#reverse ? items[section].reverse() : items[section]).map(
(item: LegacyQueueItem) => {
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
const removeAction = item.remove ?? {
name: 'Delete',
cb: () => api.deleteItem(this._type, item.prompt[1])
cb: () => api.deleteItem(this.#type, item.prompt[1])
}
return $el('div', { textContent: item.prompt[0] + ': ' }, [
$el('button', {
@@ -311,9 +311,9 @@ class ComfyList {
]),
$el('div.comfy-list-actions', [
$el('button', {
textContent: 'Clear ' + this._text,
textContent: 'Clear ' + this.#text,
onclick: async () => {
await api.clearItems(this._type)
await api.clearItems(this.#type)
await this.load()
}
}),
@@ -339,7 +339,7 @@ class ComfyList {
hide() {
this.element.style.display = 'none'
// @ts-expect-error fixme ts strict error
this.button.textContent = 'View ' + this._text
this.button.textContent = 'View ' + this.#text
}
toggle() {

View File

@@ -6,7 +6,7 @@ type DialogAction<T> = string | { value?: T; text: string }
export class ComfyAsyncDialog<
T = string | null
> extends ComfyDialog<HTMLDialogElement> {
private _resolve: (value: T | null) => void = () => {}
#resolve: (value: T | null) => void = () => {}
constructor(actions?: Array<DialogAction<T>>) {
super(
@@ -30,7 +30,7 @@ export class ComfyAsyncDialog<
super.show(html)
return new Promise((resolve) => {
this._resolve = resolve
this.#resolve = resolve
})
}
@@ -43,12 +43,12 @@ export class ComfyAsyncDialog<
this.element.showModal()
return new Promise((resolve) => {
this._resolve = resolve
this.#resolve = resolve
})
}
override close(result: T | null = null) {
this._resolve(result)
this.#resolve(result)
this.element.close()
super.close()
}

View File

@@ -21,8 +21,8 @@ type ComfyButtonProps = {
}
export class ComfyButton implements ComfyComponent<HTMLElement> {
private _over = 0
private _popupOpen = false
#over = 0
#popupOpen = false
isOver = false
iconElement = $el('i.mdi')
contentElement = $el('span')
@@ -123,7 +123,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
this.element.addEventListener('click', (e) => {
if (this.popup) {
// we are either a touch device or triggered by click not hover
if (!this._over) {
if (!this.#over) {
this.popup.toggle()
}
}
@@ -157,7 +157,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
internalClasses.push('disabled')
}
if (this.popup) {
if (this._popupOpen) {
if (this.#popupOpen) {
internalClasses.push('popup-open')
} else {
internalClasses.push('popup-closed')
@@ -172,16 +172,16 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
if (mode === 'hover') {
for (const el of [this.element, this.popup.element]) {
el.addEventListener('mouseenter', () => {
this.popup.open = !!++this._over
this.popup.open = !!++this.#over
})
el.addEventListener('mouseleave', () => {
this.popup.open = !!--this._over
this.popup.open = !!--this.#over
})
}
}
popup.addEventListener('change', () => {
this._popupOpen = popup.open
this.#popupOpen = popup.open
this.updateClasses()
})

View File

@@ -54,9 +54,9 @@ export class ComfyPopup extends EventTarget {
this.open = prop(this, 'open', false, (v, o) => {
if (v === o) return
if (v) {
this._show()
this.#show()
} else {
this._hide()
this.#hide()
}
})
}
@@ -65,24 +65,24 @@ export class ComfyPopup extends EventTarget {
this.open = !this.open
}
private _hide() {
#hide() {
this.element.classList.remove('open')
window.removeEventListener('resize', this.update)
window.removeEventListener('click', this._clickHandler, { capture: true })
window.removeEventListener('keydown', this._escHandler, { capture: true })
window.removeEventListener('click', this.#clickHandler, { capture: true })
window.removeEventListener('keydown', this.#escHandler, { capture: true })
this.dispatchEvent(new CustomEvent('close'))
this.dispatchEvent(new CustomEvent('change'))
}
private _show() {
#show() {
this.element.classList.add('open')
this.update()
window.addEventListener('resize', this.update)
window.addEventListener('click', this._clickHandler, { capture: true })
window.addEventListener('click', this.#clickHandler, { capture: true })
if (this.closeOnEscape) {
window.addEventListener('keydown', this._escHandler, { capture: true })
window.addEventListener('keydown', this.#escHandler, { capture: true })
}
this.dispatchEvent(new CustomEvent('open'))
@@ -90,7 +90,7 @@ export class ComfyPopup extends EventTarget {
}
// @ts-expect-error fixme ts strict error
private _escHandler = (e) => {
#escHandler = (e) => {
if (e.key === 'Escape') {
this.open = false
e.preventDefault()
@@ -99,7 +99,7 @@ export class ComfyPopup extends EventTarget {
}
// @ts-expect-error fixme ts strict error
private _clickHandler = (e) => {
#clickHandler = (e) => {
/** @type {any} */
const target = e.target
if (

View File

@@ -5,11 +5,11 @@ export class ComfyDialog<
> extends EventTarget {
element: T
textElement!: HTMLElement
private _buttons: HTMLButtonElement[] | null
#buttons: HTMLButtonElement[] | null
constructor(type = 'div', buttons: HTMLButtonElement[] | null = null) {
super()
this._buttons = buttons
this.#buttons = buttons
this.element = $el(type + '.comfy-modal', { parent: document.body }, [
$el('div.comfy-modal-content', [
$el('p', { $: (p) => (this.textElement = p) }),
@@ -20,7 +20,7 @@ export class ComfyDialog<
createButtons() {
return (
this._buttons ?? [
this.#buttons ?? [
$el('button', {
type: 'button',
textContent: 'Close',

View File

@@ -71,6 +71,7 @@ The following table lists ALL services in the system as of 2025-09-01:
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
| dialogService.ts | Provides dialog and modal management | UI |
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
| load3dService.ts | Manages 3D model loading and visualization | 3D |
| mediaCacheService.ts | Manages media file caching with blob storage and cleanup | Media |

View File

@@ -5,8 +5,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'

View File

@@ -0,0 +1,209 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
// Mock stores
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
describe('keybindingService - Escape key handling', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
// Mock command store execute
const commandStore = useCommandStore()
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
// Reset dialog store mock to empty - only mock the properties we need for testing
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [],
// Add other required properties as undefined/default values to satisfy the type
// but they won't be used in these tests
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
})
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should register Escape key for ExitSubgraph command', () => {
const keybindingStore = useKeybindingStore()
// Check that the Escape keybinding exists in core keybindings
const escapeKeybinding = CORE_KEYBINDINGS.find(
(kb) =>
kb.combo.key === 'Escape' && kb.commandId === 'Comfy.Graph.ExitSubgraph'
)
expect(escapeKeybinding).toBeDefined()
// Check that it was registered in the store
const registeredBinding = keybindingStore.getKeybinding(
new KeyComboImpl({ key: 'Escape' })
)
expect(registeredBinding).toBeDefined()
expect(registeredBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
})
it('should execute ExitSubgraph command when Escape is pressed', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
})
it('should not execute command when Escape is pressed with modifiers', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
ctrlKey: true,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not execute command when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [inputElement])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should close dialogs when no keybinding is registered', async () => {
// Remove the Escape keybinding
const keybindingStore = useKeybindingStore()
keybindingStore.unsetKeybinding(
new KeybindingImpl({
commandId: 'Comfy.Graph.ExitSubgraph',
combo: { key: 'Escape' }
})
)
// Create a mock dialog
const dialog = document.createElement('dialog')
dialog.open = true
dialog.close = vi.fn()
document.body.appendChild(dialog)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(dialog.close).toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
// Cleanup
document.body.removeChild(dialog)
})
it('should allow user to rebind Escape key to different command', async () => {
const keybindingStore = useKeybindingStore()
// Add a user keybinding for Escape to a different command
keybindingStore.addUserKeybinding(
new KeybindingImpl({
commandId: 'Custom.Command',
combo: { key: 'Escape' }
})
)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Custom.Command')
expect(mockCommandExecute).not.toHaveBeenCalledWith(
'Comfy.Graph.ExitSubgraph'
)
})
it('should not execute Escape keybinding when dialogs are open', async () => {
// Mock dialog store to have open dialogs
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' } as DialogInstance],
// Add other required properties as undefined/default values to satisfy the type
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
})
// Re-create keybinding service to pick up new mock
keybindingService = useKeybindingService()
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
// Should not call preventDefault or execute command
expect(event.preventDefault).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
})

View File

@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
// Mock the app and canvas using factory functions
vi.mock('@/scripts/app', () => {
return {
app: {
@@ -17,6 +18,7 @@ vi.mock('@/scripts/app', () => {
}
})
// Mock stores
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
@@ -29,6 +31,7 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
// Test utility for creating keyboard events with mocked methods
function createTestKeyboardEvent(
key: string,
options: {
@@ -54,6 +57,7 @@ function createTestKeyboardEvent(
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [target])
@@ -67,9 +71,11 @@ describe('keybindingService - Event Forwarding', () => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
// Mock command store execute
const commandStore = useCommandStore()
commandStore.execute = vi.fn()
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
@@ -85,7 +91,9 @@ describe('keybindingService - Event Forwarding', () => {
await keybindingService.keybindHandler(event)
// Should forward to canvas processKey
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
// Should not execute any command
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
@@ -104,6 +112,7 @@ describe('keybindingService - Event Forwarding', () => {
await keybindingService.keybindHandler(event)
// Should not forward to canvas when in input field
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
@@ -135,6 +144,7 @@ describe('keybindingService - Event Forwarding', () => {
})
it('should not forward Delete key when canvas is not available', async () => {
// Temporarily set canvas to null
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
@@ -154,6 +164,7 @@ describe('keybindingService - Event Forwarding', () => {
await keybindingService.keybindHandler(event)
// Should not forward Enter key
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
@@ -163,6 +174,7 @@ describe('keybindingService - Event Forwarding', () => {
await keybindingService.keybindHandler(event)
// Should not forward when modifiers are pressed
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})

View File

@@ -1,35 +1,40 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { CORE_KEYBINDINGS } from './defaults'
import { KeyComboImpl } from './keyCombo'
import { KeybindingImpl } from './keybinding'
import { useKeybindingStore } from './keybindingStore'
export function useKeybindingService() {
export const useKeybindingService = () => {
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
// Helper function to determine if an event should be forwarded to canvas
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
// Don't forward if modifier keys are pressed (except shift)
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
// Keys that LiteGraph handles but aren't in core keybindings
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) {
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
return
}
// Ignore reserved or non-modifier keybindings if typing in input fields
const target = event.composedPath()[0] as HTMLElement
if (
keyCombo.isReservedByTextInput &&
@@ -44,17 +49,20 @@ export function useKeybindingService() {
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
// Special handling for Escape key - let dialogs handle it first
if (
event.key === 'Escape' &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
) {
// If dialogs are open, don't execute the keybinding - let the dialog handle it
if (dialogStore.dialogStack.length > 0) {
return
}
}
// Prevent default browser behavior first, then execute the command
event.preventDefault()
const runCommandIds = new Set([
'Comfy.QueuePrompt',
@@ -73,6 +81,7 @@ export function useKeybindingService() {
return
}
// Forward unhandled canvas-targeted events to LiteGraph
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
@@ -80,15 +89,18 @@ export function useKeybindingService() {
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
// Let LiteGraph handle the event
canvas.processKey(event)
return
}
}
// Only clear dialogs if not using modifiers
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}
// Escape key: close the first open modal found, and all dialogs
if (event.key === 'Escape') {
const modals = document.querySelectorAll<HTMLElement>('.comfy-modal')
for (const modal of modals) {
@@ -106,13 +118,14 @@ export function useKeybindingService() {
}
}
function registerCoreKeybindings() {
const registerCoreKeybindings = () => {
for (const keybinding of CORE_KEYBINDINGS) {
keybindingStore.addDefaultKeybinding(new KeybindingImpl(keybinding))
}
}
function registerUserKeybindings() {
// Unset bindings first as new bindings might conflict with default bindings.
const unsetBindings = settingStore.get('Comfy.Keybinding.UnsetBindings')
for (const keybinding of unsetBindings) {
keybindingStore.unsetKeybinding(new KeybindingImpl(keybinding))
@@ -124,6 +137,8 @@ export function useKeybindingService() {
}
async function persistUserKeybindings() {
// TODO(https://github.com/Comfy-Org/ComfyUI_frontend/issues/1079):
// Allow setting multiple values at once in settingStore
await settingStore.set(
'Comfy.Keybinding.NewBindings',
Object.values(keybindingStore.getUserKeybindings())

View File

@@ -5,7 +5,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: Partial<ComfyNodeDefImpl> = {}) => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
@@ -273,7 +273,7 @@ describe('nodeOrganizationService', () => {
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as NodeSourceType,
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'

View File

@@ -1,34 +1,20 @@
import type { Mock } from 'vitest'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']
type GlobalWithAlgolia = typeof globalThis & {
__ALGOLIA_APP_ID__: string
__ALGOLIA_API_KEY__: string
}
// Mock global Algolia constants
const globalWithAlgolia = globalThis as GlobalWithAlgolia
globalWithAlgolia.__ALGOLIA_APP_ID__ = 'test-app-id'
globalWithAlgolia.__ALGOLIA_API_KEY__ = 'test-api-key'
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
// Mock algoliasearch
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
liteClient: vi.fn()
}))
interface MockSearchClient {
search: Mock
}
describe('useAlgoliaSearchProvider', () => {
let mockSearchClient: MockSearchClient
let mockSearchClient: any
beforeEach(() => {
vi.clearAllMocks()
@@ -38,11 +24,7 @@ describe('useAlgoliaSearchProvider', () => {
search: vi.fn()
}
vi.mocked(algoliasearch).mockReturnValue(
mockSearchClient as Partial<
ReturnType<typeof algoliasearch>
> as ReturnType<typeof algoliasearch>
)
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
})
afterEach(() => {
@@ -270,7 +252,7 @@ describe('useAlgoliaSearchProvider', () => {
})
describe('getSortValue', () => {
const testPack: Partial<RegistryNodePack> = {
const testPack = {
id: '1',
name: 'Test Pack',
downloads: 100,
@@ -297,10 +279,7 @@ describe('useAlgoliaSearchProvider', () => {
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
expect(
provider.getSortValue(
testPack as RegistryNodePack,
SortableAlgoliaField.Created
)
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
).toBe(createdTimestamp)
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
@@ -310,35 +289,23 @@ describe('useAlgoliaSearchProvider', () => {
})
it('should handle missing values', () => {
const incompletePack: Partial<RegistryNodePack> = {
id: '1',
name: 'Incomplete'
}
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useAlgoliaSearchProvider()
expect(
provider.getSortValue(
incompletePack as RegistryNodePack,
SortableAlgoliaField.Downloads
)
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
).toBe(0)
expect(
provider.getSortValue(
incompletePack as RegistryNodePack,
SortableAlgoliaField.Publisher
)
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
).toBe('')
expect(
provider.getSortValue(
incompletePack as RegistryNodePack,
incompletePack as any,
SortableAlgoliaField.Created
)
).toBe(0)
expect(
provider.getSortValue(
incompletePack as RegistryNodePack,
SortableAlgoliaField.Updated
)
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
).toBe(0)
})
})

View File

@@ -14,31 +14,20 @@ describe('useComfyRegistrySearchProvider', () => {
const mockListAllPacksCall = vi.fn()
const mockListAllPacksClear = vi.fn()
const createMockStore = (
params: Partial<ReturnType<typeof useComfyRegistryStore>> = {}
) => {
return {
search: {
call: mockSearchCall,
clear: mockSearchClear,
cancel: vi.fn()
},
listAllPacks: {
call: mockListAllPacksCall,
clear: mockListAllPacksClear,
cancel: vi.fn()
},
...params
} as Partial<ReturnType<typeof useComfyRegistryStore>> as ReturnType<
typeof useComfyRegistryStore
>
}
beforeEach(() => {
vi.clearAllMocks()
// Setup store mock
vi.mocked(useComfyRegistryStore).mockReturnValue(createMockStore())
vi.mocked(useComfyRegistryStore).mockReturnValue({
search: {
call: mockSearchCall,
clear: mockSearchClear
},
listAllPacks: {
call: mockListAllPacksCall,
clear: mockListAllPacksClear
}
} as any)
})
describe('searchPacks', () => {

View File

@@ -123,6 +123,7 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
| graphStore.ts | useCanvasStore | Manages the graph canvas state and interactions | Core |
| helpCenterStore.ts | useHelpCenterStore | Manages help center visibility and state | UI |
| imagePreviewStore.ts | useNodeOutputStore | Manages node outputs and execution results | Media |
| keybindingStore.ts | useKeybindingStore | Manages keyboard shortcuts | Input |
| maintenanceTaskStore.ts | useMaintenanceTaskStore | Handles system maintenance tasks | System |
| menuItemStore.ts | useMenuItemStore | Handles menu items and their state | UI |
| modelStore.ts | useModelStore | Manages AI models information | Models |

View File

@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
mockIsCloud.value = false
})
const createMockAsset = (id: string) => ({
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
id,
name: `asset-${id}`,
size: 100,
created_at: new Date().toISOString(),
tags: ['models'],
tags,
preview_url: `http://test.com/${id}`
})
@@ -751,4 +751,48 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.getAssets('tag:models')).toEqual([])
})
})
describe('hasCategory', () => {
it('should return true for loaded categories', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
})
it('should return true for tag-based category when tag: prefix is not used', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
await store.updateModelsForTag('models')
// hasCategory('models') checks for both 'models' and 'tag:models'
expect(store.hasCategory('models')).toBe(true)
})
it('should return false for unloaded categories', () => {
const store = useAssetsStore()
expect(store.hasCategory('checkpoints')).toBe(false)
expect(store.hasCategory('unknown-category')).toBe(false)
})
it('should return false after category is invalidated', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
store.invalidateCategory('checkpoints')
expect(store.hasCategory('checkpoints')).toBe(false)
})
})
})

View File

@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
@@ -608,17 +620,61 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Refreshes all node types that provide this category plus tag-based caches
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
async function invalidateModelsForCategory(
category: string
): Promise<void> {
const providers = modelToNodeStore
.getAllNodeProviders(category)
.filter((provider) => provider.nodeDef?.name)
const nodeTypeUpdates = providers.map((provider) =>
updateModelsForNodeType(provider.nodeDef.name)
)
const tagUpdates = [
updateModelsForTag(category),
updateModelsForTag('models')
]
await Promise.allSettled([...nodeTypeUpdates, ...tagUpdates])
}
/**
* Remove a specific asset from a category's cache.
* Used for optimistic updates after asset deletion.
* @param assetId The asset ID to remove
* @param category The model category (e.g., 'loras')
*/
function removeAssetFromCache(assetId: string, category: string): void {
const state = modelStateByCategory.value.get(category)
if (state) {
state.assets.delete(assetId)
assetsArrayCache.delete(category)
}
assetsArrayCache.delete(`tag:${category}`)
assetsArrayCache.delete('tag:models')
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory,
removeAssetFromCache
}
}
@@ -629,11 +685,14 @@ export const useAssetsStore = defineStore('assets', () => {
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {}
updateAssetTags: async () => {},
invalidateModelsForCategory: async () => {},
removeAssetFromCache: () => {}
}
}
@@ -643,11 +702,14 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory,
removeAssetFromCache
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -718,12 +780,15 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
// Model assets - actions
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory,
removeAssetFromCache
}
})

View File

@@ -127,9 +127,7 @@ describe('useComfyRegistryStore', () => {
}
vi.mocked(useComfyRegistryService).mockReturnValue(
mockRegistryService as Partial<
ReturnType<typeof useComfyRegistryService>
> as ReturnType<typeof useComfyRegistryService>
mockRegistryService as any
)
})
@@ -179,7 +177,7 @@ describe('useComfyRegistryStore', () => {
const store = useComfyRegistryStore()
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
const result = await store.getPackById.call(null!)
const result = await store.getPackById.call(null as any)
expect(result).toBeNull()
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()

View File

@@ -2,10 +2,11 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import type { ComfyExtension } from '@/types/comfy'
import { useKeybindingStore } from './keybindingStore'
import type { KeybindingImpl } from './keybindingStore'
export interface ComfyCommand {
id: string
function: (metadata?: Record<string, unknown>) => void | Promise<void>

View File

@@ -1,9 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { createTestingPinia } from '@pinia/testing'
// Mock DOM widget for testing
const createMockDOMWidget = (id: string) => {
@@ -16,7 +15,7 @@ const createMockDOMWidget = (id: string) => {
title: 'Test Node',
pos: [0, 0],
size: [200, 100]
} as Partial<LGraphNode> as LGraphNode,
} as any,
name: 'test_widget',
type: 'text',
value: 'test',
@@ -24,7 +23,7 @@ const createMockDOMWidget = (id: string) => {
y: 0,
margin: 10,
isVisible: () => true,
containerNode: undefined
containerNode: undefined as any
}
}

View File

@@ -1,5 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -9,8 +11,6 @@ const mockNodeIdToNodeLocatorId = vi.fn()
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
// Mock the workflowStore
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
@@ -72,11 +72,12 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
nodes: []
}
const mockNode = createMockLGraphNode({
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
})
} as any
// Mock app.rootGraph.getNodeById to return the mock node
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
@@ -177,11 +178,11 @@ describe('useExecutionStore - Node Error Lookups', () => {
nodes: []
}
const mockNode = createMockLGraphNode({
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
})
} as any
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

View File

@@ -1,28 +1,12 @@
import { FirebaseError } from 'firebase/app'
import type { User, UserCredential } from 'firebase/auth'
import * as firebaseAuth from 'firebase/auth'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { createTestingPinia } from '@pinia/testing'
// Hoisted mocks for dynamic imports
const { mockDistributionTypes } = vi.hoisted(() => ({
mockDistributionTypes: {
isCloud: true,
isDesktop: true
}
}))
type MockUser = Omit<User, 'getIdToken'> & {
getIdToken: Mock
}
type MockAuth = Record<string, unknown>
// Mock fetch
const mockFetch = vi.fn()
@@ -99,20 +83,35 @@ vi.mock('@/stores/toastStore', () => ({
// Mock useDialogService
vi.mock('@/services/dialogService')
const mockDistributionTypes = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
const mockApiKeyStore = vi.hoisted(() => ({
getAuthHeader: vi.fn().mockReturnValue(null)
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyStore
}))
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
let authStateCallback: (user: any) => void
let idTokenCallback: (user: any) => void
const mockAuth: MockAuth = {
const mockAuth = {
/* mock Auth object */
}
const mockUser: MockUser = {
const mockUser = {
uid: 'test-user-id',
email: 'test@example.com',
getIdToken: vi.fn().mockResolvedValue('mock-id-token')
} as Partial<User> as MockUser
}
beforeEach(() => {
vi.resetAllMocks()
@@ -124,18 +123,14 @@ describe('useFirebaseAuthStore', () => {
})
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
mockAuth as Partial<
ReturnType<typeof vuefire.useFirebaseAuth>
> as ReturnType<typeof vuefire.useFirebaseAuth>
)
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
(_, callback) => {
authStateCallback = callback as (user: User | null) => void
authStateCallback = callback as (user: any) => void
// Call the callback with our mock user
;(callback as (user: User | null) => void)(mockUser)
;(callback as (user: any) => void)(mockUser)
// Return an unsubscribe function
return vi.fn()
}
@@ -168,26 +163,21 @@ describe('useFirebaseAuthStore', () => {
})
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
beforeEach(() => {
mockDistributionTypes.isCloud = true
mockDistributionTypes.isDesktop = true
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {
idTokenCallback = callback as (user: User | null) => void
idTokenCallback = callback as (user: any) => void
return vi.fn()
}
)
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
mockAuth as Partial<
ReturnType<typeof vuefire.useFirebaseAuth>
> as ReturnType<typeof vuefire.useFirebaseAuth>
)
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
setActivePinia(createTestingPinia({ stubActions: false }))
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
store = useFirebaseAuthStore()
})
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {
@@ -202,14 +192,14 @@ describe('useFirebaseAuthStore', () => {
})
it('should not increment when ID token event is for a different user UID', () => {
const otherUser = { uid: 'other-user-id' } as Partial<User> as User
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
expect(store.tokenRefreshTrigger).toBe(0)
})
it('should increment after switching to a new UID and receiving a second event for that UID', () => {
const otherUser = { uid: 'other-user-id' } as Partial<User> as User
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
idTokenCallback?.(otherUser)
@@ -248,7 +238,7 @@ describe('useFirebaseAuthStore', () => {
// Now, succeed on next attempt
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
user: mockUser
} as Partial<UserCredential> as UserCredential)
} as any)
await store.login('test@example.com', 'correct-password')
})
@@ -257,7 +247,7 @@ describe('useFirebaseAuthStore', () => {
it('should login with valid credentials', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const result = await store.login('test@example.com', 'password')
@@ -293,7 +283,7 @@ describe('useFirebaseAuthStore', () => {
// Set up multiple login promises
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const loginPromise1 = store.login('user1@example.com', 'password1')
@@ -311,7 +301,7 @@ describe('useFirebaseAuthStore', () => {
it('should register a new user', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const result = await store.register('new@example.com', 'password')
@@ -388,7 +378,7 @@ describe('useFirebaseAuthStore', () => {
// Setup mock for login
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
// Login
@@ -464,6 +454,9 @@ describe('useFirebaseAuthStore', () => {
// This test reproduces the issue where getAuthHeader fails due to network errors
// when Firebase Auth tries to refresh tokens offline
// Configure mockApiKeyStore to return null (no API key fallback)
mockApiKeyStore.getAuthHeader.mockReturnValue(null)
// Setup user with network error on token refresh
mockUser.getIdToken.mockReset()
const networkError = new FirebaseError(
@@ -482,7 +475,7 @@ describe('useFirebaseAuthStore', () => {
it('should sign in with Google', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const result = await store.loginWithGoogle()
@@ -515,7 +508,7 @@ describe('useFirebaseAuthStore', () => {
it('should sign in with Github', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const result = await store.loginWithGithub()
@@ -547,7 +540,7 @@ describe('useFirebaseAuthStore', () => {
it('should handle concurrent social login attempts correctly', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential as any
)
const googleLoginPromise = store.loginWithGoogle()

View File

@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
describe('useKeybindingStore', () => {
beforeEach(() => {
@@ -177,14 +176,18 @@ describe('useKeybindingStore', () => {
combo: { key: 'I', ctrl: true }
})
// Add default keybinding
store.addDefaultKeybinding(defaultKeybinding)
expect(store.keybindings).toHaveLength(1)
// Unset the default keybinding
store.unsetKeybinding(defaultKeybinding)
expect(store.keybindings).toHaveLength(0)
// Add the same keybinding as a user keybinding
store.addUserKeybinding(defaultKeybinding)
// Check that the keybinding is back and not in the unset list
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(defaultKeybinding.combo)).toEqual(
defaultKeybinding
@@ -197,7 +200,10 @@ describe('useKeybindingStore', () => {
commandId: 'test.command',
combo: { key: 'J', ctrl: true }
})
// Add default keybinding.
// This can happen when we change default keybindings.
store.addDefaultKeybinding(keybinding)
// Add user keybinding.
store.addUserKeybinding(keybinding)
expect(store.keybindings).toHaveLength(1)
@@ -205,6 +211,10 @@ describe('useKeybindingStore', () => {
})
it('Should keep previously customized keybindings after default keybindings change', () => {
// Initially command 'foo' was bound to 'K, Ctrl'. User unset it and bound the
// command to 'A, Ctrl'.
// Now we change the default keybindings of 'foo' to 'A, Ctrl'.
// The user customized keybinding should be kept.
const store = useKeybindingStore()
const userUnsetKeybindings = [
@@ -381,15 +391,18 @@ describe('useKeybindingStore', () => {
it('should handle complex scenario with both unset and user keybindings', () => {
const store = useKeybindingStore()
// Create default keybinding
const defaultKeybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'Q', ctrl: true }
})
store.addDefaultKeybinding(defaultKeybinding)
// Unset default keybinding
store.unsetKeybinding(defaultKeybinding)
expect(store.keybindings).toHaveLength(0)
// Add user keybinding with different combo
const userKeybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'R', alt: true }
@@ -400,6 +413,7 @@ describe('useKeybindingStore', () => {
userKeybinding
)
// Reset keybinding to default
const result = store.resetKeybindingForCommand('test.command')
expect(result).toBe(true)

View File

@@ -1,20 +1,140 @@
import { groupBy } from 'es-toolkit/compat'
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, toRaw } from 'vue'
import type { KeyComboImpl } from './keyCombo'
import type { KeybindingImpl } from './keybinding'
import { RESERVED_BY_TEXT_INPUT } from '@/constants/reservedKeyCombos'
import type { KeyCombo, Keybinding } from '@/schemas/keyBindingSchema'
export class KeybindingImpl implements Keybinding {
commandId: string
combo: KeyComboImpl
targetElementId?: string
constructor(obj: Keybinding) {
this.commandId = obj.commandId
this.combo = new KeyComboImpl(obj.combo)
this.targetElementId = obj.targetElementId
}
equals(other: unknown): boolean {
const raw = toRaw(other)
return raw instanceof KeybindingImpl
? this.commandId === raw.commandId &&
this.combo.equals(raw.combo) &&
this.targetElementId === raw.targetElementId
: false
}
}
export class KeyComboImpl implements KeyCombo {
key: string
// ctrl or meta(cmd on mac)
ctrl: boolean
alt: boolean
shift: boolean
constructor(obj: KeyCombo) {
this.key = obj.key
this.ctrl = obj.ctrl ?? false
this.alt = obj.alt ?? false
this.shift = obj.shift ?? false
}
static fromEvent(event: KeyboardEvent) {
return new KeyComboImpl({
key: event.key,
ctrl: event.ctrlKey || event.metaKey,
alt: event.altKey,
shift: event.shiftKey
})
}
equals(other: unknown): boolean {
const raw = toRaw(other)
return raw instanceof KeyComboImpl
? this.key.toUpperCase() === raw.key.toUpperCase() &&
this.ctrl === raw.ctrl &&
this.alt === raw.alt &&
this.shift === raw.shift
: false
}
serialize(): string {
return `${this.key.toUpperCase()}:${this.ctrl}:${this.alt}:${this.shift}`
}
toString(): string {
return this.getKeySequences().join(' + ')
}
get hasModifier(): boolean {
return this.ctrl || this.alt || this.shift
}
get isModifier(): boolean {
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
get modifierCount(): number {
const modifiers = [this.ctrl, this.alt, this.shift]
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
}
get isShiftOnly(): boolean {
return this.shift && this.modifierCount === 1
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
)
}
getKeySequences(): string[] {
const sequences: string[] = []
if (this.ctrl) {
sequences.push('Ctrl')
}
if (this.alt) {
sequences.push('Alt')
}
if (this.shift) {
sequences.push('Shift')
}
sequences.push(this.key)
return sequences
}
}
export const useKeybindingStore = defineStore('keybinding', () => {
/**
* Default keybindings provided by core and extensions.
*/
const defaultKeybindings = ref<Record<string, KeybindingImpl>>({})
/**
* User-defined keybindings.
*/
const userKeybindings = ref<Record<string, KeybindingImpl>>({})
/**
* User-defined keybindings that unset default keybindings.
*/
const userUnsetKeybindings = ref<Record<string, KeybindingImpl>>({})
/**
* Get user-defined keybindings.
*/
function getUserKeybindings() {
return userKeybindings.value
}
/**
* Get user-defined keybindings that unset default keybindings.
*/
function getUserUnsetKeybindings() {
return userUnsetKeybindings.value
}
@@ -47,7 +167,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
const keybindingsByCommandId = computed<Record<string, KeybindingImpl[]>>(
() => {
return groupBy(keybindings.value, 'commandId')
return _.groupBy(keybindings.value, 'commandId')
}
)
@@ -58,13 +178,26 @@ export const useKeybindingStore = defineStore('keybinding', () => {
const defaultKeybindingsByCommandId = computed<
Record<string, KeybindingImpl[]>
>(() => {
return groupBy(Object.values(defaultKeybindings.value), 'commandId')
return _.groupBy(Object.values(defaultKeybindings.value), 'commandId')
})
function getKeybindingByCommandId(commandId: string) {
return getKeybindingsByCommandId(commandId)[0]
}
/**
* Adds a keybinding to the specified target reference.
*
* @param target - A ref that holds a record of keybindings. The keys represent
* serialized key combos, and the values are `KeybindingImpl` objects.
* @param keybinding - The keybinding to add, represented as a `KeybindingImpl` object.
* @param options - An options object.
* @param options.existOk - If true, allows overwriting an existing keybinding with the
* same combo. Defaults to false.
*
* @throws {Error} Throws an error if a keybinding with the same combo already exists in
* the target and `existOk` is false.
*/
function addKeybinding(
target: Ref<Record<string, KeybindingImpl>>,
keybinding: KeybindingImpl,
@@ -90,6 +223,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
const userUnsetKeybinding =
userUnsetKeybindings.value[keybinding.combo.serialize()]
// User is adding back a keybinding that was an unsetted default keybinding.
if (
keybinding.equals(defaultKeybinding) &&
keybinding.equals(userUnsetKeybinding)
@@ -98,6 +232,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
return
}
// Unset keybinding on default keybinding if it exists and is not the same as userUnsetKeybinding
if (defaultKeybinding && !defaultKeybinding.equals(userUnsetKeybinding)) {
unsetKeybinding(defaultKeybinding)
}
@@ -127,6 +262,11 @@ export const useKeybindingStore = defineStore('keybinding', () => {
console.warn(`Unset unknown keybinding: ${JSON.stringify(keybinding)}`)
}
/**
* Update the keybinding on given command if it is different from the current keybinding.
*
* @returns true if the keybinding is updated, false otherwise.
*/
function updateKeybindingOnCommand(keybinding: KeybindingImpl): boolean {
const currentKeybinding = getKeybindingByCommandId(keybinding.commandId)
if (currentKeybinding?.equals(keybinding)) {
@@ -144,11 +284,18 @@ export const useKeybindingStore = defineStore('keybinding', () => {
userUnsetKeybindings.value = {}
}
/**
* Resets the keybinding for a given command to its default value.
*
* @param commandId - The commandId of the keybind to be reset
* @returns `true` if changes were made, `false` if not
*/
function resetKeybindingForCommand(commandId: string): boolean {
const currentKeybinding = getKeybindingByCommandId(commandId)
const defaultKeybinding =
defaultKeybindingsByCommandId.value[commandId]?.[0]
// No default keybinding exists, need to remove any user binding
if (!defaultKeybinding) {
if (currentKeybinding) {
unsetKeybinding(currentKeybinding)
@@ -157,14 +304,17 @@ export const useKeybindingStore = defineStore('keybinding', () => {
return false
}
// Current binding equals default binding, no changes needed
if (currentKeybinding?.equals(defaultKeybinding)) {
return false
}
// Unset current keybinding if exists
if (currentKeybinding) {
unsetKeybinding(currentKeybinding)
}
// Remove the unset record if it exists
const serializedCombo = defaultKeybinding.combo.serialize()
if (
userUnsetKeybindings.value[serializedCombo]?.equals(defaultKeybinding)

View File

@@ -7,19 +7,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
function _findInMetadata(
metadata: Record<string, string | null>,
...keys: string[]
): string | null {
function _findInMetadata(metadata: any, ...keys: string[]): string | null {
for (const key of keys) {
if (key in metadata) {
const value = metadata[key]
return value || null
return metadata[key]
}
for (const k in metadata) {
if (k.endsWith(key)) {
const value = metadata[k]
return value || null
return metadata[k]
}
}
}

View File

@@ -239,7 +239,7 @@ describe('useModelToNodeStore', () => {
it('should not register provider when nodeDef is undefined', () => {
const modelToNodeStore = useModelToNodeStore()
const providerWithoutNodeDef = new ModelNodeProvider(
undefined!,
undefined as any,
'custom_key'
)
@@ -507,11 +507,15 @@ describe('useModelToNodeStore', () => {
modelToNodeStore.registerDefaults()
// These should not throw but return undefined
expect(modelToNodeStore.getCategoryForNodeType(null!)).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(undefined!)
modelToNodeStore.getCategoryForNodeType(null as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(undefined as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(123 as any)
).toBeUndefined()
expect(modelToNodeStore.getCategoryForNodeType('123')).toBeUndefined()
})
it('should be case-sensitive for node type matching', () => {

View File

@@ -89,7 +89,7 @@ export class ComfyNodeDefImpl
* @internal
* Migrate default input options to forceInput.
*/
private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
static #migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
const def = _.cloneDeep(nodeDef)
def.input ??= {}
// For required inputs, now we have the input socket always present. Specifying
@@ -118,7 +118,7 @@ export class ComfyNodeDefImpl
}
constructor(def: ComfyNodeDefV1) {
const obj = ComfyNodeDefImpl._migrateDefaultInput(def)
const obj = ComfyNodeDefImpl.#migrateDefaultInput(def)
/**
* Assign extra fields to `this` for compatibility with group node feature.

View File

@@ -38,11 +38,7 @@ function createHistoryJob(createTime: number, id: string): JobListItem {
const createTaskOutput = (
nodeId: string = 'node-1',
images: {
type?: 'output' | 'input' | 'temp'
filename?: string
subfolder?: string
}[] = []
images: any[] = []
): TaskOutput => ({
[nodeId]: {
images
@@ -494,7 +490,7 @@ describe('useQueueStore', () => {
it('should recreate TaskItemImpl when outputs_count changes', async () => {
// Initial load without outputs_count
const jobWithoutOutputsCount = createHistoryJob(10, 'job-1')
delete jobWithoutOutputsCount.outputs_count
delete (jobWithoutOutputsCount as any).outputs_count
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
mockGetHistory.mockResolvedValue([jobWithoutOutputsCount])

View File

@@ -7,9 +7,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -40,7 +38,7 @@ vi.mock('@/scripts/app', () => {
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => app.canvas
getCanvas: () => (app as any).canvas
})
}))
@@ -65,8 +63,7 @@ describe('useSubgraphNavigationStore', () => {
} as ComfyWorkflow
// Set the active workflow (cast to bypass TypeScript check in test)
workflowStore.activeWorkflow =
mockWorkflow as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = mockWorkflow as any
// Simulate being in a subgraph by restoring state
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
@@ -75,9 +72,7 @@ describe('useSubgraphNavigationStore', () => {
// Simulate a change to the workflow's internal state
// (e.g., changeTracker.activeState being reassigned)
mockWorkflow.changeTracker = {
activeState: {}
} as typeof mockWorkflow.changeTracker
mockWorkflow.changeTracker = { activeState: {} } as any
// The navigation stack should NOT be cleared because the path hasn't changed
expect(navigationStore.exportState()).toHaveLength(2)
@@ -92,15 +87,14 @@ describe('useSubgraphNavigationStore', () => {
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json',
changeTracker: createMockChangeTracker({
changeTracker: {
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
}
} as unknown as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = workflow1 as any
// Simulate the restore process that happens when loading a workflow
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
@@ -114,14 +108,13 @@ describe('useSubgraphNavigationStore', () => {
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json',
changeTracker: createMockChangeTracker({
changeTracker: {
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
}
} as unknown as ComfyWorkflow
workflowStore.activeWorkflow =
workflow2 as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = workflow2 as any
// Simulate the restore process for workflow2
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
@@ -131,8 +124,7 @@ describe('useSubgraphNavigationStore', () => {
expect(navigationStore.exportState()).toHaveLength(0)
// Switch back to workflow1
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = workflow1 as any
// Simulate the restore process for workflow1 again
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
@@ -146,18 +138,17 @@ describe('useSubgraphNavigationStore', () => {
it('should clear navigation when activeSubgraph becomes undefined', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Create mock subgraph and graph structure
const mockSubgraph = {
id: 'subgraph-1',
rootGraph: app.graph,
rootGraph: (app as any).graph,
_nodes: [],
nodes: []
} as Partial<Subgraph> as Subgraph
}
// Add the subgraph to the graph's subgraphs map
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
;(app as any).graph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = {
@@ -165,14 +156,13 @@ describe('useSubgraphNavigationStore', () => {
filename: 'test-workflow.json'
} as ComfyWorkflow
workflowStore.activeWorkflow =
mockWorkflow as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = mockWorkflow as any
// Mock findSubgraphPathById to return the correct path
vi.mocked(findSubgraphPathById).mockReturnValue(['subgraph-1'])
// Set canvas.subgraph and trigger update to set activeSubgraph
app.canvas.subgraph = mockSubgraph
;(app as any).canvas.subgraph = mockSubgraph
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change
@@ -183,7 +173,7 @@ describe('useSubgraphNavigationStore', () => {
expect(navigationStore.exportState()).toEqual(['subgraph-1'])
// Clear canvas.subgraph and trigger update (simulating navigating back to root)
app.canvas.subgraph = undefined
;(app as any).canvas.subgraph = null
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change

Some files were not shown because too many files have changed in this diff Show More