Compare commits

...

29 Commits

Author SHA1 Message Date
Alexander Brown
53b1170bd2 Silence some errors on the websocket proxy 2026-01-31 01:53:41 -08:00
Alexander Brown
50c1754da6 deps: Update vite 2026-01-31 00:54:05 -08:00
AustinMroz
be7c34e28b Update control_after_generate schema (#8505)
Updates `control_after_generate` in the schema to support specifying the
default control value as a string

See Comfy-Org/ComfyUI#12187

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8505-Update-control_after_generate-schema-2f96d73d365081f9bf73c804072bb415)
by [Unito](https://www.unito.io)
2026-01-30 21:34:50 -08:00
Comfy Org PR Bot
a807986835 1.39.3 (#8500)
Patch version increment to 1.39.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8500-1-39-3-2f96d73d365081a9a74fd1fcb7e33a41)
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>
2026-01-30 20:30:35 -08:00
Christian Byrne
679288500a fix(cloud): disable legacy node templates feature on cloud (#8462)
## Summary

Disables the legacy "node templates" feature on ComfyUI Cloud. This
feature is accessed via right-clicking on the canvas and is distinct
from both subgraphs and workflow templates.

## Problem

The legacy node templates feature presents false-positive errors on
cloud:
- Errors appear when no existing templates exist initially
- After refresh, workflow progress is lost from workflow tabs
- Node templates fail to delete (except the first one)

## Solution

Conditionally load the `nodeTemplates` extension only for non-cloud
builds by wrapping the import in `if (!isCloud)`.

## Testing

- [ ] Verify right-click canvas menu no longer shows "Node Templates"
submenu on cloud
- [ ] Verify right-click canvas menu no longer shows "Save Selected as
Template" on cloud
- [ ] Verify the feature still works on desktop/OSS builds

## Related

- Refs: COM-14105
- Related issue: #4056 (Migrate Node Templates to Workflows)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8462-fix-cloud-disable-legacy-node-templates-feature-on-cloud-2f86d73d36508162948cc897d4c7ef5e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 16:51:31 -08:00
Christian Byrne
34d42311ea fix: add security params to Contact Support window.open call (#8470)
## Summary

Adds `'noopener,noreferrer'` to the `window.open()` call in
`Comfy.ContactSupport` command, aligning with the established codebase
pattern for external links.

## Changes

- Updated `src/composables/useCoreCommands.ts` line 865 to include
security parameters

## Testing

- [x] Typecheck passes
- [x] Lint passes

## Related

- Fixes COM-13631
- Notion: https://www.notion.so/2ee6d73d365081328ae5cc7bce64b310

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8470-fix-add-security-params-to-Contact-Support-window-open-call-2f86d73d365081e38ea1c1ea9c9883f5)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 16:51:04 -08:00
Christian Byrne
c4e5fc8dbf fix: update reactive ref after merge in imagePreviewStore (#8479)
## Summary
Fix for COM-14110: Preview image does not display new outputs in
vue-nodes.

## Problem
The merge logic in `setOutputsByLocatorId` updated `app.nodeOutputs` but
returned early without updating the reactive `nodeOutputs.value` ref.
This caused Vue components to never receive merged output updates
because only the non-reactive `app.nodeOutputs` was being updated.

## Solution
Added `nodeOutputs.value[nodeLocatorId] = existingOutput` after the
merge loop, before the return statement.

## Testing
- Added 2 unit tests covering the merge behavior
- All 4076 existing unit tests pass
- Typechecks pass
- Lint passes

## Notes
- Related open PRs touching same files: #8143, #8366 - potential minor
conflicts possible

Fixes COM-14110

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8479-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f86d73d365081f1a145fa5a9782515f)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 16:49:57 -08:00
Johnpaul Chiwetelu
a64c561a5f Road to No explicit any: Group 8 (part 8) test files (#8496)
## 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 improper `as unknown as Type` patterns from test files in
Group 8 part 8
- Replaced with proper TypeScript patterns using Pinia store testing
patterns
- Fixed parameter shadowing issue in typeGuardUtil.test.ts (constructor
→ nodeConstructor)
- Fixed stale mock values in useConflictDetection.test.ts using getter
functions
- Refactored useManagerState tests to follow proper Pinia store testing
patterns with createTestingPinia

### Files Changed

Test files (Group 8 part 8 - utils and manager composables):
- src/utils/typeGuardUtil.test.ts - Fixed parameter shadowing
- src/utils/graphTraversalUtil.test.ts - Removed unsafe type assertions
- src/utils/litegraphUtil.test.ts - Improved type handling
- src/workbench/extensions/manager/composables/useManagerState.test.ts -
Complete rewrite using Pinia testing patterns
-
src/workbench/extensions/manager/composables/useConflictDetection.test.ts
- Fixed stale mock values with getters
- src/workbench/extensions/manager/composables/useManagerQueue.test.ts -
Type safety improvements
-
src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts
- Removed unsafe casts
-
src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts
- Type improvements
-
src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts
- Type improvements
- src/workbench/extensions/manager/utils/versionUtil.test.ts - Type
safety fixes

Source files (minor type fixes):
- src/utils/fuseUtil.ts - Type improvements
- src/utils/linkFixer.ts - Type safety fixes
- src/utils/syncUtil.ts - Type improvements
-
src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts
- Type fix
-
src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts
- Type fix

### Testing
- All TypeScript type checking passes (`pnpm typecheck`)
- All affected test files pass (`pnpm test:unit`)
- Linting passes without errors (`pnpm lint`)
- Code formatting applied (`pnpm format`)

Part of the "Road to No Explicit Any" initiative, cleaning up type
casting issues from branch `fix/remove-any-types-part8`.

### 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
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496 (this PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8496-Road-to-No-explicit-any-Group-8-part-8-test-files-2f86d73d365081f3afdcf8d01fba81e1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-30 22:25:10 +01:00
Alexander Brown
59c58379fe fix: migrate remaining ECMAScript private fields to TypeScript private (#8495)
Migrates remaining `#field` syntax to `private _field` for Vue
reactivity compatibility.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8495-fix-migrate-remaining-ECMAScript-private-fields-to-TypeScript-private-2f86d73d365081ec87afe2273c0ff6eb)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 13:24:16 -08:00
AustinMroz
6c14ae6f90 Don't bypass subgraph contents with subgraph (#8494)
When bypassing or muting a subgraph the contents are no longer bypassed
along with it. This behaviour was less than ideal because it meant that
toggling the bypass of a single subgraph node twice could change the
behaviour of the node.

It is entirely intended that a subgraph node which is bypassed does not
have it's children execute. As part of testing this behaviour, it was
found that nodes inside of a bypassed subgraph are still considered for
execution even if boundry links are treated as disconnected. The
following example would execute even if the subgraph is muted.
<img width="826" height="476" alt="image"
src="https://github.com/user-attachments/assets/7b282873-e114-494d-b8f1-74c373859151"
/>

To resolve this, the PR does not add the contents of a subgraphNode
which is muted or bypassed to the execution map.

Resolves #8489

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8494-Don-t-bypass-subgraph-contents-with-subgraph-2f86d73d365081aeba8dd2990b6ba0ad)
by [Unito](https://www.unito.io)
2026-01-30 10:56:44 -08:00
Christian Byrne
ee600a8951 fix: prevent image/video preview reset on dynamic widget addition (#8366)
## Summary

Fixes image/video previews getting stuck in loading state when widgets
are added dynamically to a node.

## Problem

When dynamic widgets are added to a node (e.g., by extensions), Vue
reactivity triggers the watch on `imageUrls` prop even when the URL
content is identical—the array has a new reference but the same values.
This caused:
1. `startDelayedLoader()` to reset loading state to pending
2. If the cached image doesn't trigger `@load` before the 250ms timeout,
the loader shows and stays stuck

## Solution

Compare URL arrays by content, not reference. Only reset loading state
when URLs actually change:
- Check array length and element-by-element equality
- Return early if URLs are identical (just a new array reference)
- Remove `deep: true` since we compare manually

## Screenshots

<img width="749" height="647" alt="image"
src="https://github.com/user-attachments/assets/3a1ff656-59ed-467a-a121-b70b91423a50"
/>


<img width="749" height="647" alt="Screenshot from 2026-01-28 15-24-18"
src="https://github.com/user-attachments/assets/28265dad-1d79-47c8-9fd4-5a82b94e72cd"
/>

<img width="749" height="647" alt="Screenshot from 2026-01-28 15-24-05"
src="https://github.com/user-attachments/assets/c7af93b7-c898-405f-860b-0f82abe5af6d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8366-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f66d73d3650819483b2d5cbfb78187f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:27:39 -08:00
Christian Byrne
e7d3bc7285 fix: properties panel obscures menus in legacy layout (#8474)
## Summary

Fixes overlap issue where the new menu's node properties panel could
cover the old floating menu.

## Changes

Hide the properties panel when using the legacy menu, so it doesn't
obscure the old menu. The properties panel did not work anyway (empy
content).


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8474-fix-increase-z-index-of-legacy-floating-menu-2f86d73d365081a0ab44db24c2ea6357)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:16:11 -08:00
Alexander Brown
b4649bc96d Set IS_NIGHTLY right when we build (#8482)
## Summary

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8482-Set-IS_NIGHTLY-right-when-we-build-2f86d73d365081009fdbc2aee8f7545d)
by [Unito](https://www.unito.io)
2026-01-30 01:11:41 -08:00
sno
d7ec24abc3 fix: use PR_GH_TOKEN in lint/i18n workflows to trigger e2e tests (#8484)
## Summary

Add `PR_GH_TOKEN` to ci-lint-format and i18n-update-core workflows to
ensure commits trigger e2e test runs.

## Changes

- Add `token: ${{ secrets.PR_GH_TOKEN }}` to checkout step in
`.github/workflows/ci-lint-format.yaml`
- Add `token: ${{ secrets.PR_GH_TOKEN }}` to checkout step in
`.github/workflows/i18n-update-core.yaml`

## Context

This matches the pattern used consistently across all other workflows in
the repository (release-version-bump, version-bump-desktop-ui,
api-update-registry-api-types, i18n-update-nodes, etc.).

Without this token, commits made by these workflows don't trigger
downstream e2e tests, which can lead to issues being missed.

## Original Work

Original idea & implementation by @drjkl in commits:
- be3a8d61c - fix: use GitHub App token for lint/i18n workflows to
trigger e2e tests
- 50ccb254b - Add github app token action to pinact

This PR simplifies the approach to use the existing `PR_GH_TOKEN` secret
instead of GitHub App tokens, for consistency with the rest of the
repository.

## Test Plan

- [x] Verify workflows can checkout code successfully
- [x] Confirm commits from these workflows trigger e2e test runs

## It works!
<img width="1075" height="402" alt="image"
src="https://github.com/user-attachments/assets/31762d68-a37f-4926-b463-84d184d4309d"
/>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-30 00:09:28 -08:00
Christian Byrne
1ac214a1cd docs: add Vite preload error handling documentation comment (#8475)
## Summary

Add documentation comment explaining the Vite preload error handler with
a link to official documentation.

## Changes

- **What**: Added a 2-line comment above the `vite:preloadError` event
listener in App.vue explaining its purpose and linking to
https://vite.dev/guide/build#load-error-handling

Fixes COM-14132

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8475-docs-add-Vite-preload-error-handling-documentation-comment-2f86d73d3650815f9731f30bd9c5eb57)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 21:06:30 -08:00
Christian Byrne
985d024a6e fix: handle non-string serverLogs in error report (#8460)
## Summary
- Fix `[object Object]` display in error report logs section

## Changes
- Add runtime type check for `serverLogs` in error report template
- JSON stringify object logs with proper formatting

## Test plan
- [x] Verify string logs still display correctly
- [x] Verify object logs are properly stringified instead of showing
`[object Object]`

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/8463

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8460-fix-handle-non-string-serverLogs-in-error-report-2f86d73d36508179af5afcdeec025a75)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
2026-01-29 19:47:59 -08:00
Christian Byrne
ee4a205d32 fix: Vue mode socket map data not cleaned up on dynamic input changes (#8469)
## Summary

Fixes a bug where socket map data was not properly removed when sockets
are dynamically added/removed via DynamicCombo widgets in Vue mode
(Nodes 2.0).

## Problem

When DynamicCombo widgets (e.g., `should_remesh` on Meshy nodes) change
their selection, inputs are dynamically added/removed. The Vue `v-for`
loop in `NodeSlots.vue` was using array index as the `:key`, causing Vue
to **reuse** slot components instead of properly unmounting them.

This led to:
- Socket map entries leaking (never cleaned up)
- Socket positions becoming desynced
- Stale cached offset data

## Solution

1. **Use slot `name` as Vue key** instead of array index in
`NodeSlots.vue`
   - Slot names are unique per node (enforced by ComfyUI backend)
- When a slot is removed, Vue sees the key disappear and properly
unmounts the component
- `onUnmounted` cleanup in `useSlotElementTracking` now runs correctly

2. **Add defensive cleanup** in `useSlotElementTracking.ts`
- Before registering a new slot, check if a stale entry exists with the
same key
   - Clean up stale entry to handle any edge cases

## Related

- Fixes COM-12970
- Related to #7837 (fixed LiteGraph version of this bug, but not Vue
mode)

## Testing

- Quality checks pass (typecheck, lint, format)
- Manual testing with DynamicCombo nodes (Meshy, nodes_logic)
recommended

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8469-fix-Vue-mode-socket-map-data-not-cleaned-up-on-dynamic-input-changes-2f86d73d365081e599eadca4f15e6b6e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 19:47:43 -08:00
Kelly Yang
d784d4982b Improve template search input performance issue (#8343)
## Summary

Improve Template search input performance issue #8134
This was caused by the search logic running too frequently (throttled at
50ms), causing the main thread to block on every few keystrokes.


## Changes
Use debouncing that wait until you stop typing for a specific time
(300ms) before running.
It makes the searching function more smoothly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8343-Improve-template-search-input-performance-issue-2f56d73d36508144bdf9fa5e0cd76818)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-29 19:05:37 -08:00
Christian Byrne
47113b117e refactor: migrate keybindings to DDD structure (#8369)
## Summary

Migrate keybindings domain to `src/platform/keybindings/` following DDD
principles.

## Changes

- **What**: Consolidate keybinding-related code (types, store, service,
defaults, reserved keys) into a single domain module with flat structure
- Extracted `KeyComboImpl` and `KeybindingImpl` classes into separate
files
- Updated all consumers to import from new location
- Colocated tests with source files
- Updated stores/README.md and services/README.md to remove migrated
entries

## Review Focus

- Verify all import paths were updated correctly
- Check that the flat structure is appropriate (vs nested core/data/ui
layers)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8369-refactor-migrate-keybindings-to-DDD-structure-2f66d73d36508120b169dc737075fb45)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:40:58 -08:00
Johnpaul Chiwetelu
13311a46ea Road to No explicit any: Group 8 (part 7) test files (#8459)
## 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 improper `as unknown as Type` patterns from 17 test files in
Group 8 part 7
- Replaced with proper TypeScript patterns using factory functions and
Mock types
- Fixed createTestingPinia usage in test files (was incorrectly using
createPinia)
- Fixed vi.hoisted pattern for mockSetDirty in viewport tests  
- Fixed vi.doMock lint issues with vi.mock and vi.hoisted pattern
- Retained necessary `as unknown as` casts only for complex mock objects
where direct type assertions would fail

### Files Changed

Test files (Group 8 part 7 - services, stores, utils):
- src/services/nodeOrganizationService.test.ts
- src/services/providers/algoliaSearchProvider.test.ts
- src/services/providers/registrySearchProvider.test.ts
- src/stores/comfyRegistryStore.test.ts
- src/stores/domWidgetStore.test.ts
- src/stores/executionStore.test.ts
- src/stores/firebaseAuthStore.test.ts
- src/stores/modelToNodeStore.test.ts
- src/stores/queueStore.test.ts
- src/stores/subgraphNavigationStore.test.ts
- src/stores/subgraphNavigationStore.viewport.test.ts
- src/stores/subgraphStore.test.ts
- src/stores/systemStatsStore.test.ts
- src/stores/workspace/nodeHelpStore.test.ts
- src/utils/colorUtil.test.ts
- src/utils/executableGroupNodeChildDTO.test.ts

Source files:
- src/stores/modelStore.ts - Improved type handling

### Testing
- All TypeScript type checking passes (`pnpm typecheck`)
- All affected test files pass (`pnpm test:unit`)
- Linting passes without errors (`pnpm lint`)
- Code formatting applied (`pnpm format`)

Part of the "Road to No Explicit Any" initiative, cleaning up type
casting issues from branch `fix/remove-any-types-part8`.

### 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
- Part 8 Group 7: #8459 (this PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8459-Road-to-No-explicit-any-Group-8-part-7-test-files-2f86d73d36508114ad28d82e72a3a5e9)
by [Unito](https://www.unito.io)
2026-01-30 03:38:06 +01:00
Alexander Brown
067d80c4ed refactor: migrate ES private fields to TypeScript private for Vue Proxy compatibility (#8440)
## Summary

Migrates ECMAScript private fields (`#`) to TypeScript private
(`private`) across LiteGraph to fix Vue Proxy reactivity
incompatibility.

## Problem

ES private fields (`#field`) are incompatible with Vue's Proxy-based
reactivity system - accessing `#field` through a Proxy throws
`TypeError: Cannot read private member from an object whose class did
not declare it`.

## Solution

- Converted all `#field` to `private _field` across 10 phases
- Added `toJSON()` methods to `LGraph`, `NodeSlot`, `NodeInputSlot`, and
`NodeOutputSlot` to prevent circular reference errors during
serialization (TypeScript private fields are visible to `JSON.stringify`
unlike true ES private fields)
- Made `DragAndScale.element.data` non-enumerable to break canvas
circular reference chain

## Testing

- All 4027 unit tests pass
- Added 9 new serialization tests to catch future circular reference
issues
- Browser tests (undo/redo, save workflows) verified working

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8440-refactor-migrate-ES-private-fields-to-TypeScript-private-for-Vue-Proxy-compatibility-2f76d73d365081a3bd82d429a3e0fcb7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:18:58 -08:00
Alexander Brown
82bacb82a7 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 16:34:56 -08:00
Christian Byrne
80ccc13659 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 16:26:51 -08:00
AustinMroz
dce6fd1040 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 16:26:07 -08:00
AustinMroz
e2625a4055 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 16:25:16 -08:00
Alexander Brown
a4cf9a1ca8 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 16:23:16 -08:00
Jin Yi
d437b96238 [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 16:21:08 -08:00
Christian Byrne
f1cf8073d6 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 16:19:25 -08:00
Jin Yi
d7654baebf [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 16:16:28 -08:00
208 changed files with 4870 additions and 3133 deletions

View File

@@ -21,6 +21,7 @@ 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,6 +17,8 @@ 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,6 +50,7 @@ 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

@@ -98,12 +98,10 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
keepNames: true,
strictExecutionOrder: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

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/schemas/keyBindingSchema'
import type { KeyCombo } from '../../src/platform/keybindings'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'

View File

@@ -7,7 +7,7 @@ import { webSocketFixture } from '../fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Actionbar', () => {
test.describe('Actionbar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Shortcuts', () => {
test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Browser tab title', { tag: '@smoke' }, () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')

View File

@@ -19,7 +19,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', () => {
test.describe('Change Tracker', { tag: '@workflow' }, () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')

View File

@@ -151,7 +151,7 @@ const customColorPalettes: Record<string, Palette> = {
}
}
test.describe('Color Palette', () => {
test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
// Reload to apply the new setting. Setting Comfy.CustomColorPalettes directly
@@ -194,104 +194,110 @@ test.describe('Color Palette', () => {
})
})
test.describe('Node Color Adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
})
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
})
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png')
})
test.describe('Context menu color adjustments', () => {
test.describe(
'Node Color Adjustments',
{ tag: ['@screenshot', '@settings'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
})
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.getFirstNodeRef()
await node?.clickContextMenuOption('Colors')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
})
test('should persist color adjustments when changing custom node colors', async ({
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("red")')
.click()
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-changed.png'
'node-lightened-colors.png'
)
})
test('should persist color adjustments when removing custom node color', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("No color")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-removed.png'
)
test.describe('Context menu color adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.getFirstNodeRef()
await node?.clickContextMenuOption('Colors')
})
test('should persist color adjustments when changing custom node colors', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("red")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-changed.png'
)
})
test('should persist color adjustments when removing custom node color', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("No color")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-removed.png'
)
})
})
})
})
}
)

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {
window['foo'] = true

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', () => {
test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)

