Compare commits

..

21 Commits

Author SHA1 Message Date
snomiao
c004a2b8bd chore(tsconfig): ensure complete TypeScript coverage for all project files (#5655)
## Summary
- Added missing directories and files to tsconfig.json to ensure
complete TypeScript type checking coverage
- Expanded config file patterns to include all .mts configuration files
- Verified all 908 TypeScript files in the project are now properly
covered

## Changes
- Added `scripts/**/*.ts` to cover all TypeScript files in scripts
directory (i18n collection, CI/CD scripts)
- Added `build/**/*.ts` to cover customIconCollection.ts and future
build scripts
- Changed `vite.config.mts` to `*.config.mts` to include all vite config
files (vite.electron.config.mts, vite.types.config.mts)

## Test plan
- [x] Run `pnpm typecheck` to verify no TypeScript errors
- [x] Verified all TypeScript files are covered by tsconfig patterns
- [x] browser_tests/ directory confirmed to have its own extending
tsconfig

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5655-chore-tsconfig-ensure-complete-TypeScript-coverage-for-all-project-files-2736d73d36508103acbadc53ca2b2913)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-23 09:31:30 -07:00
snomiao
d0aee031e9 [feat] Merge ComfyUI_devtools into ComfyUI_frontend (#5166)
## Summary
Merges ComfyUI devtools components into the ComfyUI frontend monorepo to
consolidate development tools.

## Changes
- Added devtools components from ComfyUI repository
- Integrated development nodes and utilities
- Consolidated fake model assets for testing

## Related Issues
Fixes #4683

## Testing
- Devtools components are now available within the frontend monorepo
- Development workflow remains consistent

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-22 23:15:50 -07:00
Simula_r
cec1de0147 feat: vue nodes LOD system (#5631)
## Summary

Replaced reactive (Vue-based) widget LOD with CSS visibility control.
Performance doesn't dramatically improve, but we avoid the mount/unmount
overhead during zoom/pan operations. This PR implements the visual
component of LOD—complex widgets that need lifecycle management will be
addressed separately.

### Problem & Solution
Problem: we want LOD to improve rendering performance and visual
feedback but discovered using reactivity in the current setup for it
meant mounting/unmounting caused worse lag than the performance it aimed
to fix. Switching to render all the details all the time but using css
visibility proved to be the best solution. However, it doesn't improve
rendering performance by much because the GPU texture size is the
bottleneck (from TransformPane.vue CSS transforms) and not
rasterization.

Solution: Keep all nodes/widgets mounted, use CSS visibility: hidden for
LOD. Trade memory for performance stability during zoom/pan/drag
operations.

### Technical Decision
We chose Performance > Memory:

- CSS transforms create a single GPU texture whose size depends on node
count, not widget complexity
- Mounting/unmounting hundreds of widgets during zoom = noticeable lag
from Vue VDOM diffing (since all components are mounted all the time
because of viewport culling challenge/trade off see
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5510.)
- CSS visibility changes = no reactivity overhead, smooth interactions
- Result: Similar performance, but without interaction stutters

This is the visual layer only. If we want a hook into the LOD state per
node / widget that would be the next follow up system to implement.

### Next Steps (maybe)
- Chunked (split up single Transform Pane transform layer) when
rendering 1000+ nodes (maybe)
- ~~Selective unmounting API for widgets that register as "expensive"~~
- ~~Client bound hydration system~~

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

<img width="1355" height="960" alt="image"
src="https://github.com/user-attachments/assets/41474d1b-9dbe-4240-a8cf-f4c9ff51d8e0"
/>
<img width="1354" height="963" alt="image"
src="https://github.com/user-attachments/assets/9f55edaa-5858-41b9-b6a8-c2d37e1649bd"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5631-feat-vue-nodes-LOD-system-2726d73d365081c6a6c4e14aa634f19c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-22 20:05:13 -07:00
Christian Byrne
b4976c1ddc Revert: Move VueFire persistence configuration to initialization (#5614) (#5729)
## Summary
This reverts PR #5614 which moved VueFire persistence configuration to
initialization.

## Reason for Revert

It breaks Google SSO login with error:

```
useErrorHandling.ts:12 FirebaseError: Firebase: Error (auth/argument-error).
    at createErrorInternal (index-c92d61ad.js:506:41)
    at _assert (index-c92d61ad.js:512:15)
    at _withDefaultResolver (index-c92d61ad.js:9237:5)
    at signInWithPopup (index-c92d61ad.js:9457:30)
    at executeAuthAction.createCustomer (firebaseAuthStore.ts:263:25)
    at executeAuthAction (firebaseAuthStore.ts:223:28)
    at Proxy.loginWithGoogle (firebaseAuthStore.ts:262:5)
    at Proxy.wrappedAction (pinia.mjs:1405:26)
    at useFirebaseAuthActions.ts:104:28
    at Object.signInWithGoogle (useErrorHandling.ts:39:22)
```

## Changes
- Reverts commit ea4e57b60 "Move VueFire persistence configuration to
initialization (#5614)"
- Restores previous Firebase auth persistence behavior

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5729-Revert-Move-VueFire-persistence-configuration-to-initialization-5614-2776d73d3650814c9b80d9c67c852874)
by [Unito](https://www.unito.io)
2025-09-22 19:04:08 -07:00
Alexander Brown
1611c7a224 Refactor: Further state management cleanup (#5727)
## Summary

Going through the GraphNodeManager and VueNodeLifecycle one property at
a time and removing the pieces that are not currently wired up or used
by the rest of the application

Fixes paste location by updating the layoutStore in LGraphCanvas (which
already mutates layoutStore elsewhere)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5727-WIP-Refactor-Further-state-management-cleanup-2766d73d36508173b379c6009c194a5a)
by [Unito](https://www.unito.io)
2025-09-22 18:47:26 -07:00
Comfy Org PR Bot
d01081dab4 1.28.1 (#5728)
Patch version increment to 1.28.1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5728-1-28-1-2776d73d365081b287e4fdc5cbcea07b)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2025-09-22 20:36:12 -05:00
Alexander Brown
e5d4d07d32 Refactor: More state management simplification (#5721)
## Summary

Remove more procedural synchronization in favor of using reactive
references.

> Note: Also includes some fixes for issues caused during HMR.

## Review Focus

In testing it seems to work the same, but let me know if I missed
something.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5721-Refactor-More-state-management-simplification-2766d73d3650819b8d7ddc047c460f2b)
by [Unito](https://www.unito.io)
2025-09-22 13:15:33 -07:00
Alexander Piskun
f086377307 add pricing for new api nodes (#5724)
## Summary

Added prices for the new upcoming API nodes. Backport required.
2025-09-22 11:33:00 -07:00
filtered
687b9e659c Fix reroute ID 0 treated as invalid (#5723)
## Summary

Fixes old logic bug from refactor
https://github.com/Comfy-Org/litegraph.js/pull/602/files

## Changes

- Fixes truthy refactor to explicitly check undefined

## Review Focus

No expectation that this will impact prod, however it may impact
extensions IF someone has explicitly been setting link parentId to 0.
This would be very strange, as it would cause unexpected behaviour in
other parts of the code (which all explicitly check `undefined`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5723-Fix-reroute-ID-0-treated-as-invalid-2766d73d365081568124ce1f85cdf84e)
by [Unito](https://www.unito.io)
2025-09-22 11:13:38 -07:00
Christian Byrne
da0d51311b fix Vue node being dragged when interacting with widgets (e.g., resizing textarea) (#5719)
## Summary

Applying changes in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5516 to entire widget
wrapper.
 
## Changes

- **What**: Added `.stop` modifier to pointer events in NodeWidgets
component to prevent [event
propagation](https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation)

## Review Focus

Verify widget interactions remain functional while ensuring parent node
drag/selection behavior is properly isolated.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5719-fix-Vue-node-being-dragged-when-interacting-with-widgets-e-g-resizing-textarea-2766d73d3650815091adcd1d65197c7b)
by [Unito](https://www.unito.io)
2025-09-21 21:56:03 -07:00
Christian Byrne
e314d9cbd9 [refactor] Simplify current user resolved hook implementation (#5718)
## Summary

Refactored `onUserResolved` function in auth composable to use VueUse
`whenever` utility instead of manual watch implementation and use
`immediate` option instead of invoking manually before creating watcher.

## Changes

- **What**: Replaced manual watch + immediate check pattern with [VueUse
whenever](https://vueuse.org/shared/whenever/) utility in
`useCurrentUser.ts:37`

## Review Focus

Behavioral equivalence verification - `whenever` with `immediate: true`
should maintain identical callback timing and cleanup semantics.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5718-refactor-Simplify-current-user-resolved-hook-implementation-2766d73d365081008b6de156dd78f940)
by [Unito](https://www.unito.io)
2025-09-21 21:53:25 -07:00
Christian Byrne
95baf8d2f1 [style] update Vue node tooltip style (#5717)
## Summary

Change Vue node tooltips to align with
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Nodes?node-id=6267-16837&m=dev)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5717-style-update-Vue-node-tooltip-style-2766d73d365081bdb095faef17f6aeb6)
by [Unito](https://www.unito.io)
2025-09-21 20:01:33 -07:00
Christian Byrne
f951e07cea fix bypass hotkey in vue nodes and fix node data instrumentation setup issue when switching to Vue nodes after initial load (#5715)
## Summary

Fixed Vue node keybinding target element ID to enable
bypass/pin/collapse hotkeys in both LiteGraph and Vue rendering modes.

Also fixed a bug when starting in litegraph mode => switching to Vue
nodes without reloading => `graph.onTrigger` is set to `undefined` which
interferes with proper setup of node data instrumentation, among other
things.

## Changes

- **What**: Updated keybinding `targetElementId` from `graph-canvas` to
`graph-canvas-container` for node manipulation commands (parent of both
the canvas and transform pane -- vue nodes container).
- **What**: Added conditional `onTrigger` handler restoration in slot
layout sync to prevent Vue node manager conflicts

## Review Focus

Event handler precedence between Vue nodes and LiteGraph systems during
mode switching, ensuring hotkeys work consistently across rendering
modes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5715-fix-bypass-hotkey-in-vue-nodes-and-fix-node-data-instrumentation-setup-issue-when-switchi-2756d73d3650815c8ec8d5e4d06232e3)
by [Unito](https://www.unito.io)
2025-09-21 17:32:12 -07:00
Christian Byrne
023e466dba fix using shift modifier to (de-)select Vue nodes (#5714)
## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5688 by
adding shift modifier support for multi-selecting Vue nodes, enabling
standard shift+click selection behavior alongside existing
ctrl/cmd+click.

## Changes

- **What**: Updated Vue node event handlers to include `event.shiftKey`
in multi-select logic
- **Testing**: Added browser tests for both ctrl and shift modifier
selection behaviors

## Review Focus

Multi-select behavior consistency across different input modifiers and
platform compatibility (Windows/Mac/Linux shift key handling).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5714-fix-using-shift-modifier-to-de-select-Vue-nodes-2756d73d365081bcb5e0fe80eacdb2f0)
by [Unito](https://www.unito.io)
2025-09-21 14:39:40 -07:00
Christian Byrne
abd6823744 [refactor] Remove redundant module comment (#5711)
Removes a comment added in initial Vue Nodes commit. The comment is
interpolated between import statements which is stylistically awkward
and it is almost totally redundant with the doc comment on the
composable:


c1d4709e96/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts (L10-L14)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5711-refactor-Remove-redundant-module-comment-2756d73d365081ef9bffe0257b3670f1)
by [Unito](https://www.unito.io)
2025-09-21 14:30:58 -07:00
Alexander Brown
c4c0e52e64 Refactor: Let LGraphNode handle more events itself (#5709)
## Summary

Don't route events up through GraphCanvas if the component itself can
handle the changes

## Changes

- **What**: Reduce the indirect access or action dispatch to
composables/stores.

## Review Focus

The behavior should be either equivalent or a little snappier than
before. Also, the local state in LGraphNode has (almost) all been
removed in favor of reacting to the nodeData prop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5709-Refactor-Let-LGraphNode-handle-more-events-itself-2756d73d365081e6a88ce6241bceecc0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-20 22:14:30 -07:00
Christian Byrne
295332dc46 update CODEOWNERS (#5667)
Add explicit CODEOWNERS for new features to allow more domain-driven
review/approval/ownership processes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5667-update-CODEOWNERS-2736d73d3650817ea52be9c4a8fe5ff2)
by [Unito](https://www.unito.io)
2025-09-20 20:11:35 -07:00
Christian Byrne
5c498348b8 fix: update to standardized mobile web app meta tag syntax (#5672)
## Summary

Fixed WebKit deprecation warning by updating to standardized mobile web
app meta tag syntax.

## Changes

- **What**: Replaced deprecated `apple-mobile-web-app-capable` with
cross-platform
[`mobile-web-app-capable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#mobile-web-app-capable)
meta tag to align with WebKit's move toward vendor-neutral standards

## Review Focus

Verify "Add to Home Screen" functionality still works on iOS/iPadOS and
that the WebKit console warning is resolved in production builds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5672-fix-update-to-standardized-mobile-web-app-meta-tag-syntax-2736d73d3650811cb2a1f0b14ce0a0e7)
by [Unito](https://www.unito.io)
2025-09-20 20:10:51 -07:00
Alexander Brown
8133bd4b7b Refactor: Composable disentangling (#5695)
## Summary

Prerequisite refactor/cleanup to use a global store instead of having
nodes throw up events to a parent component that stores a reference to a
singleton service that itself bootstraps and synchronizes with a
separate service to maintain a partially reactive but not fully reactive
set of states that describe some but not all aspects of the nodes on
either the litegraph, the vue side, or both.

## Changes

- **What**: Refactoring, the behavior should not change.
- **Dependencies**: A type utility to help with Vue component props

## Review Focus

Is there something about the current structure that this could affect
that would not be caught by our tests or using the application?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5695-Refactor-Composable-disentangling-2746d73d365081e6938ce656932f3e36)
by [Unito](https://www.unito.io)
2025-09-20 13:06:42 -07:00
Arjan Singh
fd12591756 [feat] integrate asset browser with widget system (#5629)
## Summary

Add asset browser dialog integration for combo widgets with full
animation support and proper state management.

(Thank you Claude from saving me me from merge conflict hell on this
one.)

## Changes

- Widget integration: combo widgets now use AssetBrowserModal for
eligible asset types
- Dialog animations: added animateHide() for smooth close transitions
- Async operations: proper sequencing of widget updates and dialog
animations
- Service layer: added getAssetsForNodeType() and getAssetDetails()
methods
- Type safety: comprehensive TypeScript types and error handling
- Test coverage: unit tests for all new functionality
- Bonus: fixed the hardcoded labels in AssetFilterBar

Widget behavior:
- Shows asset browser button for eligible widgets when asset API enabled
- Handles asset selection with proper callback sequencing
- Maintains widget value updates and litegraph notification

## Review Focus

I will call out some stuff inline.

## Screenshots


https://github.com/user-attachments/assets/9d3a72cf-d2b0-445f-8022-4c49daa04637

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5629-feat-integrate-asset-browser-with-widget-system-2726d73d365081a9a98be9a2307aee0b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-20 11:44:18 -07:00
Christian Byrne
b3c939ff15 fix: add Safari requestIdleCallback polyfill (#5664)
## Summary

Implemented cross-browser requestIdleCallback polyfill to fix Safari
crashes during graph initialization.

## Changes

- **What**: Added
[requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
polyfill following [VS Code's
pattern](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
with setTimeout fallback for Safari
- **Breaking**: None - maintains existing GraphView behavior

## Review Focus

Safari compatibility testing and timeout handling in the 15ms fallback
window. Verify that initialization tasks (keybindings, server config,
model loading) still execute properly on Safari iOS.

## References

- [VS Code async.ts
implementation](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
- Source pattern for our polyfill
- [MDN
requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
- Browser API documentation
- [Safari requestIdleCallback
support](https://caniuse.com/requestidlecallback) - Browser
compatibility table

Fixes CLOUD-FRONTEND-STAGING-N

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5664-fix-add-Safari-requestIdleCallback-polyfill-2736d73d365081cdbcf1fb816fe098d6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 23:34:15 -07:00
135 changed files with 3473 additions and 2776 deletions

View File

@@ -32,11 +32,10 @@ jobs:
with:
repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout custom node repository
uses: actions/checkout@v4
with:

View File

@@ -27,12 +27,10 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ components.d.ts
tests-ui/data/*
tests-ui/ComfyUI_examples
tests-ui/workflows/examples
coverage/
# Browser tests
/test-results/

View File

@@ -1,17 +1,61 @@
# Admins
* @Comfy-Org/comfy_frontend_devs
# Desktop/Electron
/src/types/desktop/ @webfiltered
/src/constants/desktopDialogs.ts @webfiltered
/src/constants/desktopMaintenanceTasks.ts @webfiltered
/src/stores/electronDownloadStore.ts @webfiltered
/src/extensions/core/electronAdapter.ts @webfiltered
/src/views/DesktopDialogView.vue @webfiltered
/src/components/install/ @webfiltered
/src/components/maintenance/ @webfiltered
/vite.electron.config.mts @webfiltered
# Maintainers
*.md @Comfy-Org/comfy_maintainer
/tests-ui/ @Comfy-Org/comfy_maintainer
/browser_tests/ @Comfy-Org/comfy_maintainer
/.env_example @Comfy-Org/comfy_maintainer
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Translations (AIGODLIKE team + shinshin86)
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
# Topbar
/src/components/topbar/ @pythongosssss
# Load 3D extension
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Mask Editor extension
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88
# Assets
/src/platform/assets/ @arjansingh
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer

View File

@@ -16,9 +16,14 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
For local development, copy the devtools files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const buttonsInNodes = vueNodesContainer.getByRole('button')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(buttonsInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,47 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Node Selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' }
] as const
for (const { key: modifier, name } of modifiers) {
test(`should allow selecting multiple nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await comfyPage.page.getByText('KSampler').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
})
test(`should allow de-selecting nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Load Checkpoint').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
})

View File

@@ -0,0 +1,49 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const BYPASS_HOTKEY = 'Control+b'
const BYPASS_CLASS = /before:bg-bypass\/60/
test.describe('Vue Node Bypass', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'Load Checkpoint'
})
await checkpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).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
}) => {
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'Load Checkpoint'
})
const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'KSampler'
})
await checkpointNode.getByText('Load Checkpoint').click()
await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
})
})

View File

@@ -3,7 +3,6 @@
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true

17
build/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Build scripts configuration */
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -33,7 +33,13 @@ export default defineConfig([
},
parserOptions: {
parser: tseslint.parser,
projectService: true,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts'
]
},
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
@@ -77,12 +83,25 @@ export default defineConfig([
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Fullscreen mode on mobile browsers -->
<meta name="mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.0",
"version": "1.28.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -27,6 +27,8 @@
"preview": "nx preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
@@ -94,6 +96,7 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-component-type-helpers": "^3.0.7",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"zip-dir": "^2.0.0",

3
pnpm-lock.yaml generated
View File

@@ -339,6 +339,9 @@ importers:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.0.7
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))

View File

@@ -9,9 +9,18 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
interface WidgetInfo {
name?: string
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
comfyPage.page.route('**/view**', async (route) => {
await comfyPage.page.route('**/view**', async (route) => {
await route.fulfill({
body: JSON.stringify({})
})
@@ -20,6 +29,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api as ComfyApi
return await api.getNodeDefs()
})
@@ -52,7 +62,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
async function extractWidgetLabels() {
const nodeLabels = {}
const nodeLabels: WidgetLabels = {}
for (const nodeDef of nodeDefs) {
const inputNames = Object.values(nodeDef.inputs).map(
@@ -65,12 +75,15 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const widgetsMappings = await comfyPage.page.evaluate(
(args) => {
const [nodeName, displayName, inputNames] = args
// @ts-expect-error - LiteGraph is dynamically added to window
const node = window['LiteGraph'].createNode(nodeName, displayName)
if (!node.widgets?.length) return {}
return Object.fromEntries(
node.widgets
.filter((w) => w?.name && !inputNames.includes(w.name))
.map((w) => [w.name, w.label])
.filter(
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
)
.map((w: WidgetInfo) => [w.name, w.label])
)
},
[nodeDef.name, nodeDef.display_name, inputNames]

View File

@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
const relativePath = file.replace(srcLocaleDir, '')
const targetPath = join(tempBaseDir, relativePath)
ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file))
writeFileSync(targetPath, readFileSync(file, 'utf8'))
}
console.log('Captured current locale files to temp/base/')
}

14
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Script files configuration */
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -929,48 +929,6 @@ audio.comfy-audio.empty-audio-widget {
}
/* End of [Desktop] Electron window specific styles */
/* Vue Node LOD (Level of Detail) System */
/* These classes control rendering detail based on zoom level */
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
.lg-node--lod-minimal {
min-height: 32px;
transition: min-height 0.2s ease;
/* Performance optimizations */
text-shadow: none;
backdrop-filter: none;
}
.lg-node--lod-minimal .lg-node-body {
display: none !important;
}
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
.lg-node--lod-reduced {
transition: opacity 0.1s ease;
/* Performance optimizations */
text-shadow: none;
}
.lg-node--lod-reduced .lg-widget-label,
.lg-node--lod-reduced .lg-slot-label {
display: none;
}
.lg-node--lod-reduced .lg-slot {
opacity: 0.6;
font-size: 0.75rem;
}
.lg-node--lod-reduced .lg-widget {
margin: 2px 0;
font-size: 0.875rem;
}
/* Full LOD (zoom > 0.8) - Complete detail rendering */
.lg-node--lod-full {
/* Uses default styling - no overrides needed */
}
.lg-node {
/* Disable text selection on all nodes */
@@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform;
}
/* Global performance optimizations for LOD */
.lg-node--lod-minimal,
.lg-node--lod-reduced {
/* Remove ALL expensive paint effects */
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
text-shadow: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
clip-path: none !important;
/* START LOD specific styles */
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.isLOD .lg-node {
box-shadow: none;
filter: none;
backdrop-filter: none;
text-shadow: none;
-webkit-mask-image: none;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
}
/* Reduce paint complexity for minimal LOD */
.lg-node--lod-minimal {
/* Skip complex borders */
border-radius: 0 !important;
/* Use solid colors only */
background-image: none !important;
.isLOD .lg-node > * {
pointer-events: none;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */

98
src/base/common/async.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* Cross-browser async utilities for scheduling tasks during browser idle time
* with proper fallbacks for browsers that don't support requestIdleCallback.
*
* Implementation based on:
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
*/
interface IdleDeadline {
didTimeout: boolean
timeRemaining(): number
}
interface IDisposable {
dispose(): void
}
/**
* Internal implementation function that handles the actual scheduling logic.
* Uses feature detection to determine whether to use native requestIdleCallback
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: any,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
/**
* Execute the callback during the next browser idle period.
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: any = globalThis
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||
typeof safeGlobal.cancelIdleCallback !== 'function'
) {
// Fallback implementation for browsers without native support (e.g., Safari)
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
setTimeout(() => {
if (disposed) {
return
}
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
const end = Date.now() + 15
const deadline: IdleDeadline = {
didTimeout: true,
timeRemaining() {
return Math.max(0, end - Date.now())
}
}
runner(Object.freeze(deadline))
})
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
}
}
}
} else {
// Native requestIdleCallback implementation
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
const handle: number = targetWindow.requestIdleCallback(
runner,
typeof timeout === 'number' ? { timeout } : undefined
)
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
targetWindow.cancelIdleCallback(handle)
}
}
}
}
runWhenGlobalIdle = (runner, timeout) =>
_runWhenIdle(globalThis, runner, timeout)
})()

View File

@@ -33,7 +33,7 @@
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@@ -43,8 +43,6 @@
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
@@ -53,9 +51,6 @@
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
@@ -79,7 +74,6 @@ import {
nextTick,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -117,14 +111,11 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -171,20 +162,13 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const vueNodeLifecycle = useVueNodeLifecycle()
const viewportCulling = useViewportCulling()
const handleVueNodeLifecycleReset = async () => {
if (isVueNodesEnabled.value) {
if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
@@ -203,32 +187,8 @@ watch(
}
)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
const handleTransformUpdate = viewportCulling.handleTransformUpdate
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

View File

@@ -33,9 +33,11 @@ const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = '')
function hideTooltip() {
return (tooltipText.value = '')
}
const showTooltip = async (tooltip: string | null | undefined) => {
async function showTooltip(tooltip: string | null | undefined) {
if (!tooltip) return
left.value = comfyApp.canvas.mouse[0] + 'px'
@@ -56,9 +58,9 @@ const showTooltip = async (tooltip: string | null | undefined) => {
}
}
const onIdle = () => {
function onIdle() {
const { canvas } = comfyApp
const node = canvas.node_over
const node = canvas?.node_over
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }

View File

@@ -11,7 +11,7 @@
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{
header: 'hidden',
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
content: 'p-1 h-10 flex flex-row gap-1'
}"
@wheel="canvasInteractions.handleWheel"
>

View File

@@ -64,31 +64,29 @@ const litegraphService = useLitegraphService()
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
const dismissable = ref(true)
const getNewNodeLocation = (): Point => {
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
: litegraphService.getCanvasCenter()
}
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value.push(filter)
}
const removeFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter)
)
}
const clearFilters = () => {
function clearFilters() {
nodeFilters.value = []
}
const closeDialog = () => {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
const addNode = (nodeDef: ComfyNodeDefImpl) => {
function addNode(nodeDef: ComfyNodeDefImpl) {
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
@@ -106,7 +104,7 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const showSearchBox = (e: CanvasPointerEvent | null) => {
function showSearchBox(e: CanvasPointerEvent | null) {
if (newSearchBoxEnabled.value) {
if (e?.pointerType === 'touch') {
setTimeout(() => {
@@ -120,11 +118,12 @@ const showSearchBox = (e: CanvasPointerEvent | null) => {
}
}
const getFirstLink = () =>
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
function getFirstLink() {
return canvasStore.getCanvas().linkConnector.renderLinks.at(0)
}
const nodeDefStore = useNodeDefStore()
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
function showNewSearchBox(e: CanvasPointerEvent | null) {
const firstLink = getFirstLink()
if (firstLink) {
const filter =
@@ -149,7 +148,7 @@ const showNewSearchBox = (e: CanvasPointerEvent | null) => {
}, 300)
}
const showContextMenu = (e: CanvasPointerEvent) => {
function showContextMenu(e: CanvasPointerEvent) {
const firstLink = getFirstLink()
if (!firstLink) return
@@ -226,7 +225,7 @@ watchEffect(() => {
)
})
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
function canvasEventHandler(e: LiteGraphCanvasEvent) {
if (e.detail.subType === 'empty-double-click') {
showSearchBox(e.detail.originalEvent)
} else if (e.detail.subType === 'group-double-click') {
@@ -249,8 +248,10 @@ const linkReleaseActionShift = computed(() =>
)
// Prevent normal LinkConnector reset (called by CanvasPointer.finally)
const preventDefault = (e: Event) => e.preventDefault()
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
function preventDefault(e: Event) {
return e.preventDefault()
}
function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
e.preventDefault()
const canvas = canvasStore.getCanvas()
@@ -260,7 +261,7 @@ const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
})
}
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
disconnectOnReset = true
const action = e.detail.shiftKey
? linkReleaseActionShift.value
@@ -281,7 +282,7 @@ const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
}
// Resets litegraph state
const reset = () => {
function reset() {
listenerController?.abort()
listenerController = null
triggerEvent = null

View File

@@ -1,4 +1,5 @@
import { computed, watch } from 'vue'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
@@ -33,19 +34,8 @@ export const useCurrentUser = () => {
return null
})
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
if (resolvedUserInfo.value) {
callback(resolvedUserInfo.value)
}
const stop = watch(resolvedUserInfo, (value) => {
if (value) {
callback(value)
}
})
return () => stop()
}
const onUserResolved = (callback: (user: AuthUserInfo) => void) =>
whenever(resolvedUserInfo, callback, { immediate: true })
const userDisplayName = computed(() => {
if (isApiKeyLogin.value) {

View File

@@ -4,7 +4,7 @@ import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -71,7 +71,7 @@ export function useSelectionToolboxPosition(
visible.value = true
// Get bounds for all selected items
const allBounds: Rect[] = []
const allBounds: ReadOnlyRect[] = []
for (const item of selectableItems) {
// Skip items without valid IDs
if (item.id == null) continue

View File

@@ -2,42 +2,15 @@
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { nextTick, reactive } from 'vue'
import { reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
spatialIndex?: QuadTree<string>
}
interface PerformanceMetrics {
fps: number
frameTime: number
updateTime: number
nodeCount: number
culledCount: number
callbackUpdateCount: number
rafUpdateCount: number
adaptiveQuality: boolean
}
export interface SafeWidgetData {
name: string
type: string
@@ -63,109 +36,26 @@ export interface VueNodeData {
}
}
interface SpatialMetrics {
queryTime: number
nodesInIndex: number
}
interface GraphNodeManager {
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState>
nodePositions: ReadonlyMap<string, { x: number; y: number }>
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
setupEventListeners(): () => void
cleanup(): void
// Update methods
scheduleUpdate(
nodeId?: string,
priority?: 'critical' | 'normal' | 'low'
): void
forceSync(): void
detectChangesInRAF(): void
// Spatial queries
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
// Debug
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
}
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { moveNode, resizeNode, createNode, deleteNode, setSource } =
useLayoutMutations()
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy data that auto-GCs when nodes are removed
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Performance tracking
const performanceMetrics = reactive<PerformanceMetrics>({
fps: 0,
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
callbackUpdateCount: 0,
rafUpdateCount: 0,
adaptiveQuality: false
})
// Spatial indexing using QuadTree
const spatialIndex = new QuadTree<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
queryTime: 0,
nodesInIndex: 0
})
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
// Change detection state
const lastNodesSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high',
spatialIndex: undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -286,7 +176,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
...currentData,
widgets: updatedWidgets
})
performanceMetrics.callbackUpdateCount++
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
@@ -356,71 +245,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
})
}
// Uncomment when needed for future features
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
// let metadata = nodeMetadata.get(node)
// if (!metadata) {
// attachMetadata(node)
// metadata = nodeMetadata.get(node)!
// }
// return metadata
// }
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(() => flush(), 4)
}
}
}
const flush = () => {
const startTime = performance.now()
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Sync with graph state
syncWithGraph()
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
}
const syncWithGraph = () => {
if (!graph?._nodes) return
@@ -431,11 +255,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
spatialIndex.remove(id)
}
}
@@ -451,163 +270,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
if (!nodeState.has(id)) {
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
}
})
// Update performance metrics
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(s) => s.culled
).length
}
// Most performant: Direct position sync without re-setting entire node
// Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
const startTime = performance.now()
// Use QuadTree for fast spatial query
const results: string[] = spatialIndex.query(viewportBounds)
const visibleIds = new Set(results)
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
return visibleIds
}
/**
* Detects position changes for a single node and updates reactive state
*/
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node and updates reactive state
*/
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Updates spatial index for a node if bounds changed
*/
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.update(id, bounds)
}
/**
* Updates performance metrics after change detection
*/
const updatePerformanceMetrics = (
startTime: number,
positionUpdates: number,
sizeUpdates: number
): void => {
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(state) => state.culled
).length
spatialMetrics.nodesInIndex = spatialIndex.size
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
}
}
/**
* Main RAF change detection function
*/
const detectChangesInRAF = () => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
setSource(LayoutSource.Canvas)
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, id)
const sizeChanged = detectSizeChanges(node, id)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Update spatial index if geometry changed
if (posChanged || sizeChanged) {
updateSpatialIndex(node, id)
}
}
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
}
/**
@@ -629,32 +292,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
nodePositions.set(id, nodePosition)
nodeSizes.set(id, nodeSize)
attachMetadata(node)
// Add to spatial index for viewport culling with final positions
const nodeBounds: Bounds = {
x: nodePosition.x,
y: nodePosition.y,
width: nodeSize.width,
height: nodeSize.height
}
spatialIndex.insert(id, nodeBounds, id)
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
@@ -698,9 +340,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
) => {
const id = String(node.id)
// Remove from spatial index
spatialIndex.remove(id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
@@ -708,10 +347,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
// Call original callback if provided
if (originalCallback) {
@@ -733,23 +368,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
// Clear pending updates
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
nodeState.clear()
nodePositions.clear()
nodeSizes.clear()
lastNodesSnapshot.clear()
pendingUpdates.clear()
criticalUpdates.clear()
lowPriorityUpdates.clear()
spatialIndex.clear()
}
}
@@ -845,18 +466,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
return {
vueNodeData,
nodeState,
nodePositions,
nodeSizes,
getNode,
setupEventListeners,
cleanup,
scheduleUpdate,
forceSync: syncWithGraph,
detectChangesInRAF,
getVisibleNodeIds,
performanceMetrics,
spatialMetrics,
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
cleanup
}
}

View File

@@ -6,27 +6,18 @@
* 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven)
*/
import { type Ref, computed } from 'vue'
import { useThrottleFn } from '@vueuse/core'
import { computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app as comfyApp } from '@/scripts/app'
interface NodeManager {
getNode: (id: string) => any
}
export function useViewportCulling(
isVueNodesEnabled: Ref<boolean>,
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
nodeDataTrigger: Ref<number>,
nodeManager: Ref<NodeManager | null>
) {
export function useViewportCulling() {
const canvasStore = useCanvasStore()
const { vueNodeData, nodeManager } = useVueNodeLifecycle()
const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values())
})
@@ -34,7 +25,7 @@ export function useViewportCulling(
* Update visibility of all nodes based on viewport
* Queries DOM directly - no cache maintenance needed
*/
const updateVisibility = () => {
function updateVisibility() {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
const canvas = canvasStore.canvas
@@ -76,31 +67,17 @@ export function useViewportCulling(
}
}
const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20)
// RAF throttling for smooth updates during continuous panning
let rafId: number | null = null
/**
* Handle transform update - called by TransformPane event
* Uses RAF to batch updates for smooth performance
*/
const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return
// Cancel previous RAF if still pending
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
// Schedule update in next animation frame
rafId = requestAnimationFrame(() => {
updateVisibility()
rafId = null
function handleTransformUpdate() {
requestAnimationFrame(async () => {
await updateVisibilityDebounced()
})
}
return {
allNodes,
handleTransformUpdate,
updateVisibility
handleTransformUpdate
}
}

View File

@@ -1,20 +1,12 @@
/**
* Vue Node Lifecycle Management Composable
*
* Handles the complete lifecycle of Vue node rendering system including:
* - Node manager initialization and cleanup
* - Layout store synchronization
* - Slot and link sync management
* - Reactive state management for node data, positions, and sizes
* - Memory management and proper cleanup
*/
import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { readonly, ref, shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
NodeState,
GraphNodeManager,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -24,13 +16,12 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { app as comfyApp } from '@/scripts/app'
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>(
null
)
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
// Sync management
@@ -40,22 +31,10 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Vue node data state
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
// Change detection function
const detectChangesInRAF = ref<() => void>(() => {})
// Trigger for forcing computed re-evaluation
const nodeDataTrigger = ref(0)
const isNodeManagerReady = computed(() => nodeManager.value !== null)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
@@ -68,10 +47,6 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Use the manager's data maps
vueNodeData.value = manager.vueNodeData
nodeState.value = manager.nodeState
nodePositions.value = manager.nodePositions
nodeSizes.value = manager.nodeSizes
detectChangesInRAF.value = manager.detectChangesInRAF
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
@@ -134,18 +109,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Reset reactive maps to clean state
vueNodeData.value = new Map()
nodeState.value = new Map()
nodePositions.value = new Map()
nodeSizes.value = new Map()
// Reset change detection function
detectChangesInRAF.value = () => {}
}
// Watch for Vue nodes enabled state changes
watch(
() =>
isVueNodesEnabled.value &&
shouldRenderVueNodes.value &&
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
(enabled) => {
if (enabled) {
@@ -159,7 +128,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Consolidated watch for slot layout sync management
watch(
[() => canvasStore.canvas, () => isVueNodesEnabled.value],
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
([canvas, vueMode], [, oldVueMode]) => {
const modeChanged = vueMode !== oldVueMode
@@ -191,7 +160,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Handle case where Vue nodes are enabled but graph starts empty
const setupEmptyGraphListener = () => {
if (
isVueNodesEnabled.value &&
shouldRenderVueNodes.value &&
comfyApp.graph &&
!nodeManager.value &&
comfyApp.graph._nodes.length === 0
@@ -202,7 +171,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
comfyApp.graph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed
if (isVueNodesEnabled.value && !nodeManager.value) {
if (shouldRenderVueNodes.value && !nodeManager.value) {
initializeNodeManager()
}
@@ -233,13 +202,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
return {
vueNodeData,
nodeState,
nodePositions,
nodeSizes,
nodeDataTrigger: readonly(nodeDataTrigger),
nodeManager: readonly(nodeManager),
detectChangesInRAF: readonly(detectChangesInRAF),
isNodeManagerReady,
// Lifecycle methods
initializeNodeManager,
@@ -248,3 +211,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
cleanup
}
}
export const useVueNodeLifecycle = createSharedComposable(
useVueNodeLifecycleIndividual
)

View File

@@ -1548,6 +1548,71 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
ByteDanceImageReferenceNode: {
displayPrice: byteDanceVideoPricingCalculator
},
WanTextToVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
const seconds = parseFloat(String(durationWidget.value))
const resolutionStr = String(resolutionWidget.value).toLowerCase()
const resKey = resolutionStr.includes('1080')
? '1080p'
: resolutionStr.includes('720')
? '720p'
: resolutionStr.includes('480')
? '480p'
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
const pricePerSecond: Record<string, number> = {
'480p': 0.05,
'720p': 0.1,
'1080p': 0.15
}
const pps = pricePerSecond[resKey]
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
const cost = (pps * seconds).toFixed(2)
return `$${cost}/Run`
}
},
WanImageToVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
const seconds = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).trim().toLowerCase()
const pricePerSecond: Record<string, number> = {
'480p': 0.05,
'720p': 0.1,
'1080p': 0.15
}
const pps = pricePerSecond[resolution]
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
const cost = (pps * seconds).toFixed(2)
return `$${cost}/Run`
}
},
WanTextToImageApi: {
displayPrice: '$0.03/Run'
}
}
@@ -1647,7 +1712,9 @@ export const useNodePricing = () => {
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution']
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

@@ -2,16 +2,17 @@
* Vue-related feature flags composable
* Manages local settings-driven flags and LiteGraph integration
*/
import { createSharedComposable } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { LiteGraph } from '../lib/litegraph/src/litegraph'
export const useVueFeatureFlags = () => {
function useVueFeatureFlagsIndividual() {
const settingStore = useSettingStore()
const isVueNodesEnabled = computed(() => {
const shouldRenderVueNodes = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
@@ -19,20 +20,20 @@ export const useVueFeatureFlags = () => {
}
})
// Whether Vue nodes should render
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
// Sync the Vue nodes flag with LiteGraph global settings
const syncVueNodesFlag = () => {
LiteGraph.vueNodesMode = isVueNodesEnabled.value
}
// Watch for changes and update LiteGraph immediately
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
watch(
shouldRenderVueNodes,
() => {
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
},
{ immediate: true }
)
return {
isVueNodesEnabled,
shouldRenderVueNodes,
syncVueNodesFlag
shouldRenderVueNodes
}
}
export const useVueFeatureFlags = createSharedComposable(
useVueFeatureFlagsIndividual
)

View File

@@ -122,14 +122,14 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: '.'
},
commandId: 'Comfy.Canvas.FitView',
targetElementId: 'graph-canvas'
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'p'
},
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
targetElementId: 'graph-canvas'
targetElementId: 'graph-canvas-container'
},
{
combo: {
@@ -137,7 +137,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
alt: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
targetElementId: 'graph-canvas'
targetElementId: 'graph-canvas-container'
},
{
combo: {
@@ -145,7 +145,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
targetElementId: 'graph-canvas'
targetElementId: 'graph-canvas-container'
},
{
combo: {
@@ -153,7 +153,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
targetElementId: 'graph-canvas'
targetElementId: 'graph-canvas-container'
},
{
combo: {

View File

@@ -76,7 +76,7 @@ export const i18n = createI18n({
})
/** Convenience shorthand: i18n.global */
export const { t, te } = i18n.global
export const { t, te, d } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -1,4 +1,4 @@
import type { Point, Rect } from './interfaces'
import type { Point, ReadOnlyRect, Rect } from './interfaces'
import { EaseFunction, Rectangle } from './litegraph'
export interface DragAndScaleState {
@@ -188,7 +188,10 @@ export class DragAndScale {
* Fits the view to the specified bounds.
* @param bounds The bounds to fit the view to, defined by a rectangle.
*/
fitToBounds(bounds: Rect, { zoom = 0.75 }: { zoom?: number } = {}): void {
fitToBounds(
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
): void {
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
let targetScale = this.scale
@@ -220,7 +223,7 @@ export class DragAndScale {
* @param bounds The bounds to animate the view to, defined by a rectangle.
*/
animateToBounds(
bounds: Rect | Rectangle,
bounds: ReadOnlyRect,
setDirty: () => void,
{
duration = 350,

View File

@@ -4,7 +4,6 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -1708,12 +1707,7 @@ export class LGraph
...subgraphNode.subgraph.groups
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
return {
boundingRect: new Rectangle(
p.pos[0],
p.pos[1],
p.size?.[0] ?? 0,
p.size?.[1] ?? 0
)
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
}
})
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]

View File

@@ -47,6 +47,8 @@ import type {
NullableProperties,
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size
} from './interfaces'
@@ -234,11 +236,11 @@ export class LGraphCanvas
implements CustomEventDispatcher<LGraphCanvasEventMap>
{
// Optimised buffers used during rendering
static #temp = [0, 0, 0, 0] satisfies Rect
static #temp_vec2 = [0, 0] satisfies Point
static #tmp_area = [0, 0, 0, 0] satisfies Rect
static #margin_area = [0, 0, 0, 0] satisfies Rect
static #link_bounding = [0, 0, 0, 0] satisfies Rect
static #temp = new Float32Array(4)
static #temp_vec2 = new Float32Array(2)
static #tmp_area = new Float32Array(4)
static #margin_area = new Float32Array(4)
static #link_bounding = new Float32Array(4)
static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -626,7 +628,7 @@ export class LGraphCanvas
dirty_area?: Rect | null
/** @deprecated Unused */
node_in_panel?: LGraphNode | null
last_mouse: Point = [0, 0]
last_mouse: ReadOnlyPoint = [0, 0]
last_mouseclick: number = 0
graph: LGraph | Subgraph | null
get _graph(): LGraph | Subgraph {
@@ -2631,7 +2633,7 @@ export class LGraphCanvas
pointer: CanvasPointer,
node?: LGraphNode | undefined
): void {
const dragRect: [number, number, number, number] = [0, 0, 0, 0]
const dragRect = new Float32Array(4)
dragRect[0] = e.canvasX
dragRect[1] = e.canvasY
@@ -3164,7 +3166,7 @@ export class LGraphCanvas
LGraphCanvas.active_canvas = this
this.adjustMouseEvent(e)
const mouse: Point = [e.clientX, e.clientY]
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
this.mouse[0] = mouse[0]
this.mouse[1] = mouse[1]
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
@@ -4030,6 +4032,18 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes`
const newPositions = created.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: node.size?.[1] ?? 200
}
}))
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)
graph.afterChange()
@@ -4053,10 +4067,7 @@ export class LGraphCanvas
this.setDirty(true)
}
#handleMultiSelect(
e: CanvasPointerEvent,
dragRect: [number, number, number, number]
) {
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this
@@ -4827,7 +4838,7 @@ export class LGraphCanvas
}
/** Get the target snap / highlight point in graph space */
#getHighlightPosition(): Point {
#getHighlightPosition(): ReadOnlyPoint {
return LiteGraph.snaps_for_comfy
? this.linkConnector.state.snapLinksPos ??
this._highlight_pos ??
@@ -4842,7 +4853,7 @@ export class LGraphCanvas
*/
#renderSnapHighlight(
ctx: CanvasRenderingContext2D,
highlightPos: Point
highlightPos: ReadOnlyPoint
): void {
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
if (!this._highlight_pos && !linkConnectorSnap) return
@@ -5184,8 +5195,7 @@ export class LGraphCanvas
// clip if required (mask)
const shape = node._shape || RenderShape.BOX
const size = LGraphCanvas.#temp_vec2
size[0] = node.renderingSize[0]
size[1] = node.renderingSize[1]
size.set(node.renderingSize)
if (node.collapsed) {
ctx.font = this.inner_text_font
@@ -5380,10 +5390,7 @@ export class LGraphCanvas
// Normalised node dimensions
const area = LGraphCanvas.#tmp_area
area[0] = node.boundingRect[0]
area[1] = node.boundingRect[1]
area[2] = node.boundingRect[2]
area[3] = node.boundingRect[3]
area.set(node.boundingRect)
area[0] -= node.pos[0]
area[1] -= node.pos[1]
@@ -5485,10 +5492,7 @@ export class LGraphCanvas
shape = RenderShape.ROUND
) {
const snapGuide = LGraphCanvas.#temp
snapGuide[0] = item.boundingRect[0]
snapGuide[1] = item.boundingRect[1]
snapGuide[2] = item.boundingRect[2]
snapGuide[3] = item.boundingRect[3]
snapGuide.set(item.boundingRect)
// Not all items have pos equal to top-left of bounds
const { pos } = item
@@ -5928,8 +5932,8 @@ export class LGraphCanvas
*/
renderLink(
ctx: CanvasRenderingContext2D,
a: Point,
b: Point,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
link: LLink | null,
skip_border: boolean,
flow: number | null,
@@ -5946,9 +5950,9 @@ export class LGraphCanvas
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
reroute?: Reroute
/** Offset of the bezier curve control point from {@link a point a} (output side) */
startControl?: Point
startControl?: ReadOnlyPoint
/** Offset of the bezier curve control point from {@link b point b} (input side) */
endControl?: Point
endControl?: ReadOnlyPoint
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
num_sublines?: number
/** Whether this is a floating link segment */
@@ -8419,7 +8423,7 @@ export class LGraphCanvas
* Starts an animation to fit the view around the specified selection of nodes.
* @param bounds The bounds to animate the view to, defined by a rectangle.
*/
animateToBounds(bounds: Rect | Rectangle, options: AnimationOptions = {}) {
animateToBounds(bounds: ReadOnlyRect, options: AnimationOptions = {}) {
const setDirty = () => this.setDirty(true, true)
this.ds.animateToBounds(bounds, setDirty, options)
}

View File

@@ -1,5 +1,4 @@
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { LGraph } from './LGraph'
import { LGraphCanvas } from './LGraphCanvas'
@@ -41,15 +40,15 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
title: string
font?: string
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
_bounding: [number, number, number, number] = [
_bounding: Float32Array = new Float32Array([
10,
10,
LGraphGroup.minWidth,
LGraphGroup.minHeight
]
])
_pos: Point = [10, 10]
_size: Size = [LGraphGroup.minWidth, LGraphGroup.minHeight]
_pos: Point = this._bounding.subarray(0, 2)
_size: Size = this._bounding.subarray(2, 4)
/** @deprecated See {@link _children} */
_nodes: LGraphNode[] = []
_children: Set<Positionable> = new Set()
@@ -108,13 +107,8 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
}
get boundingRect(): Rectangle {
return Rectangle.from([
this._pos[0],
this._pos[1],
this._size[0],
this._size[1]
])
get boundingRect() {
return this._bounding
}
get nodes() {
@@ -151,17 +145,14 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
configure(o: ISerialisedGroup): void {
this.id = o.id
this.title = o.title
this._pos[0] = o.bounding[0]
this._pos[1] = o.bounding[1]
this._size[0] = o.bounding[2]
this._size[1] = o.bounding[3]
this._bounding.set(o.bounding)
this.color = o.color
this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size
}
serialize(): ISerialisedGroup {
const b = [this._pos[0], this._pos[1], this._size[0], this._size[1]]
const b = this._bounding
return {
id: this.id,
title: this.title,
@@ -219,7 +210,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
)
if (LiteGraph.highlight_selected_group && this.selected) {
strokeShape(ctx, this.boundingRect, {
strokeShape(ctx, this._bounding, {
title_height: this.titleHeight,
padding
})
@@ -260,7 +251,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Move nodes we overlap the centre point of
for (const node of nodes) {
if (containsCentre(this.boundingRect, node.boundingRect)) {
if (containsCentre(this._bounding, node.boundingRect)) {
this._nodes.push(node)
children.add(node)
}
@@ -268,13 +259,12 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Move reroutes we overlap the centre point of
for (const reroute of reroutes.values()) {
if (isPointInRect(reroute.pos, this.boundingRect)) children.add(reroute)
if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute)
}
// Move groups we wholly contain
for (const group of groups) {
if (containsRect(this.boundingRect, group.boundingRect))
children.add(group)
if (containsRect(this._bounding, group._bounding)) children.add(group)
}
groups.sort((a, b) => {

View File

@@ -18,6 +18,7 @@ import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
import { NullGraphError } from './infrastructure/NullGraphError'
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
import { Rectangle } from './infrastructure/Rectangle'
import type {
ColorOption,
@@ -36,6 +37,8 @@ import type {
ISlotType,
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size
} from './interfaces'
@@ -384,7 +387,7 @@ export class LGraphNode
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
*/
onBounding?(this: LGraphNode, out: Rectangle): void
onBounding?(this: LGraphNode, out: Rect): void
console?: string[]
_level?: number
_shape?: RenderShape
@@ -410,12 +413,12 @@ export class LGraphNode
}
/** @inheritdoc {@link renderArea} */
#renderArea: [number, number, number, number] = [0, 0, 0, 0]
#renderArea: Float32Array = new Float32Array(4)
/**
* 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(): Rect {
get renderArea(): ReadOnlyRect {
return this.#renderArea
}
@@ -426,12 +429,12 @@ export class LGraphNode
*
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
*/
get boundingRect(): Rectangle {
get boundingRect(): ReadOnlyRectangle {
return this.#boundingRect
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
get boundingOffset(): Point {
get boundingOffset(): ReadOnlyPoint {
const {
pos: [posX, posY],
boundingRect: [bX, bY]
@@ -440,9 +443,9 @@ export class LGraphNode
}
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
_posSize: [number, number, number, number] = [0, 0, 0, 0]
_pos: Point = [0, 0]
_size: Size = [0, 0]
_posSize: Float32Array = new Float32Array(4)
_pos: Point = this._posSize.subarray(0, 2)
_size: Size = this._posSize.subarray(2, 4)
public get pos() {
return this._pos
@@ -1650,7 +1653,7 @@ export class LGraphNode
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
outputs ? outputs.length : 1
)
const size = out || [0, 0]
const size = out || new Float32Array([0, 0])
rows = Math.max(rows, 1)
// although it should be graphcanvas.inner_text_font size
const font_size = LiteGraph.NODE_TEXT_SIZE
@@ -1975,7 +1978,7 @@ export class LGraphNode
* @param out `x, y, width, height` are written to this array.
* @param ctx The canvas context to use for measuring text.
*/
measure(out: Rectangle, ctx: CanvasRenderingContext2D): void {
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
const titleMode = this.title_mode
const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE &&
@@ -2001,13 +2004,13 @@ export class LGraphNode
/**
* returns the bounding of the object, used for rendering purposes
* @param out {Rect?} [optional] a place to store the output, to free garbage
* @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage
* @param includeExternal {boolean?} [optional] set to true to
* include the shadow and connection points in the bounding calculation
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
*/
getBounding(out?: Rect, includeExternal?: boolean): Rect {
out ||= [0, 0, 0, 0]
out ||= new Float32Array(4)
const rect = includeExternal ? this.renderArea : this.boundingRect
out[0] = rect[0]
@@ -2028,10 +2031,7 @@ export class LGraphNode
this.onBounding?.(bounds)
const renderArea = this.#renderArea
renderArea[0] = bounds[0]
renderArea[1] = bounds[1]
renderArea[2] = bounds[2]
renderArea[3] = bounds[3]
renderArea.set(bounds)
// 4 offset for collapsed node connection points
renderArea[0] -= 4
renderArea[1] -= 4
@@ -3174,7 +3174,7 @@ export class LGraphNode
* @returns the position
*/
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
out ||= [0, 0]
out ||= new Float32Array(2)
const {
pos: [nodeX, nodeY],
@@ -3841,7 +3841,7 @@ export class LGraphNode
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
#measureSlots(): Rect | null {
#measureSlots(): ReadOnlyRect | null {
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {

View File

@@ -109,7 +109,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
_pos: [number, number]
_pos: Float32Array
/** @todo Clean up - never implemented in comfy. */
_last_time?: number
/** The last canvas 2D path that was used to render this link */
@@ -171,7 +171,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
this._data = null
// center
this._pos = [0, 0]
this._pos = new Float32Array(2)
}
/** @deprecated Use {@link LLink.create} */
@@ -205,7 +205,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
linkSegment: LinkSegment
): Reroute[] {
if (!linkSegment.parentId) return []
if (linkSegment.parentId === undefined) return []
return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? []
}
@@ -229,7 +229,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
linkSegment: LinkSegment,
rerouteId: RerouteId
): Reroute | null | undefined {
if (!linkSegment.parentId) return
if (linkSegment.parentId === undefined) return
return network.reroutes
.get(linkSegment.parentId)
?.findNextReroute(rerouteId)
@@ -498,7 +498,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
target_slot: this.target_slot,
type: this.type
}
if (this.parentId) copy.parentId = this.parentId
if (this.parentId !== undefined) copy.parentId = this.parentId
return copy
}
}

View File

@@ -1,4 +1,3 @@
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -13,8 +12,8 @@ import type {
LinkSegment,
Point,
Positionable,
ReadonlyLinkNetwork,
Rect
ReadOnlyRect,
ReadonlyLinkNetwork
} from './interfaces'
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
@@ -50,6 +49,8 @@ export class Reroute
return Reroute.radius + gap + Reroute.slotRadius
}
#malloc = new Float32Array(8)
/** The network this reroute belongs to. Contains all valid links and reroutes. */
#network: WeakRef<LinkNetwork>
@@ -72,7 +73,7 @@ export class Reroute
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot
#pos: [number, number] = [0, 0]
#pos = this.#malloc.subarray(0, 2)
/** @inheritdoc */
get pos(): Point {
return this.#pos
@@ -88,17 +89,17 @@ export class Reroute
}
/** @inheritdoc */
get boundingRect(): Rectangle {
get boundingRect(): ReadOnlyRect {
const { radius } = Reroute
const [x, y] = this.#pos
return Rectangle.from([x - radius, y - radius, 2 * radius, 2 * radius])
return [x - radius, y - radius, 2 * radius, 2 * radius]
}
/**
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
* Eliminates most hover positions using an extremely cheap check.
*/
get #hoverArea(): Rect {
get #hoverArea(): ReadOnlyRect {
const xOffset = 2 * Reroute.slotOffset
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
@@ -125,14 +126,14 @@ export class Reroute
sin: number = 0
/** Bezier curve control point for the "target" (input) side of the link */
controlPoint: [number, number] = [0, 0]
controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: [number, number] = [0, 0]
_pos: Float32Array = this.#malloc.subarray(6, 8)
/** @inheritdoc */
_dragging?: boolean

View File

@@ -67,7 +67,7 @@ interface IDrawTextInAreaOptions {
*/
export function strokeShape(
ctx: CanvasRenderingContext2D,
area: Rect | Rectangle,
area: Rect,
{
shape = RenderShape.BOX,
round_radius,

View File

@@ -1,6 +1,10 @@
import { clamp } from 'es-toolkit/compat'
import type { Rect, Size } from '@/lib/litegraph/src/interfaces'
import type {
ReadOnlyRect,
ReadOnlySize,
Size
} from '@/lib/litegraph/src/interfaces'
/**
* Basic width and height, with min/max constraints.
@@ -51,15 +55,15 @@ export class ConstrainedSize {
this.desiredHeight = height
}
static fromSize(size: Size): ConstrainedSize {
static fromSize(size: ReadOnlySize): ConstrainedSize {
return new ConstrainedSize(size[0], size[1])
}
static fromRect(rect: Rect): ConstrainedSize {
static fromRect(rect: ReadOnlyRect): ConstrainedSize {
return new ConstrainedSize(rect[2], rect[3])
}
setSize(size: Size): void {
setSize(size: ReadOnlySize): void {
this.desiredWidth = size[0]
this.desiredHeight = size[1]
}

View File

@@ -1,6 +1,6 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type {
ExportedSubgraph,
@@ -29,7 +29,7 @@ export interface LGraphEventMap {
/** The type of subgraph to create. */
subgraph: Subgraph
/** The boundary around every item that was moved into the subgraph. */
bounds: Rect
bounds: ReadOnlyRect
/** The raw data that was used to create the subgraph. */
exportedSubgraph: ExportedSubgraph
/** The links that were used to create the subgraph. */

View File

@@ -1,50 +1,47 @@
import type {
CompassCorners,
Point,
Rect,
ReadOnlyPoint,
ReadOnlyRect,
ReadOnlySize,
ReadOnlyTypedArray,
Size
} from '@/lib/litegraph/src/interfaces'
import { isInRectangle } from '@/lib/litegraph/src/measure'
/**
* A rectangle, represented as an array of 4 numbers: [x, y, width, height].
* A rectangle, represented as a float64 array of 4 numbers: [x, y, width, height].
*
* This class extends Array and provides both array access (rect[0], rect[1], etc.)
* and convenient property access (rect.x, rect.y, rect.width, rect.height).
* This class is a subclass of Float64Array, and so has all the methods of that class. Notably,
* {@link Rectangle.from} can be used to convert a {@link ReadOnlyRect}. Typing of this however,
* is broken due to the base TS lib returning Float64Array rather than `this`.
*
* Sub-array properties ({@link Float64Array.subarray}):
* - {@link pos}: The position of the top-left corner of the rectangle.
* - {@link size}: The size of the rectangle.
*/
export class Rectangle extends Array<number> {
export class Rectangle extends Float64Array {
#pos: Point | undefined
#size: Size | undefined
constructor(
x: number = 0,
y: number = 0,
width: number = 0,
height: number = 0
) {
super()
super(4)
this[0] = x
this[1] = y
this[2] = width
this[3] = height
this.length = 4
}
static override from([x, y, width, height]: Rect): Rectangle {
static override from([x, y, width, height]: ReadOnlyRect): Rectangle {
return new Rectangle(x, y, width, height)
}
/** Set all values from an array (for TypedArray compatibility) */
set(values: ArrayLike<number>): void {
this[0] = values[0] ?? 0
this[1] = values[1] ?? 0
this[2] = values[2] ?? 0
this[3] = values[3] ?? 0
}
/** Create a subarray (for TypedArray compatibility) */
subarray(begin: number = 0, end?: number): number[] {
const endIndex = end ?? this.length
return this.slice(begin, endIndex)
}
/**
* Creates a new rectangle positioned at the given centre, with the given width/height.
* @param centre The centre of the rectangle, as an `[x, y]` point
@@ -52,38 +49,57 @@ export class Rectangle extends Array<number> {
* @param height The height of the rectangle. Default: {@link width}
* @returns A new rectangle whose centre is at {@link x}
*/
static fromCentre([x, y]: Point, width: number, height = width): Rectangle {
static fromCentre(
[x, y]: ReadOnlyPoint,
width: number,
height = width
): Rectangle {
const left = x - width * 0.5
const top = y - height * 0.5
return new Rectangle(left, top, width, height)
}
static ensureRect(rect: Rect | Rectangle): Rectangle {
static ensureRect(rect: ReadOnlyRect): Rectangle {
return rect instanceof Rectangle
? rect
: new Rectangle(rect[0], rect[1], rect[2], rect[3])
}
/**
* The position of the top-left corner of this rectangle.
*/
get pos(): Point {
return [this[0], this[1]]
override subarray(
begin: number = 0,
end?: number
): Float64Array<ArrayBuffer> {
const byteOffset = begin << 3
const length = end === undefined ? end : end - begin
return new Float64Array(this.buffer, byteOffset, length)
}
set pos(value: Point) {
/**
* A reference to the position of the top-left corner of this rectangle.
*
* Updating the values of the returned object will update this rectangle.
*/
get pos(): Point {
this.#pos ??= this.subarray(0, 2)
return this.#pos!
}
set pos(value: ReadOnlyPoint) {
this[0] = value[0]
this[1] = value[1]
}
/**
* The size of this rectangle.
* A reference to the size of this rectangle.
*
* Updating the values of the returned object will update this rectangle.
*/
get size(): Size {
return [this[2], this[3]]
this.#size ??= this.subarray(2, 4)
return this.#size!
}
set size(value: Size) {
set size(value: ReadOnlySize) {
this[2] = value[0]
this[3] = value[1]
}
@@ -176,7 +192,7 @@ export class Rectangle extends Array<number> {
* Updates the rectangle to the values of {@link rect}.
* @param rect The rectangle to update to.
*/
updateTo(rect: Rect) {
updateTo(rect: ReadOnlyRect) {
this[0] = rect[0]
this[1] = rect[1]
this[2] = rect[2]
@@ -199,7 +215,7 @@ export class Rectangle extends Array<number> {
* @param point The point to check
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
*/
containsPoint([x, y]: Point): boolean {
containsPoint([x, y]: ReadOnlyPoint): boolean {
const [left, top, width, height] = this
return x >= left && x < left + width && y >= top && y < top + height
}
@@ -210,7 +226,7 @@ export class Rectangle extends Array<number> {
* @param other The rectangle to check
* @returns `true` if {@link other} is inside this rectangle, otherwise `false`.
*/
containsRect(other: Rect | Rectangle): boolean {
containsRect(other: ReadOnlyRect): boolean {
const { right, bottom } = this
const otherRight = other[0] + other[2]
const otherBottom = other[1] + other[3]
@@ -235,7 +251,7 @@ export class Rectangle extends Array<number> {
* @param rect The rectangle to check
* @returns `true` if {@link rect} overlaps with this rectangle, otherwise `false`.
*/
overlaps(rect: Rect | Rectangle): boolean {
overlaps(rect: ReadOnlyRect): boolean {
return (
this.x < rect[0] + rect[2] &&
this.y < rect[1] + rect[3] &&
@@ -368,12 +384,12 @@ export class Rectangle extends Array<number> {
}
/** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */
getOffsetTo([x, y]: Point): Point {
getOffsetTo([x, y]: ReadOnlyPoint): Point {
return [x - this[0], y - this[1]]
}
/** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */
getOffsetFrom([x, y]: Point): Point {
getOffsetFrom([x, y]: ReadOnlyPoint): Point {
return [this[0] - x, this[1] - y]
}
@@ -454,4 +470,14 @@ export class Rectangle extends Array<number> {
}
}
// ReadOnlyRectangle is now just Rectangle since we unified the types
export type ReadOnlyRectangle = Omit<
ReadOnlyTypedArray<Rectangle>,
| 'setHeightBottomAnchored'
| 'setWidthRightAnchored'
| 'resizeTopLeft'
| 'resizeBottomLeft'
| 'resizeTopRight'
| 'resizeBottomRight'
| 'resizeBottomRight'
| 'updateTo'
>

View File

@@ -1,4 +1,4 @@
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { ContextMenu } from './ContextMenu'
@@ -60,7 +60,7 @@ export interface HasBoundingRect {
* @readonly
* @see {@link move}
*/
readonly boundingRect: Rectangle
readonly boundingRect: ReadOnlyRect
}
/** An object containing a set of child objects */
@@ -82,6 +82,7 @@ export interface Positionable extends Parent<Positionable>, HasBoundingRect {
* @default 0,0
*/
readonly pos: Point
readonly size?: Size
/** true if this object is part of the selection, otherwise false. */
selected?: boolean
@@ -193,7 +194,7 @@ export interface LinkSegment {
/** The last canvas 2D path that was used to render this segment */
path?: Path2D
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
readonly _pos: [number, number]
readonly _pos: Float32Array
/**
* Y-forward along the {@link path} from its centre point, in radians.
* `undefined` if using circles for link centres.
@@ -225,13 +226,52 @@ export interface IFoundSlot extends IInputOrOutput {
}
/** A point represented as `[x, y]` co-ordinates */
export type Point = [x: number, y: number]
export type Point = [x: number, y: number] | Float32Array | Float64Array
/** A size represented as `[width, height]` */
export type Size = [width: number, height: number]
export type Size = [width: number, height: number] | Float32Array | Float64Array
/** A very firm array */
type ArRect = [x: number, y: number, width: number, height: number]
/** A rectangle starting at top-left coordinates `[x, y, width, height]` */
export type Rect = [number, number, number, number]
export type Rect = ArRect | Float32Array | Float64Array
/** A point represented as `[x, y]` co-ordinates that will not be modified */
export type ReadOnlyPoint =
| readonly [x: number, y: number]
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
/** A size represented as `[width, height]` that will not be modified */
export type ReadOnlySize =
| readonly [width: number, height: number]
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
export type ReadOnlyRect =
| readonly [x: number, y: number, width: number, height: number]
| ReadOnlyTypedArray<Float32Array>
| ReadOnlyTypedArray<Float64Array>
type TypedArrays =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
type TypedBigIntArrays = BigInt64Array | BigUint64Array
export type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
Omit<
Readonly<T>,
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
>
/** Union of property names that are of type Match */
type KeysOfType<T, Match> = Exclude<
@@ -290,7 +330,7 @@ export interface INodeSlot extends HasBoundingRect {
nameLocked?: boolean
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: Rectangle
boundingRect: Rect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.

View File

@@ -48,7 +48,6 @@ export interface LinkReleaseContextExtended {
links: ConnectingLink[]
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
@@ -140,7 +139,7 @@ export { BaseWidget } from './widgets/BaseWidget'
export { LegacyWidget } from './widgets/LegacyWidget'
export { isComboWidget } from './widgets/widgetMap'
export { isComboWidget, isAssetWidget } from './widgets/widgetMap'
// Additional test-specific exports
export { LGraphButton } from './LGraphButton'
export { MovingOutputLink } from './canvas/MovingOutputLink'

View File

@@ -1,5 +1,10 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { HasBoundingRect, Point, Rect } from './interfaces'
import type {
HasBoundingRect,
Point,
ReadOnlyPoint,
ReadOnlyRect,
Rect
} from './interfaces'
import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
/**
@@ -8,7 +13,7 @@ import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
* @param b Point b as `x, y`
* @returns Distance between point {@link a} & {@link b}
*/
export function distance(a: Point, b: Point): number {
export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
return Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
)
@@ -56,7 +61,10 @@ export function isInRectangle(
* @param rect The rectangle, as `x, y, width, height`
* @returns `true` if the point is inside the rect, otherwise `false`
*/
export function isPointInRect(point: Point, rect: Rect | Rectangle): boolean {
export function isPointInRect(
point: ReadOnlyPoint,
rect: ReadOnlyRect
): boolean {
return (
point[0] >= rect[0] &&
point[0] < rect[0] + rect[2] &&
@@ -72,11 +80,7 @@ export function isPointInRect(point: Point, rect: Rect | Rectangle): boolean {
* @param rect The rectangle, as `x, y, width, height`
* @returns `true` if the point is inside the rect, otherwise `false`
*/
export function isInRect(
x: number,
y: number,
rect: Rect | Rectangle
): boolean {
export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean {
return (
x >= rect[0] &&
x < rect[0] + rect[2] &&
@@ -117,10 +121,7 @@ export function isInsideRectangle(
* @param b Rectangle B as `x, y, width, height`
* @returns `true` if rectangles overlap, otherwise `false`
*/
export function overlapBounding(
a: Rect | Rectangle,
b: Rect | Rectangle
): boolean {
export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const aRight = a[0] + a[2]
const aBottom = a[1] + a[3]
const bRight = b[0] + b[2]
@@ -136,7 +137,7 @@ export function overlapBounding(
* @param rect The rectangle, as `x, y, width, height`
* @returns The centre of the rectangle, as `x, y`
*/
export function getCentre(rect: Rect | Rectangle): Point {
export function getCentre(rect: ReadOnlyRect): Point {
return [rect[0] + rect[2] * 0.5, rect[1] + rect[3] * 0.5]
}
@@ -146,10 +147,7 @@ export function getCentre(rect: Rect | Rectangle): Point {
* @param b Sub-rectangle B as `x, y, width, height`
* @returns `true` if {@link a} contains most of {@link b}, otherwise `false`
*/
export function containsCentre(
a: Rect | Rectangle,
b: Rect | Rectangle
): boolean {
export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const centreX = b[0] + b[2] * 0.5
const centreY = b[1] + b[3] * 0.5
return isInRect(centreX, centreY, a)
@@ -161,10 +159,7 @@ export function containsCentre(
* @param b Sub-rectangle B as `x, y, width, height`
* @returns `true` if {@link a} wholly contains {@link b}, otherwise `false`
*/
export function containsRect(
a: Rect | Rectangle,
b: Rect | Rectangle
): boolean {
export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
const aRight = a[0] + a[2]
const aBottom = a[1] + a[3]
const bRight = b[0] + b[2]
@@ -294,8 +289,8 @@ export function rotateLink(
* the right
*/
export function getOrientation(
lineStart: Point,
lineEnd: Point,
lineStart: ReadOnlyPoint,
lineEnd: ReadOnlyPoint,
x: number,
y: number
): number {
@@ -315,10 +310,10 @@ export function getOrientation(
*/
export function findPointOnCurve(
out: Point,
a: Point,
b: Point,
controlA: Point,
controlB: Point,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
controlA: ReadOnlyPoint,
controlB: ReadOnlyPoint,
t: number = 0.5
): void {
const iT = 1 - t
@@ -335,13 +330,8 @@ export function findPointOnCurve(
export function createBounds(
objects: Iterable<HasBoundingRect>,
padding: number = 10
): Rect | null {
const bounds: [number, number, number, number] = [
Infinity,
Infinity,
-Infinity,
-Infinity
]
): ReadOnlyRect | null {
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
for (const obj of objects) {
const rect = obj.boundingRect
@@ -389,11 +379,11 @@ export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
* @returns The original {@link rect}, modified in place.
*/
export function alignToContainer(
rect: Rect | Rectangle,
rect: Rect,
anchors: Alignment,
[containerX, containerY, containerWidth, containerHeight]: Rect | Rectangle,
[insetX, insetY]: Point = [0, 0]
): Rect | Rectangle {
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
[insetX, insetY]: ReadOnlyPoint = [0, 0]
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left
rect[0] = containerX + insetX
@@ -432,11 +422,11 @@ export function alignToContainer(
* @returns The original {@link rect}, modified in place.
*/
export function alignOutsideContainer(
rect: Rect | Rectangle,
rect: Rect,
anchors: Alignment,
[otherX, otherY, otherWidth, otherHeight]: Rect | Rectangle,
[outsetX, outsetY]: Point = [0, 0]
): Rect | Rectangle {
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
[outsetX, outsetY]: ReadOnlyPoint = [0, 0]
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left
rect[0] = otherX - outsetX - rect[2]

View File

@@ -5,7 +5,7 @@ import type {
INodeInputSlot,
INodeOutputSlot,
OptionalProps,
Point
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
@@ -32,7 +32,7 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
this.#widget = widget ? new WeakRef(widget) : undefined
}
get collapsedPos(): Point {
get collapsedPos(): ReadOnlyPoint {
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
}

View File

@@ -5,7 +5,7 @@ import type {
INodeInputSlot,
INodeOutputSlot,
OptionalProps,
Point
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
@@ -24,7 +24,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
return false
}
get collapsedPos(): Point {
get collapsedPos(): ReadOnlyPoint {
return [
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
LiteGraph.NODE_TITLE_HEIGHT * -0.5

View File

@@ -8,7 +8,8 @@ import type {
INodeSlot,
ISubgraphInput,
OptionalProps,
Point
Point,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph, Rectangle } from '@/lib/litegraph/src/litegraph'
import { getCentre } from '@/lib/litegraph/src/measure'
@@ -35,7 +36,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(): Point {
get #centreOffset(): ReadOnlyPoint {
const nodePos = this.node.pos
const { boundingRect } = this
@@ -51,7 +52,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
}
/** The center point of this slot when the node is collapsed. */
abstract get collapsedPos(): Point
abstract get collapsedPos(): ReadOnlyPoint
#node: LGraphNode
get node(): LGraphNode {

View File

@@ -7,7 +7,7 @@ import type {
INodeInputSlot,
INodeOutputSlot,
Point,
Rect
ReadOnlyRect
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -213,7 +213,7 @@ export class SubgraphInput extends SubgraphSlot {
}
/** For inputs, x is the right edge of the input node. */
override arrange(rect: Rect): void {
override arrange(rect: ReadOnlyRect): void {
const [right, top, width, height] = rect
const { boundingRect: b, pos } = this

View File

@@ -7,7 +7,7 @@ import type {
INodeInputSlot,
INodeOutputSlot,
Point,
Rect
ReadOnlyRect
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -119,7 +119,7 @@ export class SubgraphOutput extends SubgraphSlot {
return [x + height, y + height * 0.5]
}
override arrange(rect: Rect): void {
override arrange(rect: ReadOnlyRect): void {
const [left, top, width, height] = rect
const { boundingRect: b, pos } = this

View File

@@ -11,8 +11,8 @@ import type {
INodeInputSlot,
INodeOutputSlot,
Point,
Rect,
Size
ReadOnlyRect,
ReadOnlySize
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SlotBase } from '@/lib/litegraph/src/node/SlotBase'
@@ -45,7 +45,7 @@ export abstract class SubgraphSlot
return LiteGraph.NODE_SLOT_HEIGHT
}
readonly #pos: Point = [0, 0]
readonly #pos: Point = new Float32Array(2)
readonly measurement: ConstrainedSize = new ConstrainedSize(
SubgraphSlot.defaultHeight,
@@ -133,7 +133,7 @@ export abstract class SubgraphSlot
}
}
measure(): Size {
measure(): ReadOnlySize {
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
const { defaultHeight } = SubgraphSlot
@@ -141,7 +141,7 @@ export abstract class SubgraphSlot
return this.measurement.toSize()
}
abstract arrange(rect: Rect): void
abstract arrange(rect: ReadOnlyRect): void
abstract connect(
slot: INodeInputSlot | INodeOutputSlot,

View File

@@ -13,6 +13,22 @@ export class AssetWidget
this.value = widget.value?.toString() ?? ''
}
override set value(value: IAssetWidget['value']) {
const oldValue = this.value
super.value = value
// Force canvas redraw when value changes to show update immediately
if (oldValue !== value && this.node.graph?.list_of_graphcanvas) {
for (const canvas of this.node.graph.list_of_graphcanvas) {
canvas.setDirty(true)
}
}
}
override get value(): IAssetWidget['value'] {
return super.value
}
override get _displayValue(): string {
return String(this.value) //FIXME: Resolve asset name
}

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget,
IWidget,
@@ -132,4 +133,9 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
// #endregion Type Guards

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -85,8 +84,8 @@ describe('LGraphNode', () => {
}))
}
node.configure(configureData)
expect(node.pos).toEqual([50, 60])
expect(node.size).toEqual([70, 80])
expect(node.pos).toEqual(new Float32Array([50, 60]))
expect(node.size).toEqual(new Float32Array([70, 80]))
})
test('should configure inputs correctly', () => {
@@ -572,7 +571,7 @@ describe('LGraphNode', () => {
name: 'test_in',
type: 'string',
link: null,
boundingRect: new Rectangle(0, 0, 0, 0)
boundingRect: new Float32Array([0, 0, 0, 0])
}
})
test('should return position based on title height when collapsed', () => {
@@ -595,7 +594,7 @@ describe('LGraphNode', () => {
name: 'test_in_2',
type: 'number',
link: null,
boundingRect: new Rectangle(0, 0, 0, 0)
boundingRect: new Float32Array([0, 0, 0, 0])
}
node.inputs = [inputSlot, inputSlot2]
const slotIndex = 0
@@ -630,13 +629,13 @@ describe('LGraphNode', () => {
name: 'in0',
type: 'string',
link: null,
boundingRect: new Rectangle(0, 0, 0, 0)
boundingRect: new Float32Array([0, 0, 0, 0])
}
const input1: INodeInputSlot = {
name: 'in1',
type: 'number',
link: null,
boundingRect: new Rectangle(0, 0, 0, 0),
boundingRect: new Float32Array([0, 0, 0, 0]),
pos: [5, 45]
}
node.inputs = [input0, input1]

View File

@@ -4,19 +4,19 @@ exports[`LGraph configure() > LGraph matches previous snapshot (normal configure
LGraph {
"_groups": [
LGraphGroup {
"_bounding": [
10,
10,
140,
80,
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": [
"_pos": Float32Array [
20,
20,
],
"_size": [
"_size": Float32Array [
1,
3,
],
@@ -39,19 +39,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -98,7 +98,6 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -109,19 +108,19 @@ LGraph {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -168,7 +167,6 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -180,19 +178,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -239,7 +237,6 @@ LGraph {
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
@@ -252,16 +249,7 @@ LGraph {
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
@@ -308,16 +296,7 @@ LGraph {
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -4,19 +4,19 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": [
10,
10,
140,
80,
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": [
"_pos": Float32Array [
20,
20,
],
"_size": [
"_size": Float32Array [
1,
3,
],
@@ -39,19 +39,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -111,19 +111,19 @@ LGraph {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -184,19 +184,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -258,16 +258,7 @@ LGraph {
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -4,19 +4,19 @@ exports[`LGraph (constructor only) > Matches previous snapshot > basicLGraph 1`]
LGraph {
"_groups": [
LGraphGroup {
"_bounding": [
10,
10,
140,
80,
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": [
"_pos": Float32Array [
20,
20,
],
"_size": [
"_size": Float32Array [
1,
3,
],
@@ -39,19 +39,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -109,19 +109,19 @@ LGraph {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -180,19 +180,19 @@ LGraph {
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": [
"_pos": Float32Array [
10,
10,
],
"_posSize": [
0,
0,
0,
0,
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": [
"_size": Float32Array [
140,
60,
],
@@ -252,16 +252,7 @@ LGraph {
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
@@ -308,16 +299,7 @@ LGraph {
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},

View File

@@ -1,6 +1,5 @@
import { test as baseTest } from 'vitest'
import { Rectangle } from '../src/infrastructure/Rectangle'
import type { Point, Rect } from '../src/interfaces'
import {
addDirectionalOffset,
@@ -132,8 +131,8 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
test('createBounds correctly creates bounding box', ({ expect }) => {
const objects = [
{ boundingRect: new Rectangle(0, 0, 10, 10) },
{ boundingRect: new Rectangle(5, 5, 10, 10) }
{ boundingRect: [0, 0, 10, 10] as Rect },
{ boundingRect: [5, 5, 10, 10] as Rect }
]
const defaultBounds = createBounds(objects)

View File

@@ -1873,6 +1873,13 @@
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown"
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular"
}
}

View File

@@ -2,11 +2,6 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import {
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
} from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -14,7 +9,7 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuthWithDependencies } from 'vuefire'
import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
@@ -71,18 +66,6 @@ app
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [
// Configure Firebase Auth persistence: localStorage first, IndexedDB last.
// Localstorage is preferred to IndexedDB for mobile Safari compatibility.
VueFireAuthWithDependencies({
dependencies: {
persistence: [
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
]
}
})
]
modules: [VueFireAuth()]
})
.mount('#vue-app')

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import {
createMockAssets,
mockAssets
@@ -56,7 +57,7 @@ export const Default: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
@@ -96,7 +97,7 @@ export const SingleAssetType: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
@@ -145,7 +146,7 @@ export const NoLeftPanel: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {

View File

@@ -12,7 +12,7 @@
:nav-items="availableCategories"
>
<template #header-icon>
<i-lucide:folder class="size-4" />
<div class="icon-[lucide--folder] size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
</LeftSidePanel>
@@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, provide } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
@@ -46,6 +46,7 @@ import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { OnCloseKey } from '@/types/widgetTypes'
const props = defineProps<{
nodeType?: string
@@ -61,35 +62,28 @@ const emit = defineEmits<{
close: []
}>()
// Use AssetBrowser composable for all business logic
provide(OnCloseKey, props.onClose ?? (() => {}))
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
filteredAssets,
selectAsset
selectAssetWithCallback
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
// Handle close button - call both the prop callback and emit the event
const handleClose = () => {
props.onClose?.()
emit('close')
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
emit('asset-select', asset)
await selectAssetWithCallback(asset.id, props.onSelect)
}
</script>

View File

@@ -14,7 +14,7 @@
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
],
// Div-specific styles
!interactive && [

View File

@@ -3,7 +3,7 @@
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
<MultiSelect
v-model="fileFormats"
label="File formats"
:label="$t('assetBrowser.fileFormats')"
:options="fileFormatOptions"
:class="selectClasses"
data-component-id="asset-filter-file-formats"
@@ -12,7 +12,7 @@
<MultiSelect
v-model="baseModels"
label="Base models"
:label="$t('assetBrowser.baseModels')"
:options="baseModelOptions"
:class="selectClasses"
data-component-id="asset-filter-base-models"
@@ -23,7 +23,7 @@
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
<SingleSelect
v-model="sortBy"
label="Sort by"
:label="$t('assetBrowser.sortBy')"
:options="sortOptions"
:class="selectClasses"
data-component-id="asset-filter-sort"
@@ -43,6 +43,7 @@ import { ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
export interface FilterState {
@@ -74,10 +75,10 @@ const baseModelOptions = [
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: 'A-Z', value: 'name-asc' },
{ name: 'Z-A', value: 'name-desc' },
{ name: 'Recent', value: 'recent' },
{ name: 'Popular', value: 'popular' }
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
]
const emit = defineEmits<{

View File

@@ -1,8 +1,9 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { d, t } from '@/i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetBaseModel,
getAssetDescription
@@ -69,7 +70,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
// Create display stats from API data
const stats = {
formattedDate: new Date(asset.created_at).toLocaleDateString(),
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
@@ -162,12 +163,41 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
/**
* Asset selection that fetches full details and executes callback with filename
* @param assetId - The asset ID to select and fetch details for
* @param onSelect - Optional callback to execute with the asset filename
*/
async function selectAssetWithCallback(
assetId: string,
onSelect?: (filename: string) => void
): Promise<void> {
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
console.debug('Asset selected:', assetId)
}
if (!onSelect) {
return
}
try {
const detailAsset = await assetService.getAssetDetails(assetId)
const filename = detailAsset.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
assetId
)
return
}
onSelect(validatedFilename.data)
} catch (error) {
console.error(`Failed to fetch asset details for ${assetId}:`, error)
}
return asset.id
}
return {
@@ -182,7 +212,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
selectAssetWithCallback
}
}

View File

@@ -1,5 +1,7 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
@@ -8,36 +10,29 @@ interface AssetBrowserDialogProps {
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
/**
* Callback for when an asset is selected
* @param {string} filename - The validated filename from user_metadata.filename
*/
onAssetSelected?: (filename: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
function hide() {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
async function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (filename: string) => {
props.onAssetSelected?.(filename)
dialogStore.closeDialog({ key: dialogKey })
}
const handleClose = () => {
hide()
}
// Default dialog configuration for AssetBrowserModal
const dialogComponentProps = {
const dialogComponentProps: DialogComponentProps = {
headless: true,
modal: true,
closable: false,
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
},
header: {
class: 'p-0 hidden'
@@ -48,6 +43,17 @@ export const useAssetBrowserDialog = () => {
}
}
const assets: AssetItem[] = await assetService
.getAssetsForNodeType(props.nodeType)
.catch((error) => {
console.error(
'Failed to fetch assets for node type:',
props.nodeType,
error
)
return []
})
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
@@ -55,12 +61,13 @@ export const useAssetBrowserDialog = () => {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
assets,
onSelect: handleAssetSelected,
onClose: handleClose
onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
dialogComponentProps
})
}
return { show, hide }
return { show }
}

View File

@@ -4,13 +4,13 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string(),
asset_hash: z.string().nullable(),
size: z.number(),
mime_type: z.string(),
mime_type: z.string().nullable(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
updated_at: z.string().optional(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
@@ -33,6 +33,14 @@ const zModelFile = z.object({
pathIndex: z.number()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
.min(1, 'Filename cannot be empty')
.regex(/^[^\\:*?"<>|]+$/, 'Invalid filename characters') // Allow forward slashes, block backslashes and other unsafe chars
.regex(/^(?!\/|.*\.\.)/, 'Path must not start with / or contain ..') // Prevent absolute paths and directory traversal
.trim()
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse

View File

@@ -1,6 +1,7 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetItem,
type AssetResponse,
type ModelFile,
type ModelFolder,
@@ -127,10 +128,75 @@ function createAssetService() {
)
}
/**
* Gets assets for a specific node type by finding the matching category
* and fetching all assets with that category tag
*
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
*/
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
if (!nodeType || typeof nodeType !== 'string') {
return []
}
// Find the category for this node type using efficient O(1) lookup
const modelToNodeStore = useModelToNodeStore()
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) {
return []
}
// Fetch assets for this category using same API pattern as getAssetModels
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
`assets for ${nodeType}`
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
return (
data?.assets?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
) ?? []
)
}
/**
* Gets complete details for a specific asset by ID
* Calls the detail endpoint which includes user_metadata and all fields
*
* @param id - The asset ID
* @returns Promise<AssetItem> - Complete asset object with user_metadata
*/
async function getAssetDetails(id: string): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
if (!res.ok) {
throw new Error(
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const error = result.error
? fromZodError(result.error)
: 'Unknown validation error'
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
return {
getAssetModelFolders,
getAssetModels,
isAssetBrowserEligible
isAssetBrowserEligible,
getAssetsForNodeType,
getAssetDetails
}
}

View File

@@ -595,7 +595,7 @@ export const CORE_SETTINGS: SettingParams[] = [
migrateDeprecatedValue: (value: any[]) => {
return value.map((keybinding) => {
if (keybinding['targetSelector'] === '#graph-canvas') {
keybinding['targetElementId'] = 'graph-canvas'
keybinding['targetElementId'] = 'graph-canvas-container'
}
return keybinding
})

View File

@@ -99,6 +99,16 @@ export const useCanvasStore = defineStore('canvas', () => {
const currentGraph = shallowRef<LGraph | null>(null)
const isInSubgraph = ref(false)
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
selectedItems.value
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
whenever(
() => canvas.value,
(newCanvas) => {
@@ -122,6 +132,7 @@ export const useCanvasStore = defineStore('canvas', () => {
return {
canvas,
selectedItems,
selectedNodeIds,
nodeSelected,
groupSelected,
rerouteSelected,

View File

@@ -1,25 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing selected node IDs to Vue node components.
* Contains a reactive Set of selected node IDs (as strings).
*/
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('selectedNodeIds')
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).
*/
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('executingNodeIds')
/**
* Injection key for providing node progress states to Vue node components.
* Contains a reactive Record of node IDs to their current progress state.
*/
export const NodeProgressStatesKey: InjectionKey<
Ref<Record<string, NodeProgressState>>
> = Symbol('nodeProgressStates')

View File

@@ -7,14 +7,11 @@
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
Point as LitegraphPoint
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
@@ -27,7 +24,6 @@ import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
@@ -209,7 +205,6 @@ export class LitegraphLinkAdapter {
case LinkDirection.DOWN:
return 'down'
case LinkDirection.CENTER:
case LinkDirection.NONE:
return 'none'
default:
return 'right'
@@ -311,22 +306,22 @@ export class LitegraphLinkAdapter {
* Critically: does nothing for CENTER/NONE directions (no case for them)
*/
private applySplineOffset(
point: LitegraphPoint,
point: Point,
direction: LinkDirection,
distance: number
): void {
switch (direction) {
case LinkDirection.LEFT:
point[0] -= distance
point.x -= distance
break
case LinkDirection.RIGHT:
point[0] += distance
point.x += distance
break
case LinkDirection.UP:
point[1] -= distance
point.y -= distance
break
case LinkDirection.DOWN:
point[1] += distance
point.y += distance
break
// CENTER and NONE: no offset applied (original behavior)
}
@@ -338,8 +333,8 @@ export class LitegraphLinkAdapter {
*/
renderLinkDirect(
ctx: CanvasRenderingContext2D,
a: LitegraphPoint,
b: LitegraphPoint,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
link: LLink | null,
skip_border: boolean,
flow: number | boolean | null,
@@ -349,8 +344,8 @@ export class LitegraphLinkAdapter {
context: LinkRenderContext,
extras: {
reroute?: Reroute
startControl?: LitegraphPoint
endControl?: LitegraphPoint
startControl?: ReadOnlyPoint
endControl?: ReadOnlyPoint
num_sublines?: number
disabled?: boolean
} = {}
@@ -411,19 +406,13 @@ export class LitegraphLinkAdapter {
y: a[1] + (extras.startControl![1] || 0)
}
const end = { x: b[0], y: b[1] }
const endArray: LitegraphPoint = [end.x, end.y]
this.applySplineOffset(endArray, endDir, dist * factor)
end.x = endArray[0]
end.y = endArray[1]
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
} else if (!hasStartCtrl && hasEndCtrl) {
// End provided, derive start via direction offset (CENTER => no offset)
const start = { x: a[0], y: a[1] }
const startArray: LitegraphPoint = [start.x, start.y]
this.applySplineOffset(startArray, startDir, dist * factor)
start.x = startArray[0]
start.y = startArray[1]
this.applySplineOffset(start, startDir, dist * factor)
const end = {
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
@@ -434,14 +423,8 @@ export class LitegraphLinkAdapter {
// Neither provided: derive both from directions (CENTER => no offset)
const start = { x: a[0], y: a[1] }
const end = { x: b[0], y: b[1] }
const startArray: LitegraphPoint = [start.x, start.y]
const endArray: LitegraphPoint = [end.x, end.y]
this.applySplineOffset(startArray, startDir, dist * factor)
this.applySplineOffset(endArray, endDir, dist * factor)
start.x = startArray[0]
start.y = startArray[1]
end.x = endArray[0]
end.y = endArray[1]
this.applySplineOffset(start, startDir, dist * factor)
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
}
@@ -466,7 +449,7 @@ export class LitegraphLinkAdapter {
// Copy calculated center position back to litegraph object
// This is needed for hit detection and menu interaction
if (linkData.centerPos) {
linkSegment._pos = linkSegment._pos || [0, 0]
linkSegment._pos = linkSegment._pos || new Float32Array(2)
linkSegment._pos[0] = linkData.centerPos.x
linkSegment._pos[1] = linkData.centerPos.y
@@ -480,8 +463,8 @@ export class LitegraphLinkAdapter {
if (this.enableLayoutStoreWrites && link && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(
[linkData.startPoint.x, linkData.startPoint.y] as LitegraphPoint,
[linkData.endPoint.x, linkData.endPoint.y] as LitegraphPoint,
[linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
[linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
linkData
)
const centerPos = linkData.centerPos || {
@@ -514,57 +497,33 @@ export class LitegraphLinkAdapter {
}
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: LitegraphPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
from: ReadOnlyPoint,
to: ReadOnlyPoint,
colour: CanvasColour,
startDir: LinkDirection,
endDir: LinkDirection,
context: LinkRenderContext
): void {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
this.renderLinkDirect(
ctx,
from,
to,
null,
false,
null,
colour,
startDir,
endDir,
{
...context,
linkMarkerShape: LinkMarkerShape.None
},
{
disabled: false
}
)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Hide center marker when dragging links
pathContext.style.showCenterMarker = false
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**
@@ -572,8 +531,8 @@ export class LitegraphLinkAdapter {
* Includes padding for line width and control points
*/
private calculateLinkBounds(
startPos: LitegraphPoint,
endPos: LitegraphPoint,
startPos: ReadOnlyPoint,
endPos: ReadOnlyPoint,
linkData: LinkRenderData
): Bounds {
let minX = Math.min(startPos[0], endPos[0])

View File

@@ -1,6 +1,6 @@
import type { InjectionKey } from 'vue'
import type { Point } from '@/renderer/core/layout/types'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/**
* Lightweight, injectable transform state used by layout-aware components.
@@ -21,29 +21,11 @@ import type { Point } from '@/renderer/core/layout/types'
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
interface TransformState {
/** Convert a screen-space point (CSS pixels) to canvas space. */
screenToCanvas: (p: Point) => Point
/** Convert a canvas-space point to screen space (CSS pixels). */
canvasToScreen: (p: Point) => Point
/** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */
camera?: { x: number; y: number; z: number }
/**
* Test whether a node's rectangle intersects the (expanded) viewport.
* Handy for viewport culling and lazy work.
*
* @param nodePos Top-left in canvas space `[x, y]`
* @param nodeSize Size in canvas units `[width, height]`
* @param viewport Screen-space viewport `{ width, height }`
* @param margin Optional fractional margin (e.g. `0.2` = 20%)
*/
isNodeInViewport?: (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin?: number
) => boolean
}
interface TransformState
extends Pick<
ReturnType<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
> {}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -1379,6 +1379,7 @@ class LayoutStoreImpl implements LayoutStore {
this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds)
ynode.set('position', { x: bounds.x, y: bounds.y })
ynode.set('size', { width: bounds.width, height: bounds.height })
}
}, this.currentActor)

View File

@@ -12,7 +12,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { Point as LitegraphPoint } from '@/lib/litegraph/src/interfaces'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -125,7 +125,7 @@ export function useLinkLayoutSync() {
// Special handling for floating input chain
const isFloatingInputChain = !sourceNode && targetNode
const startControl: LitegraphPoint = isFloatingInputChain
const startControl: ReadOnlyPoint = isFloatingInputChain
? [0, 0]
: [dist * reroute.cos, dist * reroute.sin]
@@ -161,7 +161,7 @@ export function useLinkLayoutSync() {
(endPos[1] - lastReroute.pos[1]) ** 2
)
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
const finalStartControl: LitegraphPoint = [
const finalStartControl: ReadOnlyPoint = [
finalDist * lastReroute.cos,
finalDist * lastReroute.sin
]

View File

@@ -134,7 +134,11 @@ export function useSlotLayoutSync() {
restoreHandlers = () => {
graph.onNodeAdded = origNodeAdded || undefined
graph.onNodeRemoved = origNodeRemoved || undefined
graph.onTrigger = origTrigger || undefined
// Only restore onTrigger if Vue nodes are not active
// Vue node manager sets its own onTrigger handler
if (!LiteGraph.vueNodesMode) {
graph.onTrigger = origTrigger || undefined
}
graph.onAfterChange = origAfterChange || undefined
}

View File

@@ -1,7 +1,12 @@
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
class="absolute inset-0 w-full h-full pointer-events-none"
:class="
cn(
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD ? 'isLOD' : ''
)
"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
@@ -18,6 +23,8 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps {
canvas?: LGraphCanvas
@@ -34,6 +41,8 @@ const {
isNodeInViewport
} = useTransformState()
const { isLOD } = useLOD(camera)
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 200,

View File

@@ -94,17 +94,20 @@
</div>
</div>
<!-- Image Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
<div class="relative">
<!-- Image Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
</div>
</div>
</template>
@@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
interface ImagePreviewProps {
/** Array of image URLs to display */
readonly imageUrls: readonly string[]

View File

@@ -10,12 +10,15 @@
/>
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<div class="relative">
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div>
</template>
@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {

View File

@@ -23,7 +23,7 @@
bypassed,
'will-change-transform': isDragging
},
lodCssClass,
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -31,7 +31,7 @@
"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex
},
dragStyle
@@ -48,21 +48,18 @@
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, lodLevel, isCollapsed]"
v-memo="[nodeData.title, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
<div
v-if="
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
@@ -72,7 +69,7 @@
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isMinimalLOD && !isCollapsed">
<template v-if="!isCollapsed">
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
@@ -96,29 +93,24 @@
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-if="shouldRenderSlots"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
@slot-click="handleSlotClick"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="shouldShowWidgets"
v-memo="[nodeData.widgets?.length, lodLevel]"
v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="shouldShowContent"
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
@@ -139,29 +131,21 @@
</template>
<script setup lang="ts">
import {
computed,
inject,
onErrorCaptured,
onMounted,
provide,
ref,
toRef,
watch
} from 'vue'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -171,7 +155,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
@@ -181,8 +164,6 @@ import SlotConnectionDot from './SlotConnectionDot.vue'
// Extended props for main node component
interface LGraphNodeProps {
nodeData: VueNodeData
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
error?: string | null
zoomLevel?: number
@@ -190,39 +171,16 @@ interface LGraphNodeProps {
const {
nodeData,
position,
size,
error = null,
readonly = false,
zoomLevel = 1
readonly = false
} = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
]
'slot-click': [
event: PointerEvent,
nodeData: VueNodeData,
slotIndex: number,
isInput: boolean
]
dragStart: [event: DragEvent, nodeData: VueNodeData]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
useNodeEventHandlers()
useVueElementTracking(nodeData.id, 'node')
useVueElementTracking(() => nodeData.id, 'node')
// Inject selection state from parent
const selectedNodeIds = inject(SelectedNodeIdsKey)
if (!selectedNodeIds) {
throw new Error(
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
)
}
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Inject transform state for coordinate conversion
const transformState = inject(TransformStateKey)
@@ -233,7 +191,7 @@ const isSelected = computed(() => {
})
// Use execution state composable
const { executing, progress } = useNodeExecutionState(nodeData.id)
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
// Direct access to execution store for error state
const executionStore = useExecutionStore()
@@ -249,25 +207,7 @@ const hasAnyError = computed(
const bypassed = computed((): boolean => nodeData.mode === 4)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const {
handleWheel,
handlePointer,
forwardEventToCanvas,
shouldHandleNodePointerEvents
} = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel)
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(zoomRef)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -280,48 +220,28 @@ onErrorCaptured((error) => {
})
// Use layout system for node position and dragging
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
const {
position: layoutPosition,
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag,
resize
} = useNodeLayout(nodeData.id)
handlePointerDown,
handlePointerUp,
handlePointerMove,
isDragging,
dragStyle
} = useNodePointerInteractions(() => nodeData, handleNodeSelect)
onMounted(() => {
if (size && transformState?.camera) {
if (size.value && transformState?.camera) {
const scale = transformState.camera.z
const screenSize = {
width: size.width * scale,
height: size.height * scale
width: size.value.width * scale,
height: size.value.height * scale
}
resize(screenSize)
}
})
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastY = ref(0)
const lastX = ref(0)
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
// Track collapsed state
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => nodeData.flags?.collapsed,
(newCollapsed: boolean | undefined) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
}
}
)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
@@ -331,26 +251,16 @@ const hasCustomContent = computed(() => {
// Computed classes and conditions for better reusability
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
nodeData.id,
() => nodeData.id,
{
isMinimalLOD,
isCollapsed
}
)
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && nodeData.widgets?.length
)
const shouldShowContent = computed(
() => shouldRenderContent.value && hasCustomContent.value
)
const borderClass = computed(() => {
if (hasAnyError.value) {
return 'border-error'
@@ -375,86 +285,12 @@ const outlineClass = computed(() => {
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
// Check if this should be forwarded to canvas (e.g., space panning, middle mouse)
handlePointer(event)
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
emit('node-click', event, nodeData, wasDragging)
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', nodeData.id, isCollapsed.value)
handleNodeCollapse(nodeData.id, !isCollapsed.value)
}
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
// Don't handle slot clicks when canvas is in panning mode
if (!shouldHandleNodePointerEvents.value) {
return
}
emit('slot-click', event, nodeData, slotIndex, isInput)
}
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', nodeData.id, newTitle)
const handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const handleEnterSubgraph = () => {
@@ -484,15 +320,17 @@ const handleEnterSubgraph = () => {
const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const nodeImageUrls = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
nodeImageUrls.value = []
return
return []
}
const node = getNodeByLocatorId(rootGraph, locatorId)
@@ -500,30 +338,13 @@ const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) {
nodeImageUrls.value = urls
return urls
}
} else {
// Clear URLs if no outputs or no images
nodeImageUrls.value = []
}
}
// Clear URLs if no outputs or no images
return []
})
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
watch(
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
(newOutputs) => {
onNodeOutputsUpdate(newOutputs)
},
{ deep: true }
)
// Template ref for tooltip positioning
const nodeContainerRef = ref<HTMLElement>()
// Provide nodeImageUrls and tooltip container to child components
provide('nodeImageUrls', nodeImageUrls)
const nodeContainerRef = ref()
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -0,0 +1,3 @@
<template>
<div class="lod-fallback absolute inset-0 w-full h-full bg-zinc-800"></div>
</template>

View File

@@ -21,7 +21,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import ImagePreview from './ImagePreview.vue'
@@ -29,7 +28,6 @@ interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
imageUrls?: string[]
}

View File

@@ -4,41 +4,44 @@
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
class="lg-node-header p-4 rounded-t-2xl cursor-move"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
<div class="flex items-center justify-between relative">
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center lod-toggle"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1"
data-testid="node-title"
>
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1 lod-toggle"
data-testid="node-title"
>
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
<LODFallback />
</div>
<!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center">
<div v-if="!readonly" class="flex items-center lod-toggle">
<IconButton
v-if="isSubgraphNode"
size="sm"
@@ -63,17 +66,17 @@ import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { app } from '@/scripts/app'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import LODFallback from './LODFallback.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
collapsed?: boolean
}

View File

@@ -35,7 +35,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { isSlotObject } from '@/utils/typeGuardUtil'
import InputSlot from './InputSlot.vue'
@@ -44,7 +43,6 @@ import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()

View File

@@ -12,16 +12,17 @@
: 'pointer-events-none'
)
"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
@pointerdown.stop="handleWidgetPointerEvent"
@pointermove.stop="handleWidgetPointerEvent"
@pointerup.stop="handleWidgetPointerEvent"
>
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
class="lg-widget-container relative flex items-center group"
class="lg-widget-container flex items-center group"
>
<!-- Widget Input Slot Dot -->
<div
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
@@ -61,12 +62,10 @@ import type {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -77,10 +76,9 @@ import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -125,18 +123,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const widgets = nodeData.widgets as SafeWidgetData[]
const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
return []
}
for (const widget of widgets) {
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText
const simplified: SimplifiedWidget = {

View File

@@ -1,14 +1,16 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
>
{{ slotData.name || `Output ${index}` }}
</span>
<div class="relative">
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
>
{{ slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
@@ -38,6 +40,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {

View File

@@ -24,6 +24,7 @@ defineExpose({
>
<div
ref="slot-el"
class="slot-dot"
:style="{ backgroundColor: color }"
:class="
cn(

View File

@@ -1,295 +1,141 @@
# Level of Detail (LOD) Implementation Guide for Widgets
# ComfyUI Widget LOD System: Architecture and Implementation
## What is Level of Detail (LOD)?
## Executive Summary
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
For ComfyUI nodes, this means:
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
## The Two Approaches: Reactive vs. Static LOD
## Why LOD Matters
### Approach 1: Reactive LOD (Original Design)
Without LOD optimization:
- 1000+ nodes with full detail = browser lag and poor performance
- Text that's too small to read still gets rendered (wasted work)
- Visual effects that are invisible at distance still consume GPU
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
With LOD optimization:
- Smooth performance even with large node graphs
- Battery life improvement on laptops
- Better user experience across different zoom levels
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
## How to Implement LOD in Your Widget
### Approach 2: Static LOD with CSS (Current Implementation)
### Step 1: Get the LOD Context
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
```vue
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
## The GPU Texture Bottleneck
const props = defineProps<{
widget: any
zoomLevel: number
// ... other props
}>()
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
// Get LOD information
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
</script>
```
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
### Traditional Assumption
### Step 2: Choose What to Show at Different Zoom Levels
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
#### Understanding the LOD Score
- `lodScore` is a number from 0 to 1
- 0 = completely zoomed out (show minimal detail)
- 1 = fully zoomed in (show everything)
- 0.5 = medium zoom (show some details)
### Actual Browser Behavior
#### Understanding LOD Levels
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
- `'full'` = zoom level 0.8 or above (zoomed in close)
When all nodes are children of a single transformed parent:
### Step 3: Implement Your Widget's LOD Strategy
1. The browser creates one large GPU texture for the entire node graph
2. The texture dimensions are determined by the bounding box of all content
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
Here's a complete example of a slider widget with LOD:
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
```vue
<template>
<div class="number-widget">
<!-- The main control always shows -->
<input
v-model="value"
type="range"
:min="widget.min"
:max="widget.max"
class="widget-slider"
/>
<!-- Show label only when zoomed in enough to read it -->
<label
v-if="showLabel"
class="widget-label"
>
{{ widget.name }}
</label>
<!-- Show precise value only when fully zoomed in -->
<span
v-if="showValue"
class="widget-value"
>
{{ formattedValue }}
</span>
<!-- Show description only at full detail -->
<div
v-if="showDescription && widget.description"
class="widget-description"
>
{{ widget.description }}
</div>
</div>
</template>
## Two Distinct Performance Concerns
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
The analysis reveals two often-conflated performance considerations that should be understood separately:
const props = defineProps<{
widget: any
zoomLevel: number
}>()
### 1. Rendering Performance
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
**Question:** How fast can the browser paint and composite the node graph during interactions?
// Define when to show each element
const showLabel = computed(() => {
// Show label when user can actually read it
return lodScore.value > 0.4 // Roughly 12px+ text size
})
**Traditional thinking:** Show less content → render faster
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
const showValue = computed(() => {
// Show precise value only when zoomed in close
return lodScore.value > 0.7 // User is focused on this specific widget
})
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
const showDescription = computed(() => {
// Description only at full detail
return lodLevel.value === 'full' // Maximum zoom level
})
### 2. Memory and Lifecycle Management
// You can also use LOD for styling
const widgetClasses = computed(() => {
const classes = ['number-widget']
if (lodLevel.value === 'minimal') {
classes.push('widget--minimal')
}
return classes
})
</script>
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
<style scoped>
/* Apply different styles based on LOD */
.widget--minimal {
/* Simplified appearance when zoomed out */
.widget-slider {
height: 4px; /* Thinner slider */
opacity: 0.9;
}
}
This is where unmounting widgets might theoretically help:
/* Normal styling */
.widget-slider {
height: 8px;
transition: height 0.2s ease;
}
- Complex widgets (3D viewers, chart renderers) might hold significant memory
- Event listeners and reactive watchers consume resources
- Some widgets might run background processes or animations
.widget-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
.widget-value {
font-family: monospace;
font-size: 0.7rem;
color: var(--text-accent);
}
## Design Philosophy and Trade-offs
.widget-description {
font-size: 0.6rem;
color: var(--text-muted);
margin-top: 4px;
}
</style>
```
The current CSS-based approach makes several deliberate trade-offs:
## Common LOD Patterns
### What We Optimize For
### Pattern 1: Essential vs. Nice-to-Have
```typescript
// Always show the main functionality
const showMainControl = computed(() => true)
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
3. **Simple widget development** - Widget authors don't need to implement LOD logic
4. **Reliable state preservation** - Widgets never lose state from unmounting
// Granular control with lodScore
const showLabels = computed(() => lodScore.value > 0.4)
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
### What We Accept
// Simple control with lodLevel
const showExtras = computed(() => lodLevel.value === 'full')
```
1. **Higher baseline memory usage** - All widgets remain mounted
2. **Less granular control** - Widgets can't optimize their own LOD behavior
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
### Pattern 2: Smooth Opacity Transitions
```typescript
// Gradually fade elements based on zoom
const labelOpacity = computed(() => {
// Fade in from zoom 0.3 to 0.6
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
})
```
## Open Questions and Future Considerations
### Pattern 3: Progressive Detail
```typescript
const detailLevel = computed(() => {
if (lodScore.value < 0.3) return 'none'
if (lodScore.value < 0.6) return 'basic'
if (lodScore.value < 0.8) return 'standard'
return 'full'
})
```
### Should widgets have any LOD control?
## LOD Guidelines by Widget Type
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
### Text Input Widgets
- **Always show**: The input field itself
- **Medium zoom**: Show label
- **High zoom**: Show placeholder text, validation messages
- **Full zoom**: Show character count, format hints
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
**Current behavior:** Hidden via CSS but still mounted
**Question:** Should such widgets be able to opt into unmounting at distance?
### Button Widgets
- **Always show**: The button
- **Medium zoom**: Show button text
- **High zoom**: Show button description
- **Full zoom**: Show keyboard shortcuts, tooltips
The challenge is that introducing selective unmounting would require:
### Selection Widgets (Dropdown, Radio)
- **Always show**: The current selection
- **Medium zoom**: Show option labels
- **High zoom**: Show all options when expanded
- **Full zoom**: Show option descriptions, icons
- Maintaining widget state across mount/unmount cycles
- Accepting the performance cost of remounting when zooming in
- Adding complexity to the widget API
### Complex Widgets (Color Picker, File Browser)
- **Always show**: Simplified representation (color swatch, filename)
- **Medium zoom**: Show basic controls
- **High zoom**: Show full interface
- **Full zoom**: Show advanced options, previews
### Could we reduce GPU texture size?
## Design Collaboration Guidelines
Since texture dimensions are the limiting factor, could we:
### For Designers
When designing widgets, consider creating variants for different zoom levels:
- Use multiple compositor layers for different regions (chunk the transformpane)?
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
1. **Minimal Design** (far away view)
- Essential elements only
- Higher contrast for visibility
- Simplified shapes and fewer details
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
2. **Standard Design** (normal view)
- Balanced detail and simplicity
- Clear labels and readable text
- Good for most use cases
### Is there a hybrid approach?
3. **Full Detail Design** (close-up view)
- All labels, descriptions, and help text
- Rich visual effects and polish
- Maximum information density
Could we identify specific threshold scenarios where reactive LOD makes sense?
### Design Handoff Checklist
- [ ] Specify which elements are essential vs. nice-to-have
- [ ] Define minimum readable sizes for text elements
- [ ] Provide simplified versions for distant viewing
- [ ] Consider color contrast at different opacity levels
- [ ] Test designs at multiple zoom levels
- When node count is low (< 50 nodes)
- For specifically registered "expensive" widgets
- At extreme zoom levels only
## Testing Your LOD Implementation
## Implementation Guidelines
### Manual Testing
1. Create a workflow with your widget
2. Zoom out until nodes are very small
3. Verify essential functionality still works
4. Zoom in gradually and check that details appear smoothly
5. Test performance with 50+ nodes containing your widget
Given the current architecture, here's how to work within the system:
### Performance Considerations
- Avoid complex calculations in LOD computed properties
- Use `v-if` instead of `v-show` for elements that won't render
- Consider using `v-memo` for expensive widget content
- Test on lower-end devices
### For Widget Developers
### Common Mistakes
**Don't**: Hide the main widget functionality at any zoom level
**Don't**: Use complex animations that trigger at every zoom change
**Don't**: Make LOD thresholds too sensitive (causes flickering)
**Don't**: Forget to test with real content and edge cases
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
3. **Minimize background processing** - Assume your widget is always running
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
**Do**: Keep essential functionality always visible
**Do**: Use smooth transitions between LOD levels
**Do**: Test with varying content lengths and types
**Do**: Consider accessibility at all zoom levels
### For System Architects
## Getting Help
1. **Monitor GPU memory usage** - The single texture approach has memory implications
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
- Ask in the ComfyUI frontend Discord for LOD implementation questions
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
- Profile performance impact using browser dev tools
## Conclusion
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.

View File

@@ -8,19 +8,17 @@
* - Layout mutations for visual feedback
* - Integration with LiteGraph canvas selection system
*/
import type { Ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
interface NodeManager {
getNode: (id: string) => any
}
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore()
const { nodeManager } = useVueNodeLifecycle()
const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
@@ -40,7 +38,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
const isMultiSelect = event.ctrlKey || event.metaKey
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey
if (isMultiSelect) {
// Ctrl/Cmd+click -> toggle selection
@@ -237,3 +235,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
deselectNodes
}
}
export const useNodeEventHandlers = createSharedComposable(
useNodeEventHandlersIndividual
)

View File

@@ -0,0 +1,93 @@
import { type MaybeRefOrGetter, computed, ref, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData>,
onPointerUp: (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => void
) {
const nodeData = toValue(nodeDataMaybe)
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeData.id)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastX = ref(0)
const lastY = ref(0)
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
onPointerUp(event, nodeData, wasDragging)
}
return {
isDragging,
dragStyle,
handlePointerMove,
handlePointerDown,
handlePointerUp
}
}

View File

@@ -93,10 +93,10 @@ export function useNodeTooltips(
pt: {
text: {
class:
'bg-charcoal-100 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
'bg-charcoal-800 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
},
arrow: {
class: 'before:border-charcoal-100'
class: 'before:border-slate-300'
}
}
}

View File

@@ -8,7 +8,13 @@
* Supports different element types (nodes, slots, widgets, etc.) with
* customizable data attributes and update handlers.
*/
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import {
type MaybeRefOrGetter,
getCurrentInstance,
onMounted,
onUnmounted,
toValue
} from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -154,9 +160,10 @@ const resizeObserver = new ResizeObserver((entries) => {
* ```
*/
export function useVueElementTracking(
appIdentifier: string,
appIdentifierMaybe: MaybeRefOrGetter<string>,
trackingType: string
) {
const appIdentifier = toValue(appIdentifierMaybe)
onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el
if (!(element instanceof HTMLElement) || !appIdentifier) return

View File

@@ -1,36 +0,0 @@
import { storeToRefs } from 'pinia'
import { computed, provide } from 'vue'
import {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import { useExecutionStore } from '@/stores/executionStore'
/**
* Composable for providing execution state to Vue node children
*
* This composable sets up the execution state providers that can be injected
* by child Vue nodes using useNodeExecutionState.
*
* Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas).
*/
export const useExecutionStateProvider = () => {
const executionStore = useExecutionStore()
const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } =
storeToRefs(executionStore)
// Convert execution store data to the format expected by Vue nodes
const executingNodeIds = computed(
() => new Set(storeExecutingNodeIds.value.map(String))
)
// Provide the execution state to all child Vue nodes
provide(ExecutingNodeIdsKey, executingNodeIds)
provide(NodeProgressStatesKey, nodeProgressStates)
return {
executingNodeIds,
nodeProgressStates
}
}

View File

@@ -1,10 +1,7 @@
import { computed, inject, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
import {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
/**
* Composable for managing execution state of Vue-based nodes
@@ -12,18 +9,18 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
* Provides reactive access to execution state and progress for a specific node
* by injecting execution data from the parent GraphCanvas provider.
*
* @param nodeId - The ID of the node to track execution state for
* @param nodeIdMaybe - The ID of the node to track execution state for
* @returns Object containing reactive execution state and progress
*/
export const useNodeExecutionState = (nodeId: string) => {
const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set<string>()))
const nodeProgressStates = inject(
NodeProgressStatesKey,
ref<Record<string, NodeProgressState>>({})
)
export const useNodeExecutionState = (
nodeIdMaybe: MaybeRefOrGetter<string>
) => {
const nodeId = toValue(nodeIdMaybe)
const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
storeToRefs(useExecutionStore())
const executing = computed(() => {
return executingNodeIds.value.has(nodeId)
return uniqueExecutingNodeIdStrings.value.has(nodeId)
})
const progress = computed(() => {

View File

@@ -1,12 +1,13 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import { computed, inject } from 'vue'
import { storeToRefs } from 'pinia'
import {
type CSSProperties,
type MaybeRefOrGetter,
computed,
inject,
toValue
} from 'vue'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -16,15 +17,16 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const nodeId = toValue(nodeIdMaybe)
const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
// Computed properties for easy access
const position = computed(() => {
@@ -53,8 +55,6 @@ export function useNodeLayout(nodeId: string) {
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
const selectedNodeIds = inject(SelectedNodeIdsKey, null)
/**
* Start dragging the node
*/
@@ -188,14 +188,16 @@ export function useNodeLayout(nodeId: string) {
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(() => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
}))
nodeStyle: computed(
(): CSSProperties => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
})
)
}
}

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