View File

@@ -22,7 +22,7 @@ async function verifyCustomIconSvg(iconElement: Locator) {
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
}
test.describe('Custom Icons', () => {
test.describe('Custom Icons', { tag: '@settings' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -1,14 +1,14 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import type { Keybinding } from '../../src/platform/keybindings'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', () => {
test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage
}) => {

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('DOM Widget', () => {
test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
@@ -29,12 +29,16 @@ test.describe('DOM Widget', () => {
await expect(lastMultiline).not.toBeVisible()
})
test('Position update when entering focus mode', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
})
test(
'Position update when entering focus mode',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
)
// No DOM widget should be created by creation of interim LGraphNode objects.
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {

View File

@@ -6,40 +6,48 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Execution', () => {
test('Report error on unconnected slot', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await comfyPage.clickEmptySpace()
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test(
'Report error on unconnected slot',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await comfyPage.clickEmptySpace()
await comfyPage.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
})
await comfyPage.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
}
)
})
test.describe('Execute to selected output nodes', () => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
test.describe(
'Execute to selected output nodes',
{ tag: ['@smoke', '@workflow'] },
() => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
})
})
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
})
}
)

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Feature Flags', () => {
test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage
}) => {

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph', () => {
test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Should be able to fix link input slot index after swap the input order
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph Canvas Menu', () => {
test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'
// side effects.
@@ -15,29 +15,33 @@ test.describe('Graph Canvas Menu', () => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
})
test('Can toggle link visibility', async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window['LiteGraph'].HIDDEN_LINK
})
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
test(
'Can toggle link visibility',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window['LiteGraph'].HIDDEN_LINK
})
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
hiddenLinkRenderMode
)
})
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
hiddenLinkRenderMode
)
}
)
test('Toggle minimap button is clickable and has correct test id', async ({
comfyPage

View File

@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Group Node', () => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
@@ -89,16 +89,20 @@ test.describe('Group Node', () => {
// does not have a v-model on the query, so we cannot observe the raw
// query update, and thus cannot set the spinning state between the raw query
// update and the debounced search update.
test.skip('Can be added to canvas using search', async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
})
test.skip(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)

View File

@@ -13,7 +13,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Item Interaction', () => {
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
@@ -60,13 +60,17 @@ test.describe('Node Interaction', () => {
})
})
test('@2x Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
test(
'@2x Can highlight selected',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
}
)
const dragSelectNodes = async (
comfyPage: ComfyPage,
@@ -150,12 +154,12 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', async ({ comfyPage }) => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.dragNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test.describe('Edge Interaction', () => {
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
@@ -222,12 +226,18 @@ test.describe('Node Interaction', () => {
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
})
test(
'Can adjust widget value',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot(
'adjusted-widget-value.png'
)
}
)
test('Link snap to slot', async ({ comfyPage }) => {
test('Link snap to slot', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
@@ -244,57 +254,67 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
test('Can batch move links by drag with shift', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
test(
'Can batch move links by drag with shift',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
const outputSlot1Pos = {
x: 304,
y: 127
const outputSlot1Pos = {
x: 304,
y: 127
}
const outputSlot2Pos = {
x: 307,
y: 310
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
'batch_move_links_moved.png'
)
}
const outputSlot2Pos = {
x: 307,
y: 310
)
test(
'Can batch disconnect links with ctrl+alt+click',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const loadCheckpointClipSlotPos = {
x: 332,
y: 508
}
await comfyPage.canvas.click({
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'batch-disconnect-links-disconnected.png'
)
}
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
'batch_move_links_moved.png'
)
})
test('Can batch disconnect links with ctrl+alt+click', async ({
comfyPage
}) => {
const loadCheckpointClipSlotPos = {
x: 332,
y: 508
test(
'Can toggle dom widget node open/closed',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
}
await comfyPage.canvas.click({
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'batch-disconnect-links-disconnected.png'
)
})
test('Can toggle dom widget node open/closed', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
})
)
test('Can close prompt dialog with canvas click (number widget)', async ({
comfyPage
@@ -341,19 +361,23 @@ test.describe('Node Interaction', () => {
await expect(legacyPrompt).toBeHidden()
})
test('Can double click node title to edit', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
y: 10
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
})
test(
'Can double click node title to edit',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
y: 10
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
}
)
test('Double click node body does not trigger edit', async ({
comfyPage
@@ -369,29 +393,41 @@ test.describe('Node Interaction', () => {
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
})
test('Can group selected nodes', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
await comfyPage.select2Nodes()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.press('KeyG')
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-selected-nodes.png')
})
test(
'Can group selected nodes',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
await comfyPage.select2Nodes()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.press('KeyG')
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
}
)
test('Can fit group to contents', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-fit-to-contents.png')
})
test(
'Can fit group to contents',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-fit-to-contents.png'
)
}
)
test('Can pin/unpin nodes', async ({ comfyPage }) => {
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
await comfyPage.nextFrame()
@@ -401,20 +437,22 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
})
test('Can bypass/unbypass nodes with keyboard shortcut', async ({
comfyPage
}) => {
await comfyPage.select2Nodes()
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
})
test(
'Can bypass/unbypass nodes with keyboard shortcut',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
}
)
})
test.describe('Group Interaction', () => {
test.describe('Group Interaction', { tag: '@screenshot' }, () => {
test('Can double click group title to edit', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/single_group')
await comfyPage.canvas.dblclick({
@@ -430,7 +468,7 @@ test.describe('Group Interaction', () => {
})
})
test.describe('Canvas Interaction', () => {
test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
@@ -632,7 +670,7 @@ test.describe('Widget Interaction', () => {
})
})
test.describe('Load workflow', () => {
test.describe('Load workflow', { tag: '@screenshot' }, () => {
test('Can load workflow with string node id', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/string_node_id')
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
@@ -824,7 +862,7 @@ test.describe('Viewport settings', () => {
})
})
test.describe('Canvas Navigation', () => {
test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage
}) => {

View File

@@ -14,7 +14,7 @@ function listenForEvent(): Promise<Event> {
})
}
test.describe('Canvas Event', () => {
test.describe('Canvas Event', { tag: '@canvas' }, () => {
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const disconnectPromise = comfyPage.disconnectEdge()

View File

@@ -6,46 +6,50 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load Workflow in Media', () => {
const fileNames = [
'workflow.webp',
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.
// 'workflow.glb',
'workflow.mp4',
'workflow.mov',
'workflow.m4v',
'workflow.svg'
// TODO: Re-enable after fixing test asset to use core nodes only
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
// 'workflow.avif'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropFile(`workflowInMedia/${fileName}`)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
test.describe(
'Load Workflow in Media',
{ tag: ['@screenshot', '@workflow'] },
() => {
const fileNames = [
'workflow.webp',
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.
// 'workflow.glb',
'workflow.mp4',
'workflow.mov',
'workflow.m4v',
'workflow.svg'
// TODO: Re-enable after fixing test asset to use core nodes only
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
// 'workflow.avif'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropFile(`workflowInMedia/${fileName}`)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})
})
const urls = [
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
]
urls.forEach(async (url) => {
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`
)
const urls = [
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
]
urls.forEach(async (url) => {
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`
)
})
})
})
})
}
)

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('LOD Threshold', () => {
test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
test('Should switch to low quality mode at correct zoom threshold', async ({
comfyPage
}) => {
@@ -149,53 +149,55 @@ test.describe('LOD Threshold', () => {
expect(state.scale).toBeLessThan(0.2) // Very zoomed out
})
test('Should show visual difference between LOD on and off', async ({
comfyPage
}) => {
// Load a workflow with text-heavy nodes for clear visual difference
await comfyPage.loadWorkflow('default')
test(
'Should show visual difference between LOD on and off',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
// Load a workflow with text-heavy nodes for clear visual difference
await comfyPage.loadWorkflow('default')
// Set zoom level clearly below the threshold to ensure LOD activates
const targetZoom = 0.4 // Well below default threshold of ~0.571
// Set zoom level clearly below the threshold to ensure LOD activates
const targetZoom = 0.4 // Well below default threshold of ~0.571
// Zoom to target level
await comfyPage.page.evaluate((zoom) => {
window['app'].canvas.ds.scale = zoom
window['app'].canvas.setDirty(true, true)
}, targetZoom)
await comfyPage.nextFrame()
// Zoom to target level
await comfyPage.page.evaluate((zoom) => {
window['app'].canvas.ds.scale = zoom
window['app'].canvas.setDirty(true, true)
}, targetZoom)
await comfyPage.nextFrame()
// Take snapshot with LOD active (default 8px setting)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-low-quality.png'
)
// Take snapshot with LOD active (default 8px setting)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-low-quality.png'
)
const lowQualityState = await comfyPage.page.evaluate(() => {
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(lowQualityState.lowQuality).toBe(true)
const lowQualityState = await comfyPage.page.evaluate(() => {
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(lowQualityState.lowQuality).toBe(true)
// Disable LOD to see high quality at same zoom
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
await comfyPage.nextFrame()
// Disable LOD to see high quality at same zoom
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
await comfyPage.nextFrame()
// Take snapshot with LOD disabled (full quality at same zoom)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-high-quality.png'
)
// Take snapshot with LOD disabled (full quality at same zoom)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-high-quality.png'
)
const highQualityState = await comfyPage.page.evaluate(() => {
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(highQualityState.lowQuality).toBe(false)
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
})
const highQualityState = await comfyPage.page.evaluate(() => {
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(highQualityState.lowQuality).toBe(false)
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
}
)
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Menu', () => {
test.describe('Menu', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Minimap', () => {
test.describe('Minimap', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Minimap.Visible', true)

View File

@@ -1,35 +1,39 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { expect } from '@playwright/test'
test.describe('Mobile Baseline Snapshots', () => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test.describe(
'Mobile Baseline Snapshots',
{ tag: ['@mobile', '@screenshot'] },
() => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png',
{
mask: [
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
)
})
})
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png',
{
mask: [
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
)
})
}
)

View File

@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node Badge', () => {
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const LGraphBadge = window['LGraphBadge']
@@ -66,50 +66,60 @@ test.describe('Node Badge', () => {
})
})
test.describe('Node source badge', () => {
Object.values(NodeBadgeMode).forEach(async (mode) => {
test(`Shows node badges (${mode})`, async ({ comfyPage }) => {
// Execution error workflow has both custom node and core node.
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.setSetting('Comfy.NodeBadge.NodeSourceBadgeMode', mode)
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', mode)
await comfyPage.nextFrame()
await comfyPage.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(`node-badge-${mode}.png`)
test.describe(
'Node source badge',
{ tag: ['@screenshot', '@smoke', '@node'] },
() => {
Object.values(NodeBadgeMode).forEach(async (mode) => {
test(`Shows node badges (${mode})`, async ({ comfyPage }) => {
// Execution error workflow has both custom node and core node.
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.setSetting('Comfy.NodeBadge.NodeSourceBadgeMode', mode)
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', mode)
await comfyPage.nextFrame()
await comfyPage.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`node-badge-${mode}.png`
)
})
})
})
})
}
)
test.describe('Node badge color', () => {
test('Can show node badge with unknown color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'unknown')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-unknown-color-palette.png'
)
})
test.describe(
'Node badge color',
{ tag: ['@screenshot', '@smoke', '@node'] },
() => {
test('Can show node badge with unknown color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'unknown')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-unknown-color-palette.png'
)
})
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)
})
})
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)
})
}
)

View File

@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', () => {
test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
test('No shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('inputs/optional_input_no_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')

View File

@@ -23,7 +23,7 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
}
test.describe('Node Help', () => {
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')

View File

@@ -7,7 +7,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node search box', () => {
test.describe('Node search box', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
@@ -46,14 +46,14 @@ test.describe('Node search box', () => {
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add node', async ({ comfyPage }) => {
test('Can add node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test('Can auto link node', async ({ comfyPage }) => {
test('Can auto link node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
@@ -62,41 +62,47 @@ test.describe('Node search box', () => {
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
test('Can auto link batch moved node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/batch_move_links')
test(
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.loadWorkflow('links/batch_move_links')
const outputSlot1Pos = {
x: 304,
y: 127
const outputSlot1Pos = {
x: 304,
y: 127
}
const emptySpacePos = {
x: 5,
y: 5
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
await comfyPage.page.keyboard.up('Shift')
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
suggestionIndex: 0
})
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'
)
}
const emptySpacePos = {
x: 5,
y: 5
)
test(
'Link release connecting to node with no slots',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot(
'added-node-no-connection.png'
)
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
await comfyPage.page.keyboard.up('Shift')
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
suggestionIndex: 0
})
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'
)
})
test('Link release connecting to node with no slots', async ({
comfyPage
}) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot(
'added-node-no-connection.png'
)
})
)
test('Has correct aria-labels on search results', async ({ comfyPage }) => {
const node = 'Load Checkpoint'
@@ -251,40 +257,45 @@ test.describe('Node search box', () => {
})
})
test.describe('Release context menu', () => {
test.describe('Release context menu', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'link-release-context-menu.png'
)
})
test(
'Can trigger on link release',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'link-release-context-menu.png'
)
}
)
test('Can search and add node from context menu', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.clickContextMenuItem('Search')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
})
test(
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage, comfyMouse }) => {
await comfyPage.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.clickContextMenuItem('Search')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
}
)
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
comfyPage

View File

@@ -6,8 +6,8 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Note Node', () => {
test('Can load node nodes', async ({ comfyPage }) => {
test.describe('Note Node', { tag: '@node' }, () => {
test('Can load node nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/note_nodes')
await expect(comfyPage.canvas).toHaveScreenshot('note_nodes.png')
})

View File

@@ -7,7 +7,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Primitive Node', () => {
test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive/primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Record Audio Node', () => {
test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
test('should add a record audio node and take a screenshot', async ({
comfyPage
}) => {

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Remote COMBO Widget', () => {
test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const mockOptions = ['d', 'c', 'b', 'a']
const addRemoteWidgetNode = async (

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getMiddlePoint } from '../fixtures/utils/litegraphUtils'
test.describe('Reroute Node', () => {
test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
@@ -38,92 +38,96 @@ test.describe('Reroute Node', () => {
})
})
test.describe('LiteGraph Native Reroute Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
})
test.describe(
'LiteGraph Native Reroute Node',
{ tag: ['@screenshot', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
})
test('loads from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('reroute/native_reroute')
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
})
test('loads from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('reroute/native_reroute')
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
})
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
comfyPage
}) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]
const clipEncodeNode = (
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
comfyPage
}) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]
const clipEncodeNode = (
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
const slot1 = await loadCheckpointNode.getOutput(1)
const slot2 = await clipEncodeNode.getInput(0)
const middlePoint = getMiddlePoint(
await slot1.getPosition(),
await slot2.getPosition()
)
const slot1 = await loadCheckpointNode.getOutput(1)
const slot2 = await clipEncodeNode.getInput(0)
const middlePoint = getMiddlePoint(
await slot1.getPosition(),
await slot2.getPosition()
)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page.keyboard.up('Alt')
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page.keyboard.up('Alt')
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_alt_click.png'
)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_alt_click.png'
)
})
test('Can add reroute by clicking middle of link context menu', async ({
comfyPage
}) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]
const clipEncodeNode = (
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
test('Can add reroute by clicking middle of link context menu', async ({
comfyPage
}) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]
const clipEncodeNode = (
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
const slot1 = await loadCheckpointNode.getOutput(1)
const slot2 = await clipEncodeNode.getInput(0)
const middlePoint = getMiddlePoint(
await slot1.getPosition(),
await slot2.getPosition()
)
const slot1 = await loadCheckpointNode.getOutput(1)
const slot2 = await clipEncodeNode.getInput(0)
const middlePoint = getMiddlePoint(
await slot1.getPosition(),
await slot2.getPosition()
)
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Add Reroute' })
.click()
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Add Reroute' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_context_menu.png'
)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_context_menu.png'
)
})
test('Can delete link that is connected to two reroutes', async ({
comfyPage
}) => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
await comfyPage.loadWorkflow(
'reroute/single-native-reroute-default-workflow'
)
test('Can delete link that is connected to two reroutes', async ({
comfyPage
}) => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
await comfyPage.loadWorkflow(
'reroute/single-native-reroute-default-workflow'
)
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
})
})
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
})
}
)

View File

@@ -7,43 +7,49 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Canvas Right Click Menu', () => {
test('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
test.describe(
'Canvas Right Click Menu',
{ tag: ['@screenshot', '@ui'] },
() => {
test('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
test('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'add-group-group-added.png'
)
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'
)
})
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'
)
})
}
)
test.describe('Node Right Click Menu', () => {
test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')

View File

@@ -10,7 +10,7 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
test.describe('Selection Toolbox', () => {
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})

View File

@@ -7,178 +7,190 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Selection Toolbox - More Options Submenus', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await comfyPage.page.click('[data-testid="more-options-button"]')
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click({ force: true })
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialShape = await nodeRef.getProperty<number>('shape')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
timeout: 5000
})
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
const newShape = await nodeRef.getProperty<number>('shape')
expect(newShape).not.toBe(initialShape)
expect(newShape).toBe(1)
})
test('changes node color via Color submenu swatch', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialColor = await nodeRef.getProperty<string | undefined>('color')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
await blueSwatch.first().click()
await comfyPage.nextFrame()
const newColor = await nodeRef.getProperty<string | undefined>('color')
expect(newColor).toBe('#223')
if (initialColor) {
expect(newColor).not.toBe(initialColor)
}
})
test('renames a node using Rename action', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
await expect(input).toBeVisible()
await input.fill('RenamedNode')
await input.press('Enter')
await comfyPage.nextFrame()
const newTitle = await nodeRef.getProperty<string>('title')
expect(newTitle).toBe('RenamedNode')
})
test('closes More Options menu when clicking outside', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible({ timeout: 5000 })
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
test.describe(
'Selection Toolbox - More Options Submenus',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await comfyPage.page.click('[data-testid="more-options-button"]')
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click({ force: true })
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialShape = await nodeRef.getProperty<number>('shape')
test('closes More Options menu when clicking the button again (toggle)', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible({
timeout: 5000
})
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const btn = document.querySelector('[data-testid="more-options-button"]')
if (btn) {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
detail: 1
})
btn.dispatchEvent(event)
const newShape = await nodeRef.getProperty<number>('shape')
expect(newShape).not.toBe(initialShape)
expect(newShape).toBe(1)
})
test('changes node color via Color submenu swatch', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialColor = await nodeRef.getProperty<string | undefined>(
'color'
)
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
await blueSwatch.first().click()
await comfyPage.nextFrame()
const newColor = await nodeRef.getProperty<string | undefined>('color')
expect(newColor).toBe('#223')
if (initialColor) {
expect(newColor).not.toBe(initialColor)
}
})
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
})
test('renames a node using Rename action', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
await expect(input).toBeVisible()
await input.fill('RenamedNode')
await input.press('Enter')
await comfyPage.nextFrame()
const newTitle = await nodeRef.getProperty<string>('title')
expect(newTitle).toBe('RenamedNode')
})
test('closes More Options menu when clicking outside', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible({ timeout: 5000 })
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
await comfyPage.nextFrame()
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
test('closes More Options menu when clicking the button again (toggle)', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page.evaluate(() => {
const btn = document.querySelector(
'[data-testid="more-options-button"]'
)
if (btn) {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
detail: 1
})
btn.dispatchEvent(event)
}
})
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
}
)

View File

@@ -12,7 +12,7 @@ const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
test.describe('Subgraph Slot Rename Dialog', () => {
test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})

View File

@@ -16,7 +16,7 @@ const SELECTORS = {
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', () => {
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})

View File

@@ -13,7 +13,7 @@ async function checkTemplateFileExists(
return response.ok()
}
test.describe('Templates', () => {
test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', false)
@@ -207,109 +207,114 @@ test.describe('Templates', () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test('template cards descriptions adjust height dynamically', async ({
comfyPage
}) => {
// Setup test by intercepting templates response to inject cards with varying description lengths
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
// Setup test by intercepting templates response to inject cards with varying description lengths
await comfyPage.page.route(
'**/templates/index.json',
async (route, _) => {
const response = [
{
name: 'short-description',
title: 'Short Description',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'This is a short description.'
},
{
name: 'medium-description',
title: 'Medium Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a medium length description that should take up two lines on most displays.'
},
{
name: 'long-description',
title: 'Long Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'short-description',
title: 'Short Description',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'This is a short description.'
},
{
name: 'medium-description',
title: 'Medium Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a medium length description that should take up two lines on most displays.'
},
{
name: 'long-description',
title: 'Long Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
)
// Mock the thumbnail images to avoid 404s
await comfyPage.page.route('**/templates/**.webp', async (route) => {
const headers = {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers
})
})
})
// Mock the thumbnail images to avoid 404s
await comfyPage.page.route('**/templates/**.webp', async (route) => {
const headers = {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers
})
})
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for cards to load
await expect(
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible({ timeout: 5000 })
// Wait for cards to load
await expect(
comfyPage.page.locator(
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible({ timeout: 5000 })
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'
)
})
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'
)
}
)
})

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Settings Search functionality', () => {
test.describe('Settings Search functionality', { tag: '@settings' }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Register test settings to verify hidden/deprecated filtering
await comfyPage.page.evaluate(() => {

View File

@@ -5,7 +5,7 @@ import { userSelectPageFixture as test } from '../fixtures/UserSelectPage'
/**
* Expects ComfyUI backend to be launched with `--multi-user` flag.
*/
test.describe('User Select View', () => {
test.describe('User Select View', { tag: '@settings' }, () => {
test.beforeEach(async ({ userSelectPage, page }) => {
await page.goto(userSelectPage.url)
await page.evaluate(() => {

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {
test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Viewport', () => {
test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
test('Fits view to nodes when saved viewport position is offscreen', async ({
comfyPage
}) => {

View File

@@ -5,7 +5,7 @@ import {
const CREATE_GROUP_HOTKEY = 'Control+g'
test.describe('Vue Node Groups', () => {
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('Comfy.Minimap.ShowGroups', true)

View File

@@ -9,10 +9,14 @@ test.describe('Vue Nodes Canvas Pan', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-paned-with-touch.png'
)
})
test(
'@mobile Can pan with touch',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-paned-with-touch.png'
)
}
)
})

View File

@@ -10,25 +10,30 @@ test.describe('Vue Nodes Zoom', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should not capture drag while zooming with ctrl+shift+drag', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBoundingBox = await checkpointNode.boundingBox()
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
test(
'should not capture drag while zooming with ctrl+shift+drag',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBoundingBox = await checkpointNode.boundingBox()
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
// the node. The node should not capture the drag while drag-zooming.
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
{ x: 200, y: 300 },
{ x: nodeMidpointX, y: nodeMidpointY }
)
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
// the node. The node should not capture the drag while drag-zooming.
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
{ x: 200, y: 300 },
{ x: nodeMidpointX, y: nodeMidpointY }
)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
})
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-ctrl-shift.png'
)
}
)
})

View File

@@ -98,7 +98,7 @@ async function connectSlots(
await nextFrame()
}
test.describe('Vue Node Link Interaction', () => {
test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)

View File

@@ -5,7 +5,7 @@ import {
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../../../helpers/fitToView'
test.describe('Vue Node Bring to Front', () => {
test.describe('Vue Node Bring to Front', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)

View File

@@ -1,8 +1,8 @@
import {
type ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import type { Position } from '../../../../fixtures/types'
test.describe('Vue Node Moving', () => {
@@ -29,39 +29,47 @@ test.describe('Vue Node Moving', () => {
expect(diffY).toBeGreaterThan(0)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test(
'should allow moving nodes by dragging',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const loadCheckpointHeaderPos =
await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
})
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
}
)
test('@mobile should allow moving nodes by dragging on touch devices', async ({
comfyPage
}) => {
// Disable minimap (gets in way of the node on small screens)
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
// Disable minimap (gets in way of the node on small screens)
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.panWithTouch(
{
x: 64,
y: 64
},
loadCheckpointHeaderPos
)
const loadCheckpointHeaderPos =
await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.panWithTouch(
{
x: 64,
y: 64
},
loadCheckpointHeaderPos
)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-moved-node-touch.png'
)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-moved-node-touch.png'
)
}
)
})

View File

@@ -15,22 +15,25 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
test(
'should allow toggling bypass on a selected node with hotkey',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
})
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
}
)
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
comfyPage

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Node Custom Colors', () => {
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)

View File

@@ -12,19 +12,24 @@ test.describe('Vue Node Mute', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling mute on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
test(
'should allow toggling mute on a selected node with hotkey',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-muted-state.png'
)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
})
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
}
)
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
comfyPage

View File

@@ -9,13 +9,17 @@ test.describe('Vue Upload Widgets', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
test(
'should hide canvas-only upload buttons',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
}
)
})

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Combo text widget', () => {
test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Truncates text when resized', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -79,7 +79,7 @@ test.describe('Combo text widget', () => {
})
})
test.describe('Boolean widget', () => {
test.describe('Boolean widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can toggle', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/boolean_widget')
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget.png')
@@ -92,7 +92,7 @@ test.describe('Boolean widget', () => {
})
})
test.describe('Slider widget', () => {
test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('inputs/simple_slider')
const node = (await comfyPage.getFirstNodeRef())!
@@ -113,7 +113,7 @@ test.describe('Slider widget', () => {
})
})
test.describe('Number widget', () => {
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/seed_widget')
@@ -134,22 +134,28 @@ test.describe('Number widget', () => {
})
})
test.describe('Dynamic widget manipulation', () => {
test('Auto expand node when widget is added dynamically', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
test.describe(
'Dynamic widget manipulation',
{ tag: ['@screenshot', '@widget'] },
() => {
test('Auto expand node when widget is added dynamically', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.page.evaluate(() => {
window['graph'].nodes[0].addWidget('number', 'new_widget', 10)
window['graph'].setDirtyCanvas(true, true)
await comfyPage.page.evaluate(() => {
window['graph'].nodes[0].addWidget('number', 'new_widget', 10)
window['graph'].setDirtyCanvas(true, true)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'ksampler_widget_added.png'
)
})
}
)
await expect(comfyPage.canvas).toHaveScreenshot('ksampler_widget_added.png')
})
})
test.describe('Image widget', () => {
test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
@@ -236,99 +242,103 @@ test.describe('Image widget', () => {
})
})
test.describe('Animated image widget', () => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3718
test.skip('Shows preview of uploaded animated image', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
test.describe(
'Animated image widget',
{ tag: ['@screenshot', '@widget'] },
() => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3718
test.skip('Shows preview of uploaded animated image', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y },
waitForUpload: true
})
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y },
waitForUpload: true
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
})
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node
const loadNodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = loadNodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Get position of the load animated webp node
const loadNodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = loadNodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
await comfyPage.nextFrame()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
// Get the SaveAnimatedWEBP node
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
const saveAnimatedWebpNode = saveNodes[0]
if (!saveAnimatedWebpNode)
throw new Error('SaveAnimatedWEBP node not found')
// Simulate the graph executing
await comfyPage.page.evaluate(
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
app.canvas.setDirty(true)
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2)
})
await comfyPage.nextFrame()
}
)
// Get the SaveAnimatedWEBP node
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
const saveAnimatedWebpNode = saveNodes[0]
if (!saveAnimatedWebpNode)
throw new Error('SaveAnimatedWEBP node not found')
// Simulate the graph executing
await comfyPage.page.evaluate(
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
app.canvas.setDirty(true)
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2)
})
})
test.describe('Load audio widget', () => {
test.describe('Load audio widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')
// Wait for the audio widget to be rendered in the DOM
@@ -338,7 +348,7 @@ test.describe('Load audio widget', () => {
})
})
test.describe('Unserialized widgets', () => {
test.describe('Unserialized widgets', { tag: '@widget' }, () => {
test('Unserialized widgets values do not mark graph as modified', async ({
comfyPage
}) => {

View File

@@ -1,8 +1,9 @@
import { expect } from '@playwright/test'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.describe('Workflow Tab Thumbnails', () => {
test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.2",
"version": "1.39.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -192,7 +192,7 @@
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
"vite": "^8.0.0-beta.11"
}
}
}

567
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,8 +62,8 @@ catalog:
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsonata: ^2.1.0
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: 8.0.0-beta.11
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -46,6 +46,8 @@ 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

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

View File

@@ -150,13 +150,11 @@ import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useKeybindingService } from '@/services/keybindingService'
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 {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
@@ -265,18 +263,15 @@ function cancelEdit() {
}
async function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
await keybindingService.persistUserKeybindings()
}
}
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {

View File

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

View File

@@ -70,7 +70,7 @@ 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 '@/stores/keybindingStore'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -90,6 +91,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
@@ -133,6 +133,7 @@ import {
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -154,6 +155,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)

View File

@@ -276,7 +276,7 @@ describe('useSelectedLiteGraphItems', () => {
expect(selectedNodes).toContainEqual(subNode2)
})
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
it('toggleSelectedNodesMode should not apply 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,9 +294,8 @@ describe('useSelectedLiteGraphItems', () => {
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
// 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
// Subgraph children do not change state
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS) // was ALWAYS, stays ALWAYS
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
})
@@ -317,9 +316,9 @@ describe('useSelectedLiteGraphItems', () => {
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
// All children should also get ALWAYS (unified with parent's new state)
// All children should be unchanged
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.BYPASS)
})
})

View File

@@ -2,10 +2,7 @@ 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,
traverseNodesDepthFirst
} from '@/utils/graphTraversalUtil'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -97,16 +94,10 @@ export function useSelectedLiteGraphItems() {
}
/**
* Toggle the execution mode of all selected nodes with unified subgraph behavior.
* Toggle the execution mode of all selected nodes
*
* 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
* - If any nodes are not already the specified node mode → all are set to specified mode
* - Otherwise → set all nodes to ALWAYS
*
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
@@ -124,27 +115,8 @@ export function useSelectedLiteGraphItems() {
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
// 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
for (const selectedNode of selectedNodeArray)
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

@@ -862,7 +862,7 @@ export function useCoreCommands(): ComfyCommand[] {
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank')
window.open(supportUrl, '_blank', 'noopener,noreferrer')
}
},
{

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, 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 = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -557,7 +557,7 @@ function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
if (!autogrowGroup) return
if (app.configuringGraph && input.widget)
ensureWidgetForInput(node, input)
if (iscon && linf) {
if (iscon) {
if (swappingConnection || !linf) return
autogrowInputConnected(slot, this)
} else {

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 {
}
}
#convertedToProcess: (() => void)[] = []
private _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

@@ -13,7 +13,9 @@ import './imageCompare'
import './imageCrop'
import './load3d'
import './maskeditor'
import './nodeTemplates'
if (!isCloud) {
await import('./nodeTemplates')
}
import './noteNode'
import './previewAny'
import './rerouteNode'

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
}
#onFirstConnection(recreating?: boolean) {
private _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 {
)
}
#createWidget(
private _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]
}
#mergeWidgetConfig() {
private _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)
}
}
#isValidConnection(input: INodeInputSlot, forceUpdate?: boolean) {
private _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 {
)
}
#removeWidgets() {
private _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
}
static #maxClickDrift = 6
private static _maxClickDrift = 6
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
static #maxClickDrift2 = this.#maxClickDrift ** 2
private 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
}
}
#finally?: () => unknown
private _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
}
#completeClick(e: CanvasPointerEvent): void {
private _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`
*/
#hasSamePosition(
private _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
*/
#isDoubleClick(): boolean {
private _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)
)
}
#setDragStarted(eMove?: CanvasPointerEvent): void {
private _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
*/
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
private _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.
*/
#isWithinCooldown(timeSinceLastEvent: number): boolean {
private _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.
*/
#updateDeviceMode(event: WheelEvent, now: number): void {
if (this.#isTrackpadPattern(event)) {
private _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.
*/
#clearLinuxBuffer(): void {
private _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
*/
#isTrackpadPattern(event: WheelEvent): boolean {
private _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
*/
#isMousePattern(event: WheelEvent): boolean {
private _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
*/
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
private _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
*/
#bufferLinuxEvent(event: WheelEvent, now: number): void {
private _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
*/
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
private _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`.
*/
#stateHasChanged(): boolean {
private _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

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import { test } from './__fixtures__/testExtensions'
@@ -206,6 +210,70 @@ describe('Graph Clearing and Callbacks', () => {
})
})
describe('Subgraph Definition Garbage Collection', () => {
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const innerNodes: LGraphNode[] = []
for (let i = 0; i < nodeCount; i++) {
const node = new LGraphNode(`Inner Node ${i}`)
subgraph.add(node)
innerNodes.push(node)
}
return { subgraph, innerNodes }
}
it('removing SubgraphNode fires onRemoved for inner nodes', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
const removedNodeIds = new Set<string>()
for (const node of innerNodes) {
node.onRemoved = () => removedNodeIds.add(String(node.id))
}
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
expect(subgraph.nodes.length).toBe(2)
rootGraph.remove(subgraphNode)
expect(removedNodeIds.size).toBe(2)
})
it('removing SubgraphNode fires onNodeRemoved callback', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 2)
const graphRemovedNodeIds = new Set<string>()
subgraph.onNodeRemoved = (node) => graphRemovedNodeIds.add(String(node.id))
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(graphRemovedNodeIds.size).toBe(2)
})
it('subgraph definition is removed when SubgraphNode is removed', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
const subgraphId = subgraph.id
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
rootGraph.remove(subgraphNode)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
})
})
describe('Legacy LGraph Compatibility Layer', () => {
test('can be extended via prototype', ({ expect, minimalGraph }) => {
// @ts-expect-error Should always be an error.

View File

@@ -244,7 +244,7 @@ export class LGraph
}
/** Internal only. Not required for serialisation; calculated on deserialise. */
#lastFloatingLinkId: number = 0
private _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 = []
@@ -992,6 +992,16 @@ export class LGraph
}
}
// Subgraph cleanup (use local const to avoid type narrowing affecting node.graph assignment)
const subgraphNode = node.isSubgraphNode() ? node : null
if (subgraphNode) {
for (const innerNode of subgraphNode.subgraph.nodes) {
innerNode.onRemoved?.()
subgraphNode.subgraph.onNodeRemoved?.(innerNode)
}
this.rootGraph.subgraphs.delete(subgraphNode.subgraph.id)
}
// callback
node.onRemoved?.()
@@ -1294,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)
@@ -2165,8 +2175,16 @@ 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`. */
#getDragAndScale(): DragAndScaleState | undefined {
private _getDragAndScale(): DragAndScaleState | undefined {
const ds = this.list_of_graphcanvas?.at(0)?.ds
if (ds) return { scale: ds.scale, offset: ds.offset }
}
@@ -2206,7 +2224,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> = {
@@ -2396,8 +2414,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
}
}
@@ -2448,13 +2466,13 @@ export class LGraph
}
}
#canvas?: LGraphCanvas
private _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) {
@@ -2523,9 +2541,9 @@ export class Subgraph
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
readonly widgets: ExposedWidget[] = []
#rootGraph: LGraph
private _rootGraph: LGraph
override get rootGraph(): LGraph {
return this.#rootGraph
return this._rootGraph
}
constructor(rootGraph: LGraph, data: ExportedSubgraph) {
@@ -2533,11 +2551,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(
@@ -2549,7 +2567,7 @@ export class Subgraph
if (outputNode.containsPoint([x, y])) return outputNode
}
#configureSubgraph(
private _configureSubgraph(
data:
| (ISerialisedGraph & ExportedSubgraph)
| (SerialisableGraph & ExportedSubgraph)
@@ -2593,7 +2611,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
}
#subgraph?: Subgraph
private _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 }))
}
#updateCursorStyle() {
private _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}`
}
#maximumFrameGap = 0
private _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.
*/
#visible_node_ids: Set<NodeId> = new Set()
private _visible_node_ids: Set<NodeId> = new Set()
node_over?: LGraphNode
node_capturing_input?: LGraphNode | null
highlighted_links: Dictionary<boolean> = {}
#visibleReroutes: Set<Reroute> = new Set()
private _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. */
#snapToGrid?: number
private _snapToGrid?: number
/** Set on keydown, keyup. @todo */
#shiftDown: boolean = false
private _shiftDown: boolean = false
/** Link rendering adapter for litegraph-to-canvas integration */
linkRenderer: LitegraphLinkAdapter | null = null
@@ -735,7 +735,11 @@ 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. */
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
private _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
@@ -810,7 +814,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.
@@ -1808,7 +1812,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.dragging_canvas = false
this.#dirty()
this._dirty()
this.dirty_area = null
this.node_in_panel = null
@@ -1836,7 +1840,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 {
@@ -1873,7 +1877,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
*/
#validateCanvas(
private _validateCanvas(
canvas: string | HTMLCanvasElement
): HTMLCanvasElement & { data?: LGraphCanvas } {
if (typeof canvas === 'string') {
@@ -1892,7 +1896,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()
@@ -1905,7 +1909,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// TODO: classList.add
element.className += ' lgraphcanvas'
element.data = this
Object.defineProperty(element, 'data', {
value: this,
writable: true,
configurable: true,
enumerable: false
})
// Background canvas: To render objects behind nodes (background, links, groups)
this.bgcanvas = document.createElement('canvas')
@@ -2026,12 +2035,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/** Marks the entire canvas as dirty. */
#dirty(): void {
private _dirty(): void {
this.dirty_canvas = true
this.dirty_bgcanvas = true
}
#linkConnectorDrop(): void {
private _linkConnectorDrop(): void {
const { graph, linkConnector, pointer } = this
if (!graph) throw new NullGraphError()
@@ -2070,10 +2079,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
@@ -2161,7 +2170,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
@@ -2208,9 +2217,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 &&
@@ -2246,7 +2255,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this.#visibleReroutes
this._visibleReroutes
)
}
if (reroute) {
@@ -2302,18 +2311,24 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param y The y coordinate in canvas space
* @returns The positionable item or undefined
*/
#getPositionableOnPos(x: number, y: number): Positionable | undefined {
private _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)
}
#processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
private _processPrimaryButton(
e: CanvasPointerEvent,
node: LGraphNode | undefined
) {
const { pointer, graph, linkConnector, subgraph } = this
if (!graph) throw new NullGraphError()
@@ -2329,7 +2344,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
!e.altKey &&
LiteGraph.leftMouseClickBehavior === 'panning'
) {
this.#setupNodeSelectionDrag(e, pointer, node)
this._setupNodeSelectionDrag(e, pointer, node)
return
}
@@ -2360,16 +2375,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) {
@@ -2387,8 +2402,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
}
}
@@ -2404,7 +2419,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
@@ -2413,19 +2428,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()
@@ -2470,14 +2485,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 (
@@ -2519,7 +2534,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
@@ -2542,9 +2557,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)
}
}
@@ -2582,12 +2597,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)
}
}
}
#setupNodeSelectionDrag(
private _setupNodeSelectionDrag(
e: CanvasPointerEvent,
pointer: CanvasPointer,
node?: LGraphNode | undefined
@@ -2602,7 +2617,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)
@@ -2617,7 +2632,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)
@@ -2629,7 +2644,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
*/
#processNodeClick(
private _processNodeClick(
e: CanvasPointerEvent,
ctrlOrMeta: boolean,
node: LGraphNode
@@ -2685,13 +2700,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) {
@@ -2744,7 +2759,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
linkConnector.dragNewFromInput(graph, node, input)
}
this.#linkConnectorDrop()
this._linkConnectorDrop()
this.dirty_bgcanvas = true
return
@@ -2874,7 +2889,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')
@@ -2882,7 +2897,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')) {
@@ -2893,7 +2908,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
snapPoint(newBounds.size, this.#snapToGrid)
snapPoint(newBounds.size, this._snapToGrid)
}
// Apply snapping to size changes
@@ -2918,11 +2933,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 = () => {
@@ -2938,8 +2953,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
@@ -3008,7 +3023,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param e The pointerdown event
* @param node The node to process a click event for
*/
#processMiddleButton(e: CanvasPointerEvent, node: LGraphNode | undefined) {
private _processMiddleButton(
e: CanvasPointerEvent,
node: LGraphNode | undefined
) {
const { pointer } = this
if (
@@ -3105,14 +3123,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
#processDragZoom(e: PointerEvent): void {
private _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()
@@ -3126,10 +3144,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.graph.change()
}
#finishDragZoom(): void {
const start = this.#dragZoomStart
private _finishDragZoom(): void {
const start = this._dragZoomStart
if (!start) return
this.#dragZoomStart = null
this._dragZoomStart = null
this.read_only = start.readOnly
}
@@ -3141,9 +3159,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.dragZoomEnabled &&
e.ctrlKey &&
e.shiftKey &&
this.#dragZoomStart
this._dragZoomStart
) {
this.#processDragZoom(e)
this._processDragZoom(e)
return
}
@@ -3210,7 +3228,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
@@ -3258,7 +3276,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
}
@@ -3382,10 +3400,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
@@ -3435,7 +3453,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
this.#dirty()
this._dirty()
}
}
@@ -3449,14 +3467,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.
*/
#updateReroutes(underPointer: CanvasItem): CanvasItem {
private _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
@@ -3464,7 +3482,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
@@ -3489,7 +3507,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}
*/
#startDraggingItems(
private _startDraggingItems(
item: Positionable,
pointer: CanvasPointer,
sticky = false
@@ -3511,7 +3529,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
*/
#processDraggedItems(e: CanvasPointerEvent): void {
private _processDraggedItems(e: CanvasPointerEvent): void {
const { graph } = this
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
graph?.snapToGrid(this.selectedItems)
@@ -3533,7 +3551,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const { graph, pointer } = this
if (!graph) return
this.#finishDragZoom()
this._finishDragZoom()
LGraphCanvas.active_canvas = this
@@ -3677,7 +3695,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
#noItemsSelected(): void {
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
@@ -3688,7 +3706,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
@@ -3735,7 +3753,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
}
@@ -4097,7 +4115,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param dragRect The drag rectangle to normalize (modified in place)
* @returns The normalized rectangle
*/
#normalizeDragRect(dragRect: Rect): Rect {
private _normalizeDragRect(dragRect: Rect): Rect {
const w = Math.abs(dragRect[2])
const h = Math.abs(dragRect[3])
if (dragRect[2] < 0) dragRect[0] -= w
@@ -4112,7 +4130,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param rect The rectangle to check against
* @returns Set of positionable items that overlap with the rectangle
*/
#getItemsInRect(rect: Rect): Set<Positionable> {
private _getItemsInRect(rect: Rect): Set<Positionable> {
const { graph, subgraph } = this
if (!graph) throw new NullGraphError()
@@ -4166,9 +4184,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) {
@@ -4215,16 +4233,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @param e The pointer up event
* @param dragRect The drag rectangle
*/
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
private _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) {
@@ -4588,7 +4606,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
*/
setZoom(value: number, zooming_center: Point) {
this.ds.changeScale(value, zooming_center)
this.#dirty()
this._dirty()
}
/**
@@ -4671,7 +4689,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)
}
/**
@@ -4692,7 +4710,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)
)
@@ -4746,8 +4764,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
@@ -4789,7 +4807,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) {
@@ -4829,7 +4847,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) {
@@ -4883,7 +4901,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
@@ -4909,7 +4927,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`. */
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
private _getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
// Skip hit detection if center markers are disabled
if (this.linkMarkerShape === LinkMarkerShape.None) {
return undefined
@@ -4928,7 +4946,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
/** Get the target snap / highlight point in graph space */
#getHighlightPosition(): Readonly<Point> {
private _getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy
? (this.linkConnector.state.snapLinksPos ??
this._highlight_pos ??
@@ -4941,7 +4959,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* Partial border over target node and a highlight over the slot itself.
* @param ctx Canvas 2D context
*/
#renderSnapHighlight(
private _renderSnapHighlight(
ctx: CanvasRenderingContext2D,
highlightPos: Readonly<Point>
): void {
@@ -5592,7 +5610,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
@@ -5672,7 +5690,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const output = start_node.outputs[outputId]
if (!output) continue
this.#renderAllLinkSegments(
this._renderAllLinkSegments(
ctx,
link,
startPos,
@@ -5701,7 +5719,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,
@@ -5728,7 +5746,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,
@@ -5742,10 +5760,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
@@ -5754,7 +5772,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
rerouteSet.add(reroute)
if (
this.#snapToGrid &&
this._snapToGrid &&
this.isDragging &&
this.selectedItems.has(reroute)
) {
@@ -5776,7 +5794,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
: this.editor_alpha
}
#renderFloatingLinks(
private _renderFloatingLinks(
ctx: CanvasRenderingContext2D,
graph: LGraph,
visibleReroutes: Reroute[],
@@ -5805,7 +5823,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,
@@ -5827,7 +5845,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,
@@ -5843,7 +5861,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = globalAlpha
}
#renderAllLinkSegments(
private _renderAllLinkSegments(
ctx: CanvasRenderingContext2D,
link: LLink,
startPos: Point,
@@ -6146,7 +6164,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) {
@@ -6513,7 +6531,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
},
optPass || {}
)
const dirty = () => this.#dirty()
const dirty = () => this._dirty()
const that = this
const { graph } = this
@@ -7522,7 +7540,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 (
@@ -8356,7 +8374,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this.#visibleReroutes
this._visibleReroutes
)
}
if (reroute) {
@@ -8646,4 +8664,17 @@ 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[] = []
#concreteInputs: NodeInputSlot[] = []
#concreteOutputs: NodeOutputSlot[] = []
private _concreteInputs: NodeInputSlot[] = []
private _concreteOutputs: NodeOutputSlot[] = []
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
@@ -438,24 +438,24 @@ export class LGraphNode
}
/** @inheritdoc {@link renderArea} */
#renderArea = new Rectangle()
private _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} */
#boundingRect: Rectangle = new Rectangle()
private _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,7 +753,9 @@ export class LGraphNode
onPropertyChange?(this: LGraphNode): void
updateOutputData?(this: LGraphNode, origin_slot: number): void
#getErrorStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
private _getErrorStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.has_errors) {
return {
padding: 12,
@@ -763,7 +765,9 @@ export class LGraphNode
}
}
#getSelectedStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
private _getSelectedStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.selected) {
return {
padding: this.has_errors ? 20 : undefined
@@ -778,8 +782,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)
@@ -2067,11 +2071,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
@@ -2293,7 +2297,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)
}
/**
@@ -2308,14 +2312,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
*/
#findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
private _findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
options?: FindFreeSlotOptions
): TSlot | number {
@@ -2357,7 +2361,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this.#findSlotByType(
return this._findSlotByType(
this.inputs,
type,
returnObj,
@@ -2387,7 +2391,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this.#findSlotByType(
return this._findSlotByType(
this.outputs,
type,
returnObj,
@@ -2433,14 +2437,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,
@@ -2461,7 +2465,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
*/
#findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
private _findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
type: ISlotType,
returnObj?: boolean,
@@ -3310,8 +3314,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] =
@@ -3324,7 +3328,7 @@ export class LGraphNode
/**
* @internal The inputs that are not positioned with absolute coordinates.
*/
get #defaultVerticalInputs() {
private get _defaultVerticalInputs() {
return this.inputs.filter(
(slot) => !slot.pos && !(this.widgets?.length && isWidgetInputSlot(slot))
)
@@ -3333,7 +3337,7 @@ export class LGraphNode
/**
* @internal The outputs that are not positioned with absolute coordinates.
*/
get #defaultVerticalOutputs() {
private get _defaultVerticalOutputs() {
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
@@ -3341,7 +3345,7 @@ export class LGraphNode
* Get the context needed for slot position calculations
* @internal
*/
#getSlotPositionContext(): SlotPositionContext {
private _getSlotPositionContext(): SlotPositionContext {
return {
nodeX: this.pos[0],
nodeY: this.pos[1],
@@ -3373,7 +3377,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)
}
/**
@@ -3916,13 +3920,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
@@ -3934,7 +3938,7 @@ export class LGraphNode
return [...this.inputs, ...this.outputs]
}
#measureSlot(
private _measureSlot(
slot: NodeInputSlot | NodeOutputSlot,
slotIndex: number,
isInput: boolean
@@ -3951,27 +3955,27 @@ export class LGraphNode
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
#measureSlots(): ReadOnlyRect | null {
private _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
}
#getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
private _getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
const isInput = isINodeInputSlot(slot)
const mouseOverId = this.mouseOver?.[isInput ? 'inputId' : 'outputId'] ?? -1
if (mouseOverId === -1) {
@@ -3980,11 +3984,11 @@ export class LGraphNode
return isInput ? this.inputs[mouseOverId] : this.outputs[mouseOverId]
}
#isMouseOverSlot(slot: INodeSlot): boolean {
return this.#getMouseOverSlot(slot) === slot
private _isMouseOverSlot(slot: INodeSlot): boolean {
return this._getMouseOverSlot(slot) === slot
}
#isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
private _isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
if (!widget) return false
return this.mouseOver?.overWidget === widget
}
@@ -4016,9 +4020,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
@@ -4033,7 +4037,7 @@ export class LGraphNode
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
@@ -4054,7 +4058,7 @@ export class LGraphNode
* - {@link IBaseWidget.y}
* @param widgetStartY The y-coordinate of the first widget
*/
#arrangeWidgets(widgetStartY: number): void {
private _arrangeWidgets(widgetStartY: number): void {
if (!this.widgets || !this.widgets.length) return
const bodyHeight = this.bodyHeight
@@ -4132,7 +4136,7 @@ export class LGraphNode
/**
* Arranges the layout of the node's widget input slots.
*/
#arrangeWidgetInputSlots(): void {
private _arrangeWidgetInputSlots(): void {
if (!this.widgets) return
const slotByWidgetName = new Map<
@@ -4154,10 +4158,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
@@ -4165,7 +4169,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)
}
}
}
@@ -4178,10 +4182,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)
)
}
@@ -4190,12 +4194,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 */
#instrumentedPaths = new Set<string>()
private _instrumentedPaths = new Set<string>()
constructor(node: LGraphNode) {
this.node = node
this.#setupInstrumentation()
this._setupInstrumentation()
}
/**
* Sets up property instrumentation for all tracked properties
*/
#setupInstrumentation(): void {
private _setupInstrumentation(): void {
for (const path of DEFAULT_TRACKED_PROPERTIES) {
this.#instrumentProperty(path)
this._instrumentProperty(path)
}
}
#resolveTargetObject(parts: string[]): {
private _resolveTargetObject(parts: string[]): {
targetObject: Record<string, unknown>
propertyName: string
} {
@@ -73,14 +73,14 @@ export class LGraphNodeProperties {
/**
* Instruments a single property to track changes
*/
#instrumentProperty(path: string): void {
private _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
*/
#createInstrumentedDescriptor(
private _createInstrumentedDescriptor(
propertyPath: string,
initialValue: unknown
): PropertyDescriptor {
return this.#createInstrumentedDescriptorTyped(propertyPath, initialValue)
return this._createInstrumentedDescriptorTyped(propertyPath, initialValue)
}
#createInstrumentedDescriptorTyped<TValue>(
private _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
*/
#emitPropertyChange(
private _emitPropertyChange(
propertyPath: string,
oldValue: unknown,
newValue: unknown
): void {
this.#emitPropertyChangeTyped(propertyPath, oldValue, newValue)
this._emitPropertyChangeTyped(propertyPath, oldValue, newValue)
}
#emitPropertyChangeTyped<TValue>(
private _emitPropertyChangeTyped<TValue>(
propertyPath: string,
oldValue: TValue,
newValue: TValue
@@ -183,7 +183,7 @@ export class LGraphNodeProperties {
/**
* Ensures parent objects exist for nested properties
*/
#ensureNestedPath(path: string): void {
private _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
#color?: CanvasColour | null
private _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,301 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Rectangle [
{
"config": {},
"definitions": undefined,
"extra": {
"reroutes": undefined,
},
"floatingLinks": undefined,
"groups": [
{
"bounding": [
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",
},
],
"_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 {},
"last_link_id": 0,
"last_node_id": 1,
"links": [],
"nodes": [
{
"id": 1,
"mode": 0,
"pos": [
10,
10,
],
},
],
"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 {
}
}
#onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
private _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)
}
#onPointerUp = this.onPointerUp.bind(this)
private _onPointerUp = this.onPointerUp.bind(this)
onPointerUp(): void {
this.mouse0Down = false
this.mouse1Down = false
this.mouse2Down = false
}
#onKeyDownOrUp = this.onKeyDownOrUp.bind(this)
private _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
readonly #setConnectingLinks: (value: ConnectingLink[]) => void
private 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)
}
}
#dropOnInput(node: LGraphNode, input: INodeInputSlot): void {
private _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 {
}
}
#dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void {
private _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. */
#setLegacyLinks(fromSlotIsInput: boolean): void {
private _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 {
#width: number = 0
#height: number = 0
#desiredWidth: number = 0
#desiredHeight: number = 0
private _width: number = 0
private _height: number = 0
private _desiredWidth: number = 0
private _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 {
#pos: Float64Array<ArrayBuffer> | undefined
#size: Float64Array<ArrayBuffer> | undefined
private _pos: Float64Array<ArrayBuffer> | undefined
private _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
}
#widget: WeakRef<IBaseWidget> | undefined
private _widgetRef: WeakRef<IBaseWidget> | undefined
/** Internal use only; API is not finalised and may change at any time. */
get _widget(): IBaseWidget | undefined {
return this.#widget?.deref()
return this._widgetRef?.deref()
}
set _widget(widget: IBaseWidget | undefined) {
this.#widget = widget ? new WeakRef(widget) : undefined
this._widgetRef = widget ? new WeakRef(widget) : undefined
}
get collapsedPos(): Readonly<Point> {
@@ -79,4 +79,12 @@ 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,8 +15,6 @@ 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
@@ -27,7 +25,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
]
}
@@ -40,7 +38,6 @@ 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(
@@ -78,4 +75,12 @@ 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. */
get #centreOffset(): Readonly<Point> {
private 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>
#node: LGraphNode
protected _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,6 +260,25 @@ 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

@@ -0,0 +1,131 @@
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}. */
#id: ExecutionId
private _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`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
private _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.
*/
#resolveSubgraphOutput(
private _resolveSubgraphOutput(
slot: number,
type: ISlotType,
visited: Set<string>

View File

@@ -38,12 +38,12 @@ export abstract class SubgraphIONodeBase<
static minWidth = 100
static roundedRadius = 10
readonly #boundingRect: Rectangle = new Rectangle()
private 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.
*/
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
private _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.
*/
#onSlotMenuAction(
private _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.
*/
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
private _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. */
#widgetRef?: WeakRef<IBaseWidget>
private _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 */
#eventAbortController = new AbortController()
private _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 {
}
}
#addSubgraphInputListeners(
private _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
}
}
}
#setWidget(
private _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
}
readonly #pos: Point = [0, 0]
private 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
}
#node: LGraphNode
private _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
#value?: TWidget['value']
private _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.

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