Compare commits

..

20 Commits

Author SHA1 Message Date
Christian Byrne
a0c02dfca6 make Vue nodes' outputs/previews responsively sized and work with node resizing (#5970)
## Summary

Added dedicated component for sampling previews and change all image
outputs (outputs, videos, previews) to be responsive and respond to node
resizing.



https://github.com/user-attachments/assets/7e683d32-4914-460c-ba08-4573c40aef24

## Changes

- **What**: Implemented `LivePreview` component for mid-execution
sampling visualization with responsive layout system
- **Dependencies**: Added resize handle composable and transform state
integration

## Review Focus

Node resize interaction conflicts with canvas dragging, and image
dimension calculation performance during rapid sampling updates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5970-make-Vue-nodes-outputs-previews-responsively-sized-and-work-with-node-resizing-2866d73d365081508d53e6e286a9a3fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-10 21:52:24 -07:00
Christian Byrne
e6534f17e6 fix: emit layout change for batch node bounds (#5939)
## Summary

Fixes issue where node size changes are not serialized by routing
DOM-driven node bounds updates through a single CRDT operation so Vue
node geometry stays synchronized with LiteGraph.

## Changes

- **What**: Added `BatchUpdateBoundsOperation` to the layout store,
applied it via the existing Yjs pipeline, notified link sync to
recompute touched nodes, and covered the path with a regression test

## Review Focus

Correctness of the new batch operation when multiple nodes update
simultaneously, especially remote replay/undo scenarios and link
geometry recomputation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5939-fix-emit-layout-change-for-batch-node-bounds-2846d73d365081db8f8cca5bf7b85308)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-10 20:47:12 -07:00
Comfy Org PR Bot
7e3c04399a 1.29.1 (#6012)
## What's Changed

### 🚀 Features
- Implement DOMWidget for vue (#6006)
- Implement drop-on-canvas + linkconnectoradapter consolidation (#5898)
- Vuenodes/audio widgets (#5627)
- Allow reordering of linked subgraph widgets (#5981)
- Contextmenu Monkeypatch Standardization (#5977)
- Fix/vue nodes snap to grid (#5973)
- Select Vue Nodes After Drag (#5863)
- fix Vue node widgets should be in disabled state if their slots are
connected with a link (#5834)

### 🐛 Bug Fixes
- [bugfix] Fix update-playwright-expectations workflow missing frontend
build (#6005)
- Fix: Reset size when collapsing (#6004)
- fix: misc LOD polish (#6001)
- Fix: Allow uncoloring Vue Nodes (#5991)
- [ci] Fix detached HEAD state in Playwright update workflow (#5985)
- Close zoom menu when toggling minimap visibility (#5974)

### 🔧 Maintenance
- Devex: Improve dev server (#6002)
- CI: Add concurrency checks to PR workflows (#6000)
- [feat] Auto-remove New Browser Test Expectations label after workflow
completes (#5998)
- CI: Simplify update playwright expectations (maybe) (#5994)
- Lint: Add tailwind linter (#5984)
- [feat] Auto-remove claude-review label after CI review completes
(#5983)
- Fix CI: Remove explicit repository parameter causing non-reproducible
test results (#5950)
- refactor: Reorganize GitHub Actions for better reusability (#5949)
- devex: Update CODEOWNERS (#5999)
- Docs: Update agent instructions about style classes (#5990)
- Style: Fix move cursors that should be grabs (#5989)
- Workflow templates review (#5975)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.29.0...v1.29.1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6012-1-29-1-2896d73d365081b08418f46934651c41)
by [Unito](https://www.unito.io)

Co-authored-by: arjansingh <1598641+arjansingh@users.noreply.github.com>
2025-10-10 20:29:49 -07:00
AustinMroz
2599136296 Implement DOMWidget for vue (#6006)
![vue-dom-widget](https://github.com/user-attachments/assets/d0c0e5f6-bacb-4fd9-957e-4f19e8071c3d)

Did testing on about a dozen custom nodes. Most just work.
- Some custom nodes have copy/pasted the `addDOMWidget` call with types
like `customtext` and get converted to textareas -> Not feasible to fix
here. Can open PRs into custom nodes if complaints arise.
- Only the KJNodes spline editor had mouse issues -> Can
investigate/open PR into KJNodes later.
- Many nodes don't resize gracefully. Probably best handled in a future
PR.
- Some expect to be handled like textareas. These currently have minsize
and don't scale.
- Others, like VHS previews, scale self properly, but don't update
height inside a drag operation -> node height can be set to less than
fit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6006-Implement-DOMWidget-for-vue-2886d73d3650817ca497c15d87d70f4f)
by [Unito](https://www.unito.io)
2025-10-10 14:11:38 -07:00
Johnpaul Chiwetelu
d7796fcda4 Vuenodes/audio widgets (#5627)
This pull request introduces a new audio playback widget for node UIs
and integrates it into the node widget system. The main changes include
the implementation of the `WidgetAudioUI` component, its registration in
the widget registry, and updates to pass node data to the new widget.
Additionally, some logging was added for debugging purposes.

**Audio Widget Implementation and Integration:**

* Added a new `WidgetAudioUI.vue` component that provides audio playback
controls (play/pause, progress slider, volume, options) and loads audio
files from the server based on node data.
* Registered the new `WidgetAudioUI` component in the widget registry by
importing it and adding an entry for the `audioUI` type.
[[1]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR21)
[[2]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR112-R115)
* Updated `NodeWidgets.vue` to pass `nodeInfo` as the `node-data` prop
to widgets of type `audioUI`, enabling the widget to access
node-specific audio file information.

**Debugging and Logging:**

* Added logging of `nodeData` in `LGraphNode.vue` and
`WidgetAudioUI.vue` to help with debugging and understanding the data
structure.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R188-R189)
[[2]](diffhunk://#diff-71cce190d74c6b5359288857ab9917caededb8cdf1a7e6377578b78aa32be2fcR1-R284)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5627-Vuenodes-audio-widgets-2716d73d365081fbbc06c1e6cf4ebf4d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Arjan Singh <1598641+arjansingh@users.noreply.github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-09 21:29:06 -07:00
Benjamin Lu
4404c0461d Implement drop-on-canvas + linkconnectoradapter consolidation (#5898)
Implements droponcanvas functionality and a linkconnectoradapter
refactor.

- Drop on canvas (Shift and default) integrated via LinkConnector
‘dropped-on-canvas’ with proper CanvasPointerEvent.
- LinkConnector adapter: now wraps the live canvas linkConnector (no
duplicate state); added dropOnCanvas() helper.
- Tests: Playwright scenarios for Shift-drop context menu/searchbox,
pinned endpoint, type prefilter, and post-selection auto-connect
(browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts).

There are some followup PRs that will fix/refactor some more noncritical
things, like the terrible slotid, the number/string nodeid confusion,
etc.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/5780 (snapping) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5898 (drop on canvas
+ linkconnectoradapter refactor) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5903 (fix reroute
snapping)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-10-09 20:55:30 -07:00
Johnpaul Chiwetelu
4cb03cf052 Select Vue Nodes After Drag (#5863)
This pull request refactors the node selection logic in the Vue nodes
event handler composable to simplify the function signature and improve
single vs. multi-selection behavior. The main change is the removal of
the `wasDragging` parameter from the `handleNodeSelect` function, with
selection logic now determined by the current selection state. Related
test code is updated to match the new function signature.

**Node selection logic improvements:**

* Refactored the `handleNodeSelect` function in
`useNodeEventHandlersIndividual` to remove the `wasDragging` parameter,
making the function signature simpler and relying on selection state to
handle single vs. multi-selection.
* Updated the selection logic to check if multiple nodes are already
selected using `isLGraphNode`, and only perform single selection if not.

**Code and test updates:**

* Updated all calls to `handleNodeSelect` in the composable to remove
the `wasDragging` argument, ensuring consistent usage throughout the
codebase.
[[1]](diffhunk://#diff-8d3820a1ca9c569bce00671fdd6290af81315ae11b8f3d6f29a5a9d30379d084L125-R123)
[[2]](diffhunk://#diff-8d3820a1ca9c569bce00671fdd6290af81315ae11b8f3d6f29a5a9d30379d084L146-R144)
[[3]](diffhunk://#diff-8d3820a1ca9c569bce00671fdd6290af81315ae11b8f3d6f29a5a9d30379d084L173-R171)
* Updated all related test cases to use the new `handleNodeSelect`
signature without the third parameter.
[[1]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL105-R105)
[[2]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL125-R125)
[[3]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL144-R144)
[[4]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL162-R162)
[[5]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL174-R174)
[[6]](diffhunk://#diff-89bfc2a05201c6ff7116578efa45f96097594eb346f18446c70aa7125ab1811aL187-R187)

**Utility import:**

* Added an import for `isLGraphNode` from `@/utils/litegraphUtil` to
support the updated selection logic.## Summary

<!-- One sentence describing what changed and why. -->


## Screenshots (if applicable)



https://github.com/user-attachments/assets/71e856d3-afc2-497d-826e-5b485066e7fe

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-09 20:48:03 -07:00
Johnpaul Chiwetelu
eeb0977738 Contextmenu Monkeypatch Standardization (#5977)
This pull request introduces a new extension API for context menu
customization, allowing extensions to contribute items to both canvas
and node right-click menus. It adds two collection methods to the
`ComfyApp` class to aggregate these menu items from all registered
extensions, and updates the extension interface accordingly.
Comprehensive unit tests are included to verify the correct aggregation
behavior and error handling.

**Extension API for Context Menus:**

* Added optional `getCanvasMenuItems` and `getNodeMenuItems` methods to
the `ComfyExtension` interface, enabling extensions to provide context
menu items for canvas and node right-click menus (`src/types/comfy.ts`).

* Updated type imports to support the new API, including
`IContextMenuValue`, `LGraphCanvas`, and `LGraphNode`
(`src/types/comfy.ts`, `src/scripts/app.ts`).
[[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66L1-R5)
[[2]](diffhunk://#diff-bde0dce9fe2403685d27b0e94a938c3d72824d02d01d1fd6167a0dddc6e585ddR10)

**Core Implementation:**

* Implemented `collectCanvasMenuItems` and `collectNodeMenuItems`
methods in the `ComfyApp` class to gather menu items from all
extensions, with robust error handling and logging for extension
failures (`src/scripts/app.ts`).

**Testing:**

* Added a comprehensive test suite for the new context menu extension
API, covering aggregation logic, error handling, and integration
scenarios (`tests-ui/tests/extensions/contextMenuExtension.test.ts`).

This is PR 1 of the 3 PRs in the Contextmenu standardizations.
-https://github.com/Comfy-Org/ComfyUI_frontend/pull/5992
-https://github.com/Comfy-Org/ComfyUI_frontend/pull/5993
2025-10-09 18:37:41 -07:00
Christian Byrne
9a505100ac [bugfix] Fix update-playwright-expectations workflow missing frontend build (#6005)
## Problem

The `update-playwright-expectations.yaml` workflow was failing with:
```
error: argument --front-end-root: The path '../dist' does not exist.
```

This was happening because the workflow was trying to launch the ComfyUI
server with `--front-end-root ../dist` before building the frontend.

## Root Cause

The workflow was missing the frontend build step entirely. It went
directly from checkout → setup server with `launch_server: true` → run
tests, skipping the crucial frontend build.

## Solution

1. Remove `launch_server: true` from `setup-comfyui-server` action call
2. Add `setup-frontend` action with `include_build_step: true` to build
the frontend
3. Add separate "Launch ComfyUI Server" step that runs AFTER frontend is
built

This ensures the `dist/` directory exists before the server tries to use
it.

## Testing

This fixes errors seen on PR #5863 and any PR using the
`/update-playwright` comment trigger.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6005-bugfix-Fix-update-playwright-expectations-workflow-missing-frontend-build-2876d73d36508182bb1af1123f3b2a87)
by [Unito](https://www.unito.io)
2025-10-09 15:43:54 -07:00
Alexander Brown
21873d40d5 Devex: Improve dev server (#6002)
## Summary

Keep Vite from watching extra files, should cut down on the amount of
times it tries to reload and hopefully fix a file contention issue with
git.
https://vite.dev/config/server-options.html#server-watch

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6002-Devex-Improve-dev-server-2876d73d365081a09417c2bf17da659f)
by [Unito](https://www.unito.io)
2025-10-09 15:07:02 -07:00
Simula_r
cbbbadf438 fix: misc LOD polish (#6001)
## Summary

Fix NodeHeader styes when LOD and prevent unwanted pointer-events at
LOD.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6001-fix-misc-LOD-polish-2876d73d365081efb5d6fcaf5815eee6)
by [Unito](https://www.unito.io)
2025-10-09 14:58:13 -07:00
Alexander Brown
d2972220bb Fix: Reset size when collapsing (#6004)
## Summary

Minimal initial fix to allow resized nodes to collapse.
Does not retain the size across collapse/expand.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/bd6bf496-eb58-4f48-b5dc-b388f20ed0d9


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6004-Fix-Reset-size-when-collapsing-2876d73d365081d7886cc4b708adddd6)
by [Unito](https://www.unito.io)
2025-10-09 14:51:14 -07:00
AustinMroz
4e08ed64f0 Allow reordering of linked subgraph widgets (#5981)
Widgets which are promoted by linking to a subgraphInput node are now
also displayed in the subgraph configuration window. They can now be
reordered by drag and drop along with proxyWidgets


![linked-reorder](https://github.com/user-attachments/assets/e1b8d590-211a-4d84-9f84-3a5fd5a7aa6c)

Known Issues:
- "Hide All" will incorrectly remove physically linked widgets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5981-Allow-reordering-of-linked-subgraph-widgets-2866d73d365081d9b27cf4a9c3078007)
by [Unito](https://www.unito.io)
2025-10-09 13:50:15 -07:00
Alexander Brown
13db1e484b CI: Add concurrency checks to PR workflows (#6000)
## Summary


https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-only-cancel-in-progress-jobs-or-runs-for-the-current-workflow

## Changes

- **What**: Will cancel ongoing checks when new commits are pushed to
the PR branch

## Review Focus

What other optimizations could we make?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6000-CI-Add-concurrency-checks-to-PR-workflows-2876d73d3650813cbb65eb8c397ac748)
by [Unito](https://www.unito.io)
2025-10-09 13:28:38 -07:00
Alexander Brown
8b7bc5eb89 devex: Update CODEOWNERS (#5999)
## Summary

Exempts the instructions files from CODEOWNERS

## Review Focus

Should we have specific owners for these files?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5999-devex-Update-CODEOWNERS-2876d73d365081dc8ed1e7cdd350cd36)
by [Unito](https://www.unito.io)
2025-10-09 12:21:49 -07:00
snomiao
fd474fe2aa refactor: Reorganize GitHub Actions for better reusability (#5949)
## Summary

This PR refactors the GitHub Actions workflow structure to improve
reusability, maintainability, and CI performance.

## Changes

### New Actions
- **setup-comfyui-server**: New composite action that handles ComfyUI
server setup and launch
  - Checks out ComfyUI repository
  - Installs ComfyUI_devtools custom node
  - Sets up Python environment and dependencies
  - Optionally launches the server with configurable parameters

### Refactored Actions
- **setup-frontend**: Simplified to focus only on frontend-specific
tasks
  - Installs pnpm and Node.js
  - Installs dependencies
- Optionally builds the frontend (can be skipped when using cached
builds)
  - No longer handles server setup or checkout

### Workflow Improvements

#### tests-ci.yaml
- Introduced a setup job that builds once and caches the entire
workspace
- Test jobs now restore the cached workspace instead of rebuilding
- Eliminated redundant setup steps in each test shard
- Better separation between setup and test execution phases
- Significant performance improvement through workspace caching

#### Locale Update Workflows
- Updated `update-locales.yaml` to use the new action structure
- Updated `update-locales-for-given-custom-node-repository.yaml` with
proper custom node installation
- Updated `update-node-definitions-locales.yaml` to use new actions
- Removed `working-directory` references where appropriate

#### Other Workflows
- Updated `update-playwright-expectations.yaml` to use new action
structure
- Consistent action usage across all workflows

## Benefits

1. **Better Performance**: Workspace caching eliminates redundant builds
in CI, significantly reducing test execution time
2. **Improved Maintainability**: Clear separation of concerns makes
actions easier to understand and modify
3. **Enhanced Reusability**: Actions can be composed in different ways
for different workflows
4. **DRY Principle**: Eliminated code duplication across workflows
5. **Easier Debugging**: Smaller, focused actions make it easier to
identify and fix issues

## Testing

- [ ] Verify tests-ci workflow runs successfully
- [ ] Verify locale update workflows function correctly
- [ ] Verify playwright expectations update workflow works
- [ ] Confirm cache/restore mechanism works as expected

## Related Issues

This refactoring addresses workflow complexity and reduces CI runtime by
leveraging GitHub Actions caching more effectively.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5949-refactor-Reorganize-GitHub-Actions-for-better-reusability-2846d73d365081ae8e16f151423b5a88)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-10-09 19:21:10 +00:00
Alexander Brown
b6b6455189 CI: Simplify update playwright expectations (maybe) (#5994)
## Summary

Follow-up to https://github.com/Comfy-Org/ComfyUI_frontend/pull/5985
See if it's possible to reduce the branching, maybe add reactions and a
Done comment?

## Changes

- **What**: Snapshot Update Updates

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5994-CI-Simplify-update-playwright-expectations-maybe-2876d73d365081eab031d8301c1360b7)
by [Unito](https://www.unito.io)
2025-10-09 11:32:38 -07:00
Simula_r
1455845a30 Fix/vue nodes snap to grid (#5973)
## Summary

Enable node snap to grid in vue nodes mirroring the same behavior as
litegraph.

- Show node snap preview (semi transparent white box target behind node)
- Resize snap to grid
- Shift + drag / Auto snap 
- Multi select + group snap

## Changes

- **What**: useNodeSnap.ts useShifyKeySync.ts setups the core hooks into
both the vue node positioning/resizing system and the event forwarding
technique for communicating to litegraph.

## Review Focus

Both new composables and specifically the useNodeLayout modifications to
batch the mutations when snapping.
A key tradeoff/note is why we are using the useShifyKeySync.ts which
dispatches a new shift event to the canvas layer. This approach is the
cleaner / more declaritive method mimicking how other vue node ->
litegraph realtime events are passed.

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5973-Fix-vue-nodes-snap-to-grid-2866d73d365081c1a058d223c8c52576)
by [Unito](https://www.unito.io)
2025-10-09 11:27:18 -07:00
Christian Byrne
6b3a4d214b [feat] Auto-remove New Browser Test Expectations label after workflow completes (#5998)
## Summary
Automatically removes the `New Browser Test Expectations` label after
the Playwright expectations update workflow completes.

## Changes
- Added a cleanup step to
`.github/workflows/update-playwright-expectations.yaml` that removes the
label using `gh pr edit --remove-label`
- Uses `if: always() && github.event_name == 'pull_request'` to ensure:
  - The label is removed even if the workflow fails
- The label is only removed when triggered by the label event (not the
`/update-playwright` comment trigger)

## Benefits
- Cleaner PR label management
- Labels can be re-applied to trigger additional expectations updates
without manual cleanup
- Consistent with the claude-review workflow pattern
- Reduces noise in the PR interface

## Context
This is part of a broader effort to automatically clean up temporary
action-triggering labels across all workflows. The first PR in this
series (#5983) added the same functionality to the claude-review
workflow.

## Test Plan
- Apply the `New Browser Test Expectations` label to a PR to verify the
workflow removes it automatically after completion

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5998-feat-Auto-remove-New-Browser-Test-Expectations-label-after-workflow-completes-2876d73d365081e29fbbe6e3127ca973)
by [Unito](https://www.unito.io)
2025-10-09 11:08:48 -07:00
Christian Byrne
06b0eecfe4 fix Vue node widgets should be in disabled state if their slots are connected with a link (#5834)
## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5692 by
making widget link connection status trigger on change so Vue widgets
with connected links could properly switch to the `disabled` state when
they are implicitly converted to inputs.

## Changes

- **What**: Added `node:slot-links:changed` event tracking and reactive
slot data synchronization for Vue widgets

```mermaid
graph TD
    A[Widget Link Change] --> B[NodeInputSlot.link setter]
    B --> C{Is Widget Input?}
    C -->|Yes| D[Trigger slot-links:changed]
    C -->|No| E[End]
    D --> F[Graph Event Handler]
    F --> G[syncNodeSlotData]
    G --> H[Update Vue Reactive Data]
    H --> I[Widget Re-render]
    
    style A fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#f9f9f9,stroke:#333,color:#000
```

## Review Focus

Widget reactivity performance with frequent link changes and event
handler memory management in graph operations.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5834-fix-Vue-node-widgets-should-be-in-disabled-state-if-their-slots-are-connected-with-a-link-27c6d73d365081f6a6c3c1ddc3905c5e)
by [Unito](https://www.unito.io)
2025-10-09 10:30:12 -07:00
105 changed files with 4008 additions and 863 deletions

View File

@@ -11,6 +11,10 @@ on:
pull_request:
types: [labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
wait-for-ci:
runs-on: ubuntu-latest

View File

@@ -4,6 +4,10 @@ on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: write

View File

@@ -7,6 +7,10 @@ on:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
setup:
runs-on: ubuntu-latest
@@ -19,12 +23,10 @@ jobs:
# Setup Test Environment, build frontend but do not start server yet
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: 'true'
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
@@ -63,6 +65,8 @@ jobs:
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
@@ -108,6 +112,8 @@ jobs:
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright

View File

@@ -27,8 +27,6 @@ jobs:
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:

View File

@@ -20,10 +20,12 @@ jobs:
# Setup playwright environment
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup ComfyUI Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: 'true'
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright

View File

@@ -18,10 +18,12 @@ jobs:
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: 'true'
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
@@ -50,4 +52,3 @@ jobs:
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
base: main
labels: dependencies
path: ComfyUI_frontend

View File

@@ -7,6 +7,10 @@ on:
issue_comment:
types: [created]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -21,22 +25,44 @@ jobs:
) &&
startsWith(github.event.comment.body, '/update-playwright') )
steps:
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: "find-update-comment"
with:
issue-number: ${{ github.event.number || github.event.issue.number }}
comment-author: "github-actions[bot]"
body-includes: "Updating Playwright Expectations"
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
body: |
Updating Playwright Expectations
edit-mode: replace
reactions: eyes
- name: Get Branch SHA
id: "get-branch"
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
env:
REPO: ${{ github.repository }}
PR_NO: ${{ github.event.number || github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Initial Checkout
uses: actions/checkout@v5
- name: Pull Request Checkout (from comment)
run: gh pr checkout ${{ github.event.issue.number }}
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Request Checkout (from label)
run: |
git fetch origin ${{ github.head_ref }}
git checkout ${{ github.head_ref }}
if: github.event_name == 'pull_request'
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
ref: ${{ steps.get-branch.outputs.branch }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests and update snapshots
@@ -52,6 +78,7 @@ jobs:
- name: Debugging info
run: |
echo "PR: ${{ github.event.issue.number }}"
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
git status
- name: Commit updated expectations
run: |
@@ -62,11 +89,20 @@ jobs:
echo "No changes to commit"
else
git commit -m "[automated] Update test expectations"
if [ "${{ github.event_name }}" = "pull_request" ]; then
git push origin HEAD:${{ github.head_ref }}
else
git push
fi
git push origin ${{ steps.get-branch.outputs.branch }}
fi
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment'
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
reactions: +1
reactions-edit-mode: replace
- name: Remove New Browser Test Expectations label
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,6 +6,10 @@ on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest

View File

@@ -54,3 +54,10 @@
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
# LLM Instructions (blank on purpose)
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md

View File

@@ -0,0 +1,90 @@
{
"id": "95ea19ba-456c-46e8-aa40-dc3ff135b746",
"revision": 0,
"last_node_id": 11,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [494.3333740234375, 142.3333282470703],
"size": [444, 399],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveInt",
"pos": [24.333343505859375, 149.6666717529297],
"size": [444, 125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [67, "randomize"]
}
],
"links": [[10, 11, 0, 10, 4, "INT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.28.6"
},
"version": 0.4
}

View File

@@ -119,4 +119,24 @@ export class VueNodeHelpers {
await this.page.waitForSelector('[data-node-id]')
}
}
/**
* Get a specific widget by node title and widget name
*/
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
return this.getNodeByTitle(nodeTitle).locator(
`_vue=[widget.name="${widgetName}"]`
)
}
/**
* Get controls for input number widgets (increment/decrement buttons and input)
*/
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').last()
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -788,4 +788,171 @@ test.describe('Vue Node Link Interaction', () => {
targetSlot: 2
})
})
test.describe('Release actions (Shift-drop)', () => {
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
})
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Open Search from the context menu
await comfyPage.clickContextMenuItem('Search')
// Search box opens with prefilled type filter based on link type (LATENT)
await expect(comfyPage.searchBox.input).toBeVisible()
const chips = comfyPage.searchBox.filterChips
// Ensure at least one filter chip exists and it matches the link type
const chipCount = await chips.count()
expect(chipCount).toBeGreaterThan(0)
await expect(chips.first()).toContainText('LATENT')
// Choose a compatible node and verify it auto-connects
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
// KSampler output should now have an outgoing link
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
// One of the VAEDecode nodes should have an incoming link on input[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
test('Search box opens on Shift-drop and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Search box should open directly
await expect(comfyPage.searchBox.input).toBeVisible()
await expect(comfyPage.searchBox.filterChips.first()).toContainText(
'LATENT'
)
// Select a compatible node and verify connection
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -49,4 +49,36 @@ test.describe('Vue Node Selection', () => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
test('should select pinned node without dragging', async ({ comfyPage }) => {
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
// Select a node by clicking its title
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
// Pin it using the hotkey (as a user would)
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
const initialPos = await checkpointNodeHeader.boundingBox()
if (!initialPos) throw new Error('Failed to get header position')
await comfyPage.dragAndDrop(
{ x: initialPos.x + 10, y: initialPos.y + 10 },
{ x: initialPos.x + 100, y: initialPos.y + 100 }
)
const finalPos = await checkpointNodeHeader.boundingBox()
if (!finalPos) throw new Error('Failed to get header position after drag')
expect(finalPos).toEqual(initialPos)
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,42 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Integer Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should be disabled and not allow changing value when link connected to slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
await comfyPage.vueNodes.getNodeByTitle('Int').click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked
await controls.incrementButton.click()
await expect(controls.input).toHaveValue((initialValue + 1).toString())
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.29.0",
"version": "1.29.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1046,6 +1046,11 @@ audio.comfy-audio.empty-audio-widget {
transition: none;
}
.isLOD .lg-node-header {
border-radius: 0px;
pointer-events: none;
}
.isLOD .lg-node-widgets {
pointer-events: none;
}

View File

@@ -113,7 +113,6 @@ 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 { 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'
@@ -401,7 +400,6 @@ onMounted(async () => {
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
attachSlotLinkPreviewRenderer(comfyApp.canvas)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false

View File

@@ -12,10 +12,23 @@ import type {
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '../../lib/litegraph/src/litegraph'
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
export interface WidgetSlotMetadata {
index: number
linked: boolean
}
export interface SafeWidgetData {
name: string
@@ -25,6 +38,8 @@ export interface SafeWidgetData {
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
}
export interface VueNodeData {
@@ -68,6 +83,37 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
const refreshNodeSlots = (nodeId: string) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef || !currentData) return
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
// Update only widgets with new slot metadata, keeping other widget data intact
const updatedWidgets = currentData.widgets?.map((widget) => {
const slotInfo = slotMetadata.get(widget.name)
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
})
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets,
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
outputs: nodeRef.outputs ? [...nodeRef.outputs] : 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
@@ -76,6 +122,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
const safeWidgets = node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
@@ -92,6 +148,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
@@ -100,7 +157,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
@@ -375,7 +434,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
) => {
return () => {
// Restore original callbacks
@@ -407,29 +466,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
// Listen for property change events from instrumented nodes
graph.onTrigger = (action: string, param: unknown) => {
if (
action === 'node:property:changed' &&
param &&
typeof param === 'object'
) {
const event = param as {
nodeId: string | number
property: string
oldValue: unknown
newValue: unknown
}
const nodeId = String(event.nodeId)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
switch (event.property) {
switch (propertyEvent.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(event.newValue)
title: String(propertyEvent.newValue)
})
break
case 'flags.collapsed':
@@ -437,7 +486,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
collapsed: Boolean(propertyEvent.newValue)
}
})
break
@@ -446,22 +495,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
flags: {
...currentData.flags,
pinned: Boolean(event.newValue)
pinned: Boolean(propertyEvent.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode: typeof event.newValue === 'number' ? event.newValue : 0
mode:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof event.newValue === 'string'
? event.newValue
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
@@ -469,40 +521,38 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof event.newValue === 'string'
? event.newValue
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
}
}
} else if (
action === 'node:slot-errors:changed' &&
param &&
typeof param === 'object'
) {
const event = param as { nodeId: string | number }
const nodeId = String(event.nodeId)
const litegraphNode = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (litegraphNode && currentData) {
// Re-extract slot data with updated hasErrors properties
vueNodeData.set(nodeId, {
...currentData,
inputs: litegraphNode.inputs
? [...litegraphNode.inputs]
: undefined,
outputs: litegraphNode.outputs
? [...litegraphNode.outputs]
: undefined
})
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
}
}
// Call original trigger handler if it exists
if (originalOnTrigger) {
originalOnTrigger(action, param)
graph.onTrigger = (event: LGraphTriggerEvent) => {
switch (event.type) {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state

View File

@@ -64,15 +64,21 @@ const activeNode = computed(() => {
const activeWidgets = computed<WidgetItem[]>({
get() {
if (!activeNode.value) return []
const node = activeNode.value
if (!node) return []
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
if (id === '-1') {
const widget = node.widgets.find((w) => w.name === name)
if (!widget) return []
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
}
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const w = wNode.widgets.find((w) => w.name === name)
if (!w) return []
return [[wNode, w]]
})
const widget = wNode.widgets.find((w) => w.name === name)
if (!widget) return []
return [[wNode, widget]]
}
return proxyWidgets.value.flatMap(mapWidgets)
},
set(value: WidgetItem[]) {
const node = activeNode.value
@@ -80,9 +86,7 @@ const activeWidgets = computed<WidgetItem[]>({
console.error('Attempted to toggle widgets with no node selected')
return
}
//map back to id/name
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
proxyWidgets.value = widgets
proxyWidgets.value = value.map(widgetItemToProperty)
}
})
@@ -165,10 +169,10 @@ function showAll() {
function hideAll() {
const node = activeNode.value
if (!node) return //Not reachable
//Not great from a nesting perspective, but path is cold
//and it cleans up potential error states
proxyWidgets.value = proxyWidgets.value.filter(
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
(propertyItem) =>
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
propertyItem[0] === '-1'
)
}
function showRecommended() {
@@ -258,20 +262,16 @@ onBeforeUnmount(() => {
>
</div>
<div ref="draggableItems">
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="draggable-item w-full"
style=""
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
:is-physical="node.id === -1"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
@@ -286,17 +286,13 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.showAll') }}</a
>
</div>
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="w-full"
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
<div
v-if="recommendedWidgets.length"

View File

@@ -8,6 +8,7 @@ const props = defineProps<{
widgetName: string
isShown?: boolean
isDraggable?: boolean
isPhysical?: boolean
}>()
defineEmits<{
(e: 'toggleVisibility'): void
@@ -17,11 +18,17 @@ function classes() {
return cn(
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
: ''
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing'
)
}
function getIcon() {
return props.isPhysical
? 'icon-[lucide--link]'
: props.isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
}
</script>
<template>
<div :class="classes()">
@@ -40,7 +47,8 @@ function classes() {
<Button
size="small"
text
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
:icon="getIcon()"
:disabled="isPhysical"
severity="secondary"
@click.stop="$emit('toggleVisibility')"
/>

View File

@@ -1,5 +1,6 @@
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
@@ -75,15 +76,17 @@ const onConfigure = function (
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= []
let proxyWidgets = this.properties.proxyWidgets
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
get: () => {
return proxyWidgets
},
set: (property: string) => {
get: () =>
this.widgets.map((w) =>
isProxyWidget(w)
? [w._overlay.nodeId, w._overlay.widgetName]
: ['-1', w.name]
),
set: (property: NodeProperty) => {
const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore()
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
@@ -92,21 +95,34 @@ const onConfigure = function (
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
for (const [nodeId, widgetName] of parsed) {
const w = addProxyWidget(this, `${nodeId}`, widgetName)
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
if (nodeId === '-1') {
const widget = this.widgets.find((w) => w.name === widgetName)
return widget ? [widget] : []
}
const w = newProxyWidget(this, nodeId, widgetName)
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
}
proxyWidgets = property
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
this.widgets.push(...newWidgets)
canvasStore.canvas?.setDirty(true, true)
this._setConcreteSlots()
this.arrange()
}
})
this.properties.proxyWidgets = proxyWidgets
if (serialisedNode.properties?.proxyWidgets)
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
serialisedNode.widgets_values?.forEach((v, index) => {
if (v !== null) this.widgets[index].value = v
})
}
function addProxyWidget(
function newProxyWidget(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
@@ -130,7 +146,7 @@ function addProxyWidget(
width: undefined,
y: 0
}
return addProxyFromOverlay(subgraphNode, overlay)
return newProxyFromOverlay(subgraphNode, overlay)
}
function resolveLinkedWidget(
overlay: Overlay
@@ -141,7 +157,7 @@ function resolveLinkedWidget(
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
}
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
@@ -213,6 +229,5 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
}
const w = new Proxy(disconnectedWidget, handler)
subgraphNode.widgets.push(w)
return w
}

View File

@@ -9,13 +9,15 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
export type WidgetItem = [LGraphNode, IBaseWidget]
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
export function promoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
@@ -30,7 +32,7 @@ export function promoteWidget(
}
export function demoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {

View File

@@ -11,7 +11,10 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import {
getResourceURL,
splitFilePath
} from '@/renderer/extensions/vueNodes/widgets/utils/audioUtils'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
@@ -21,32 +24,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}
function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
async function uploadFile(
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
@@ -123,7 +100,6 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')
@@ -199,6 +175,7 @@ app.registerExtension({
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
audioUIWidget.options.canvasOnly = true
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(
@@ -273,9 +250,9 @@ app.registerExtension({
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.options.canvasOnly = true
let mediaRecorder: MediaRecorder | null = null
let isRecording = false

View File

@@ -57,6 +57,12 @@ import {
splitPositionables
} from './subgraph/subgraphUtils'
import { Alignment, LGraphEventMode } from './types/globalEnums'
import type {
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerHandler,
LGraphTriggerParam
} from './types/graphTriggers'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -68,6 +74,11 @@ import type {
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
export type {
LGraphTriggerAction,
LGraphTriggerParam
} from './types/graphTriggers'
export interface LGraphState {
lastGroupId: number
lastNodeId: number
@@ -257,7 +268,7 @@ export class LGraph
onExecuteStep?(): void
onNodeAdded?(node: LGraphNode): void
onNodeRemoved?(node: LGraphNode): void
onTrigger?(action: string, param: unknown): void
onTrigger?: LGraphTriggerHandler
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
onConnectionChange?(node: LGraphNode): void
@@ -1183,8 +1194,23 @@ export class LGraph
}
// ********** GLOBALS *****************
trigger<A extends LGraphTriggerAction>(
action: A,
param: LGraphTriggerParam<A>
): void
trigger(action: string, param: unknown): void
trigger(action: string, param: unknown) {
this.onTrigger?.(action, param)
// Convert to discriminated union format for typed handlers
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed'
])
if (validEventTypes.has(action) && param && typeof param === 'object') {
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
}
// Don't handle unknown events - just ignore them
}
/** @todo Clean up - never implemented. */

View File

@@ -3319,7 +3319,15 @@ export class LGraphCanvas
if (slot && linkConnector.isInputValidDrop(node, slot)) {
highlightInput = slot
highlightPos = node.getInputSlotPos(slot)
if (LiteGraph.vueNodesMode) {
const idx = node.inputs.indexOf(slot)
highlightPos =
idx !== -1
? getSlotPosition(node, idx, true)
: node.getInputSlotPos(slot)
} else {
highlightPos = node.getInputSlotPos(slot)
}
linkConnector.overWidget = overWidget
}
}
@@ -3331,7 +3339,9 @@ export class LGraphCanvas
const result = node.findInputByType(firstLink.fromSlot.type)
if (result) {
highlightInput = result.slot
highlightPos = node.getInputSlotPos(result.slot)
highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, true)
: node.getInputSlotPos(result.slot)
}
} else if (
inputId != -1 &&
@@ -3356,7 +3366,9 @@ export class LGraphCanvas
if (inputId === -1 && outputId === -1) {
const result = node.findOutputByType(firstLink.fromSlot.type)
if (result) {
highlightPos = node.getOutputPos(result.index)
highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, false)
: node.getOutputPos(result.index)
}
} else {
// check if I have a slot below de mouse
@@ -4696,7 +4708,9 @@ export class LGraphCanvas
// draw nodes
const { visible_nodes } = this
const drawSnapGuides = this.#snapToGrid && this.isDragging
const drawSnapGuides =
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const node of visible_nodes) {
ctx.save()
@@ -5728,7 +5742,9 @@ export class LGraphCanvas
if (!node) continue
const startPos = firstReroute.pos
const endPos = node.getInputPos(link.target_slot)
const endPos: Point = LiteGraph.vueNodesMode
? getSlotPosition(node, link.target_slot, true)
: node.getInputPos(link.target_slot)
const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
@@ -5747,7 +5763,9 @@ export class LGraphCanvas
const node = graph.getNodeById(link.origin_id)
if (!node) continue
const startPos = node.getOutputPos(link.origin_slot)
const startPos: Point = LiteGraph.vueNodesMode
? getSlotPosition(node, link.origin_slot, false)
: node.getOutputPos(link.origin_slot)
const endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir
@@ -6074,7 +6092,9 @@ export class LGraphCanvas
ctx.save()
ctx.globalAlpha = 0.5 * this.editor_alpha
const drawSnapGuides = this.#snapToGrid && this.isDragging
const drawSnapGuides =
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const group of groups) {
// out of the visible area

View File

@@ -2851,7 +2851,17 @@ export class LGraphNode
output.links ??= []
output.links.push(link.id)
// connect in input
inputNode.inputs[inputIndex].link = link.id
const targetInput = inputNode.inputs[inputIndex]
targetInput.link = link.id
if (targetInput.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: inputNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
@@ -3008,6 +3018,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3044,6 +3063,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
@@ -3113,6 +3141,15 @@ export class LGraphNode
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
// remove other side
const link_info = graph._links.get(link_id)
@@ -3289,11 +3326,14 @@ export class LGraphNode
* Gets the position of an output slot, in graph co-ordinates.
*
* This method is preferred over the legacy {@link getConnectionPos} method.
* @param slot Output slot index
* @param outputSlotIndex Output slot index
* @returns Position of the output slot
*/
getOutputPos(slot: number): Point {
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
getOutputPos(outputSlotIndex: number): Point {
return calculateOutputSlotPos(
this.#getSlotPositionContext(),
outputSlotIndex
)
}
/** @inheritdoc */

View File

@@ -102,7 +102,12 @@ export type {
Positionable,
Size
} from './interfaces'
export { LGraph } from './LGraph'
export {
LGraph,
type LGraphTriggerAction,
type LGraphTriggerParam
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'
export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'

View File

@@ -0,0 +1,38 @@
import type { NodeId } from '../LGraphNode'
import type { NodeSlotType } from './globalEnums'
interface NodePropertyChangedEvent {
type: 'node:property:changed'
nodeId: NodeId
property: string
oldValue: unknown
newValue: unknown
}
interface NodeSlotErrorsChangedEvent {
type: 'node:slot-errors:changed'
nodeId: NodeId
}
interface NodeSlotLinksChangedEvent {
type: 'node:slot-links:changed'
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
export type LGraphTriggerEvent =
| NodePropertyChangedEvent
| NodeSlotErrorsChangedEvent
| NodeSlotLinksChangedEvent
export type LGraphTriggerAction = LGraphTriggerEvent['type']
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
LGraphTriggerEvent,
{ type: A }
>
export type LGraphTriggerHandler = (event: LGraphTriggerEvent) => void

View File

@@ -79,6 +79,7 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IAudioRecordWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
type: 'audiorecord'
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'

View File

@@ -31,6 +31,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
"insert": "Insert",
@@ -182,7 +183,17 @@
"nodeHeaderError": "Node Header Error",
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error",
"frameNodes": "Frame Nodes"
"frameNodes": "Frame Nodes",
"listening": "Listening...",
"ready": "Ready",
"playRecording": "Play Recording",
"playing": "Playing",
"stopPlayback": "Stop Playback",
"playbackSpeed": "Playback Speed",
"volume": "Volume",
"halfSpeed": "0.5x",
"1x": "1x",
"2x": "2x"
},
"manager": {
"title": "Custom Nodes Manager",

View File

@@ -0,0 +1,74 @@
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type {
CanvasPointerEvent,
CanvasPointerExtensions
} from '@/lib/litegraph/src/types/events'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
type PointerOffsets = {
x: number
y: number
}
const pointerHistory = new Map<number, PointerOffsets>()
const defineEnhancements = (
event: PointerEvent,
enhancement: CanvasPointerExtensions
) => {
Object.defineProperties(event, {
canvasX: { value: enhancement.canvasX, configurable: true, writable: true },
canvasY: { value: enhancement.canvasY, configurable: true, writable: true },
deltaX: { value: enhancement.deltaX, configurable: true, writable: true },
deltaY: { value: enhancement.deltaY, configurable: true, writable: true },
safeOffsetX: {
value: enhancement.safeOffsetX,
configurable: true,
writable: true
},
safeOffsetY: {
value: enhancement.safeOffsetY,
configurable: true,
writable: true
}
})
}
const createEnhancement = (event: PointerEvent): CanvasPointerExtensions => {
const conversion = useSharedCanvasPositionConversion()
conversion.update()
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
event.clientX,
event.clientY
])
const canvas = useCanvasStore().getCanvas()
const { offset, scale } = canvas.ds
const [originClientX, originClientY] = conversion.canvasPosToClientPos([0, 0])
const left = originClientX - offset[0] * scale
const top = originClientY - offset[1] * scale
const safeOffsetX = event.clientX - left
const safeOffsetY = event.clientY - top
const previous = pointerHistory.get(event.pointerId)
const deltaX = previous ? safeOffsetX - previous.x : 0
const deltaY = previous ? safeOffsetY - previous.y : 0
pointerHistory.set(event.pointerId, { x: safeOffsetX, y: safeOffsetY })
return { canvasX, canvasY, deltaX, deltaY, safeOffsetX, safeOffsetY }
}
export const toCanvasPointerEvent = <T extends PointerEvent>(
event: T
): T & CanvasPointerEvent => {
const enhancement = createEnhancement(event)
defineEnhancements(event, enhancement)
return event as T & CanvasPointerEvent
}
export const clearCanvasPointerHistory = (pointerId: number) => {
pointerHistory.delete(pointerId)
}

View File

@@ -1,9 +1,9 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { app } from '@/scripts/app'
// Keep one adapter per graph so rendering and interaction share state.
@@ -17,16 +17,11 @@ const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
* - Preserves existing Vue composable behavior.
*/
export class LinkConnectorAdapter {
readonly linkConnector: LinkConnector
constructor(
/** Network the links belong to (typically `app.canvas.graph`). */
readonly network: LGraph
) {
// No-op legacy setter to avoid side effects when connectors update
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
this.linkConnector = new LinkConnector(setConnectingLinks)
}
readonly network: LGraph,
readonly linkConnector: LinkConnector
) {}
/**
* The currently rendered/dragged links, typed for consumer use.
@@ -133,6 +128,11 @@ export class LinkConnectorAdapter {
this.linkConnector.disconnectLinks()
}
/** Drops moving links onto the canvas (no target). */
dropOnCanvas(event: CanvasPointerEvent): void {
this.linkConnector.dropOnNothing(event)
}
/** Resets connector state and clears any temporary flags. */
reset(): void {
this.linkConnector.reset()
@@ -141,11 +141,12 @@ export class LinkConnectorAdapter {
/** Convenience creator using the current app canvas graph. */
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph as LGraph | undefined
if (!graph) return null
const graph = app.canvas?.graph
const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
let adapter = adapterByGraph.get(graph)
if (!adapter) {
adapter = new LinkConnectorAdapter(graph)
if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter)
}
return adapter

View File

@@ -0,0 +1,117 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
interface DropResolutionContext {
adapter: LinkConnectorAdapter | null
graph: LGraph | null
session: SlotLinkDragContext
}
export const resolveSlotTargetCandidate = (
target: EventTarget | null,
{ adapter, graph }: DropResolutionContext
): SlotDropCandidate | null => {
const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const candidate: SlotDropCandidate = { layout, compatible: false }
if (adapter && graph) {
const cached = dragState.compatible.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const nodeId: NodeId = layout.nodeId
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(nodeId, layout.index)
: adapter.isOutputValidDrop(nodeId, layout.index)
setCompatibleForKey(key, compatible)
candidate.compatible = compatible
}
}
return candidate
}
export const resolveNodeSurfaceSlotCandidate = (
target: EventTarget | null,
{ adapter, graph, session }: DropResolutionContext
): SlotDropCandidate | null => {
const { setCompatibleForKey } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdAttr = elWithNode?.dataset['nodeId']
if (!nodeIdAttr) return null
if (!adapter || !graph) return null
const nodeId: NodeId = nodeIdAttr
const cachedPreferredSlotForNode = session.preferredSlotForNode.get(nodeId)
if (cachedPreferredSlotForNode !== undefined) {
return cachedPreferredSlotForNode
? { layout: cachedPreferredSlotForNode.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const result = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = result?.index
if (index == null) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
setCompatibleForKey(key, compatible)
if (!compatible) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const preferred = { index, key, layout }
session.preferredSlotForNode.set(nodeId, preferred)
return { layout, compatible: true }
}

View File

@@ -5,6 +5,14 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point, SlotLayout } from '@/renderer/core/layout/types'
/**
* Slot link drag UI state
*
* Reactive, shared state for a single drag interaction that UI components subscribe to.
* Tracks pointer position, source slot, and resolved drop candidate. Also exposes
* a compatibility map used to dim incompatible slots during drag.
*/
type SlotDragType = 'input' | 'output'
interface SlotDragSource {
@@ -33,6 +41,7 @@ interface SlotDragState {
source: SlotDragSource | null
pointer: PointerPosition
candidate: SlotDropCandidate | null
compatible: Map<string, boolean>
}
const state = reactive<SlotDragState>({
@@ -43,7 +52,8 @@ const state = reactive<SlotDragState>({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
},
candidate: null
candidate: null,
compatible: new Map<string, boolean>()
})
function updatePointerPosition(
@@ -67,6 +77,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) {
state.source = source
state.pointerId = pointerId
state.candidate = null
state.compatible.clear()
}
function endDrag() {
@@ -78,6 +89,7 @@ function endDrag() {
state.pointer.canvas.x = 0
state.pointer.canvas.y = 0
state.candidate = null
state.compatible.clear()
}
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
@@ -85,13 +97,21 @@ function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
return layoutStore.getSlotLayout(slotKey)
}
export function useSlotLinkDragState() {
export function useSlotLinkDragUIState() {
return {
state: readonly(state),
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
getSlotLayout
getSlotLayout,
setCompatibleMap: (entries: Iterable<[string, boolean]>) => {
state.compatible.clear()
for (const [key, value] of entries) state.compatible.set(key, value)
},
setCompatibleForKey: (key: string, value: boolean) => {
state.compatible.set(key, value)
},
clearCompatible: () => state.compatible.clear()
}
}

View File

@@ -1,110 +0,0 @@
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
.link_type_colors,
disabledPattern: canvas._pattern
}
}
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas)
const patched = (
ctx: CanvasRenderingContext2D,
area: LGraphCanvas['visible_area']
) => {
originalOnDrawForeground?.(ctx, area)
const { state } = useSlotLinkDragState()
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
if (canvas.linkConnector?.isConnecting) return
if (!state.active || !state.source) return
const { pointer } = state
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const renderLinks = createLinkConnectorAdapter()?.renderLinks
if (!renderLinks || renderLinks.length === 0) return
const to: Readonly<Point> = state.candidate?.compatible
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
: [pointer.canvas.x, pointer.canvas.y]
ctx.save()
for (const link of renderLinks) {
const startDir = link.fromDirection ?? LinkDirection.RIGHT
const endDir = link.dragDirection ?? LinkDirection.CENTER
const colour = resolveConnectingLinkColor(link.fromSlot.type)
const fromPoint = resolveRenderLinkOrigin(link)
linkRenderer.renderDraggingLink(
ctx,
fromPoint,
to,
colour,
startDir,
endDir,
context
)
}
ctx.restore()
}
canvas.onDrawForeground = patched
}
function resolveRenderLinkOrigin(link: RenderLink): Readonly<Point> {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) {
return [rerouteLayout.position.x, rerouteLayout.position.y]
}
const [x, y] = link.fromReroute.pos
return [x, y]
}
const nodeId = getRenderLinkNodeId(link)
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) {
return [layout.position.x, layout.position.y]
}
}
return link.fromPos
}
function getRenderLinkNodeId(link: RenderLink): number | null {
const node = link.node
if (typeof node === 'object' && node !== null && 'id' in node) {
const maybeId = node.id
if (typeof maybeId === 'number') return maybeId
}
return null
}

View File

@@ -12,6 +12,7 @@ import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
@@ -577,6 +578,14 @@ class LayoutStoreImpl implements LayoutStore {
return this.rerouteLayouts.get(rerouteId) || null
}
/**
* Returns all slot layout keys currently tracked by the store.
* Useful for global passes without relying on spatial queries.
*/
getAllSlotKeys(): string[] {
return Array.from(this.slotLayouts.keys())
}
/**
* Update link segment layout data
*/
@@ -863,6 +872,12 @@ class LayoutStoreImpl implements LayoutStore {
case 'deleteNode':
this.handleDeleteNode(operation as DeleteNodeOperation, change)
break
case 'batchUpdateBounds':
this.handleBatchUpdateBounds(
operation as BatchUpdateBoundsOperation,
change
)
break
case 'createLink':
this.handleCreateLink(operation as CreateLinkOperation, change)
break
@@ -1091,6 +1106,38 @@ class LayoutStoreImpl implements LayoutStore {
change.nodeIds.push(operation.nodeId)
}
private handleBatchUpdateBounds(
operation: BatchUpdateBoundsOperation,
change: LayoutChange
): void {
const spatialUpdates: Array<{ nodeId: NodeId; bounds: Bounds }> = []
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(nodeId)
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
ynode.set('size', {
width: data.bounds.width,
height: data.bounds.height
})
ynode.set('bounds', data.bounds)
spatialUpdates.push({ nodeId, bounds: data.bounds })
change.nodeIds.push(nodeId)
}
// Batch update spatial index for better performance
if (spatialUpdates.length > 0) {
this.spatialIndex.batchUpdate(spatialUpdates)
}
if (change.nodeIds.length) {
change.type = 'update'
}
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
@@ -1371,19 +1418,38 @@ class LayoutStoreImpl implements LayoutStore {
const originalSource = this.currentSource
this.currentSource = LayoutSource.Vue
this.ydoc.transact(() => {
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
const nodeIds: NodeId[] = []
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
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 })
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
boundsRecord[nodeId] = {
bounds,
previousBounds: currentLayout.bounds
}
}, this.currentActor)
nodeIds.push(nodeId)
}
if (!nodeIds.length) {
this.currentSource = originalSource
return
}
const operation: BatchUpdateBoundsOperation = {
type: 'batchUpdateBounds',
entity: 'node',
nodeIds,
bounds: boundsRecord,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
}
this.applyOperation(operation)
// Restore original source
this.currentSource = originalSource
}
}

View File

@@ -267,6 +267,11 @@ export function useLinkLayoutSync() {
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'batchUpdateBounds':
for (const nodeId of change.operation.nodeIds) {
recomputeLinksForNode(parseInt(nodeId))
}
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break

View File

@@ -95,19 +95,19 @@ export function useSlotLayoutSync() {
}
}
graph.onTrigger = (action: string, param: any) => {
graph.onTrigger = (event) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
event.type === 'node:property:changed' &&
event.property === 'flags.collapsed'
) {
const node = graph.getNodeById(parseInt(String(param.nodeId)))
const node = graph.getNodeById(parseInt(String(event.nodeId)))
if (node) {
computeAndRegisterSlots(node)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
// Chain to original handler
origTrigger?.(event)
}
graph.onAfterChange = (graph: any, node?: any) => {

View File

@@ -122,7 +122,7 @@ type OperationType =
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
| 'batchUpdateBounds'
| 'createLink'
| 'deleteLink'
| 'createReroute'
@@ -184,10 +184,11 @@ interface SetNodeVisibilityOperation extends NodeOpBase {
/**
* Batch update operation for atomic multi-property changes
*/
interface BatchUpdateOperation extends NodeOpBase {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
export interface BatchUpdateBoundsOperation extends OperationMeta {
entity: 'node'
type: 'batchUpdateBounds'
nodeIds: NodeId[]
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
}
/**
@@ -244,7 +245,7 @@ export type LayoutOperation =
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
| BatchUpdateBoundsOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
@@ -309,6 +310,9 @@ export interface LayoutStore {
getSlotLayout(key: string): SlotLayout | null
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
// Returns all slot layout keys currently tracked by the store
getAllSlotKeys(): string[]
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void

View File

@@ -55,6 +55,17 @@ export class SpatialIndexManager {
this.invalidateCache()
}
/**
* Batch update multiple nodes' bounds in the spatial index
* More efficient than calling update() multiple times as it only invalidates cache once
*/
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
for (const { nodeId, bounds } of updates) {
this.quadTree.update(nodeId, bounds)
}
this.invalidateCache()
}
/**
* Remove a node from the spatial index
*/

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex flex-col items-center"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@@ -12,12 +12,12 @@
>
<!-- Video Wrapper -->
<div
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="videoError"
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
>
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
@@ -27,17 +27,13 @@
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="h-[352px] w-full"
border-radius="5px"
/>
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<!-- Main Video -->
<video
v-else
:src="currentVideoUrl"
class="block h-[352px] w-full object-contain"
class="block size-full object-contain"
controls
loop
playsinline

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview group relative flex flex-col items-center"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
data-capture-node="true"
tabindex="0"
role="region"
@@ -12,12 +12,12 @@
>
<!-- Image Wrapper -->
<div
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="imageError"
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
>
<i class="mb-2 icon-[lucide--image-off] h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
@@ -27,18 +27,14 @@
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="h-[352px] w-full"
border-radius="5px"
/>
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<!-- Main Image -->
<img
v-else
:src="currentImageUrl"
:alt="imageAltText"
class="block h-[352px] w-full object-contain"
class="block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>

View File

@@ -31,6 +31,8 @@ import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -103,6 +105,15 @@ const slotColor = computed(() => {
return getSlotColor(props.slotData.type)
})
const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, true)
)
const shouldDim = computed(() => {
if (!dragState.active) return false
return !dragState.compatible.get(slotKey.value)
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
@@ -112,7 +123,8 @@ const slotWrapperClass = computed(() =>
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value
}
)
)

View File

@@ -9,7 +9,7 @@
:class="
cn(
'bg-node-component-surface',
'lg-node absolute rounded-2xl touch-none',
'lg-node absolute rounded-2xl touch-none flex flex-col',
'border-1 border-solid border-node-component-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
@@ -88,7 +88,7 @@
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-col gap-4 pb-4"
class="flex min-h-0 flex-1 flex-col gap-4 pb-4"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
@@ -98,18 +98,12 @@
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:media="nodeMedia"
/>
<!-- Live preview image -->
<div v-if="shouldShowPreviewImg" class="px-4">
<img
:src="latestPreviewUrl"
alt="preview"
class="max-h-64 w-full object-contain"
/>
<div v-if="hasCustomContent" class="min-h-0 flex-1">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
<!-- Live mid-execution preview images -->
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
<LivePreview :image-url="latestPreviewUrl || null" />
</div>
</div>
</template>
@@ -124,6 +118,7 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
@@ -153,6 +148,8 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../composables/useNodeResize'
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
@@ -211,6 +208,7 @@ const hasAnyError = computed((): boolean => {
)
})
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
const bypassed = computed((): boolean => nodeData.mode === 4)
const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode
@@ -245,7 +243,7 @@ onErrorCaptured((error) => {
})
// Use layout system for node position and dragging
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
() => nodeData,
handleNodeSelect
@@ -267,13 +265,19 @@ const handleContextMenu = (event: MouseEvent) => {
}
onMounted(() => {
if (size.value && transformState?.camera) {
const scale = transformState.camera.z
const screenSize = {
width: size.value.width * scale,
height: size.value.height * scale
}
resize(screenSize)
// Set initial DOM size from layout store, but respect intrinsic content minimum
if (size.value && nodeContainerRef.value && transformState) {
const intrinsicMin = calculateIntrinsicSize(
nodeContainerRef.value,
transformState.camera.z
)
// Use the larger of stored size or intrinsic minimum
const finalWidth = Math.max(size.value.width, intrinsicMin.width)
const finalHeight = Math.max(size.value.height, intrinsicMin.height)
nodeContainerRef.value.style.width = `${finalWidth}px`
nodeContainerRef.value.style.height = `${finalHeight}px`
}
})
@@ -290,8 +294,12 @@ const { startResize } = useNodeResize(
}
)
// Track collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
whenever(isCollapsed, () => {
const element = nodeContainerRef.value
if (!element) return
element.style.width = ''
element.style.height = ''
})
// Check if node has custom content (like image/video outputs)
const hasCustomContent = computed(() => {
@@ -395,5 +403,5 @@ const nodeMedia = computed(() => {
return { type, urls } as const
})
const nodeContainerRef = ref()
const nodeContainerRef = ref<HTMLDivElement>()
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div v-if="imageUrl" class="flex h-full min-h-16 w-full min-w-16 flex-col">
<!-- Image Container -->
<div
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
>
<!-- Error State -->
<div
v-if="imageError"
class="flex h-full w-full flex-col items-center justify-center text-center text-pure-white"
>
<i-lucide:image-off class="mb-1 size-8 text-gray-500" />
<p class="text-xs text-gray-400">{{ $t('g.imageFailedToLoad') }}</p>
</div>
<!-- Main Image -->
<img
v-else
:src="imageUrl"
:alt="$t('g.liveSamplingPreview')"
class="pointer-events-none h-full w-full object-contain object-center"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<!-- Image Dimensions -->
<div class="text-node-component-header-text mt-1 text-center text-xs">
{{
imageError
? $t('g.errorLoadingImage')
: actualDimensions || $t('g.calculatingDimensions')
}}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface LivePreviewProps {
/** Image URL to display */
imageUrl: string | null
}
const props = defineProps<LivePreviewProps>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
watch(
() => props.imageUrl,
() => {
// Reset states when URL changes
actualDimensions.value = null
imageError.value = false
}
)
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleImageError = () => {
imageError.value = true
actualDimensions.value = null
}
</script>

View File

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ $t('Node Content Error') }}
</div>
<div v-else class="lg-node-content">
<div v-else class="lg-node-content flex h-full flex-col">
<!-- Default slot for custom content -->
<slot>
<VideoPreview

View File

@@ -33,7 +33,7 @@
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getWidgetInputIndex(widget)"
:index="widget.slotMetadata?.index ?? 0"
:dot-only="true"
/>
</div>
@@ -56,12 +56,13 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
// Import widget components directly
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
@@ -110,6 +111,7 @@ interface ProcessedWidget {
value: WidgetValue
updateHandler: (value: unknown) => void
tooltipConfig: any
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
@@ -119,24 +121,42 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const result: ProcessedWidget[] = []
for (const widget of widgets) {
// Skip if widget is in the hidden list for this node type
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText
const vueComponent =
getComponent(widget.type, widget.name) ||
(widget.isDOMWidget ? WidgetDOM : WidgetInputText)
const slotMetadata = widget.slotMetadata
let widgetOptions = widget.options
// Core feature: Disable Vue widgets when their input slots are connected
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) {
widgetOptions = widget.options
? { ...widget.options, disabled: true }
: { disabled: true }
}
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value: widget.value,
label: widget.label,
options: widget.options,
options: widgetOptions,
callback: widget.callback,
spec: widget.spec
}
const updateHandler = (value: unknown) => {
// Update the widget value directly
widget.value = value as WidgetValue
if (widget.callback) {
widget.callback(value)
}
@@ -152,25 +172,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
simplified,
value: widget.value,
updateHandler,
tooltipConfig
tooltipConfig,
slotMetadata
})
}
return result
})
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
// or restructuring data model to unify widgets and inputs
// Map a widget to its corresponding input slot index
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
const inputs = nodeData?.inputs
if (!inputs) return 0
const idx = inputs.findIndex((input: any) => {
if (!input || typeof input !== 'object') return false
if (!('name' in input && 'type' in input)) return false
return 'widget' in input && input.widget?.name === widget.name
})
return idx >= 0 ? idx : 0
}
</script>

View File

@@ -28,6 +28,8 @@ import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -73,6 +75,15 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, false)
)
const shouldDim = computed(() => {
if (!dragState.active) return false
return !dragState.compatible.get(slotKey.value)
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
@@ -82,7 +93,8 @@ const slotWrapperClass = computed(() =>
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value
}
)
)

View File

@@ -0,0 +1,59 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SlotLayout } from '@/renderer/core/layout/types'
/**
* Slot link drag context
*
* Non-reactive, per-drag ephemeral caches and RAF batching used during
* link drag interactions. Keeps high-churn data out of the reactive UI state.
*/
interface PendingPointerMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
export interface SlotLinkDragContext {
preferredSlotForNode: Map<
NodeId,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: NodeId | null
lastCandidateKey: string | null
pendingPointerMove: PendingPointerMoveData | null
lastPointerEventTarget: EventTarget | null
lastPointerTargetSlotKey: string | null
lastPointerTargetNodeId: NodeId | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragContext(): SlotLinkDragContext {
const state: SlotLinkDragContext = {
preferredSlotForNode: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotKey: null,
lastPointerTargetNodeId: null,
reset: () => {
state.preferredSlotForNode = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingPointerMove = null
state.lastPointerEventTarget = null
state.lastPointerTargetSlotKey = null
state.lastPointerTargetNodeId = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -1,45 +0,0 @@
import type { SlotLayout } from '@/renderer/core/layout/types'
interface PendingMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
interface SlotLinkDragSession {
compatCache: Map<string, boolean>
nodePreferred: Map<
number,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: number | null
lastCandidateKey: string | null
pendingMove: PendingMoveData | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragSession(): SlotLinkDragSession {
const state: SlotLinkDragSession = {
compatCache: new Map(),
nodePreferred: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingMove: null,
reset: () => {
state.compatCache = new Map()
state.nodePreferred = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingMove = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -15,6 +15,24 @@ 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'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Check if multiple nodes are selected
* Optimized to return early when 2+ nodes found
*/
function hasMultipleNodesSelected(selectedItems: unknown[]): boolean {
let count = 0
for (let i = 0; i < selectedItems.length; i++) {
if (isLGraphNode(selectedItems[i])) {
count++
if (count >= 2) {
return true
}
}
}
return false
}
function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore()
@@ -26,11 +44,7 @@ function useNodeEventHandlersIndividual() {
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
*/
const handleNodeSelect = (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => {
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
@@ -48,12 +62,14 @@ function useNodeEventHandlersIndividual() {
canvasStore.canvas.select(node)
}
} else {
// If it wasn't a drag: single-select the node
if (!wasDragging) {
const selectedMultipleNodes = hasMultipleNodesSelected(
canvasStore.selectedItems
)
if (!selectedMultipleNodes) {
// Single-select the node
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Regular click -> single select
}
// Bring node to front when clicked (similar to LiteGraph behavior)
@@ -122,7 +138,7 @@ function useNodeEventHandlersIndividual() {
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData, false)
handleNodeSelect(event, nodeData)
}
}
@@ -143,7 +159,7 @@ function useNodeEventHandlersIndividual() {
// Select the node if not already selected
if (!node.selected) {
handleNodeSelect(event, nodeData, false)
handleNodeSelect(event, nodeData)
}
// Let LiteGraph handle the context menu
@@ -170,7 +186,7 @@ function useNodeEventHandlersIndividual() {
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData, false)
handleNodeSelect(syntheticEvent, nodeData)
}
// Set drag data for potential drop operations

View File

@@ -69,125 +69,85 @@ const createMouseEvent = (
}
describe('useNodePointerInteractions', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
forwardEventToCanvasMock.mockClear()
// Reset layout store state between tests
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
layoutStore.isDraggingVueNodes.value = false
})
it('should only start drag on left-click', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
mockOnNodeSelect
)
// Right-click should not start drag
// Right-click should not trigger selection
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
pointerHandlers.onPointerdown(rightClickEvent)
expect(mockOnPointerUp).not.toHaveBeenCalled()
expect(mockOnNodeSelect).not.toHaveBeenCalled()
// Left-click should start drag and emit callback
// Left-click should trigger selection on pointer down
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
pointerHandlers.onPointerdown(leftClickEvent)
const pointerUpEvent = createPointerEvent('pointerup')
pointerHandlers.onPointerup(pointerUpEvent)
expect(mockOnPointerUp).toHaveBeenCalledWith(
pointerUpEvent,
mockNodeData,
false // wasDragging = false (same position)
)
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData)
})
it('forwards middle mouse interactions to the canvas', () => {
it('should call onNodeSelect on pointer down', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
mockOnNodeSelect
)
const middlePointerDown = createPointerEvent('pointerdown', { button: 1 })
pointerHandlers.onPointerdown(middlePointerDown)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerDown)
forwardEventToCanvasMock.mockClear()
const middlePointerMove = createPointerEvent('pointermove', { buttons: 4 })
pointerHandlers.onPointermove(middlePointerMove)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerMove)
forwardEventToCanvasMock.mockClear()
const middlePointerUp = createPointerEvent('pointerup', { button: 1 })
pointerHandlers.onPointerup(middlePointerUp)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerUp)
expect(mockOnPointerUp).not.toHaveBeenCalled()
})
it('should distinguish drag from click based on distance threshold', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
)
// Test drag (distance > 4px)
pointerHandlers.onPointerdown(
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
)
const dragUpEvent = createPointerEvent('pointerup', {
clientX: 200,
clientY: 200
// Selection should happen on pointer down
const downEvent = createPointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerup(dragUpEvent)
pointerHandlers.onPointerdown(downEvent)
expect(mockOnPointerUp).toHaveBeenCalledWith(
dragUpEvent,
mockNodeData,
true
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
mockOnNodeSelect.mockClear()
// Even if we drag, selection already happened on pointer down
pointerHandlers.onPointerup(
createPointerEvent('pointerup', { clientX: 200, clientY: 200 })
)
mockOnPointerUp.mockClear()
// Test click (same position)
const samePos = { clientX: 100, clientY: 100 }
pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos))
const clickUpEvent = createPointerEvent('pointerup', samePos)
pointerHandlers.onPointerup(clickUpEvent)
expect(mockOnPointerUp).toHaveBeenCalledWith(
clickUpEvent,
mockNodeData,
false
)
// onNodeSelect should not be called again on pointer up
expect(mockOnNodeSelect).not.toHaveBeenCalled()
})
it('should handle drag termination via cancel and context menu', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
mockOnNodeSelect
)
// Test pointer cancel
// Test pointer cancel - selection happens on pointer down
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
// Should not emit callback on cancel
expect(mockOnPointerUp).not.toHaveBeenCalled()
// Selection should have been called on pointer down only
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
mockOnNodeSelect.mockClear()
// Test context menu during drag prevents default
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
@@ -200,36 +160,35 @@ describe('useNodePointerInteractions', () => {
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should not emit callback when nodeData becomes null', async () => {
it('should not call onNodeSelect when nodeData is null', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const mockOnNodeSelect = vi.fn()
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
const { pointerHandlers } = useNodePointerInteractions(
nodeDataRef,
mockOnPointerUp
mockOnNodeSelect
)
// Clear nodeData before pointer down
nodeDataRef.value = null
await nextTick()
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
// Clear nodeData before pointerup
nodeDataRef.value = null
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
expect(mockOnPointerUp).not.toHaveBeenCalled()
expect(mockOnNodeSelect).not.toHaveBeenCalled()
})
it('should integrate with layout store dragging state', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
mockOnNodeSelect
)
// Start drag
@@ -242,4 +201,93 @@ describe('useNodePointerInteractions', () => {
await nextTick()
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('should select node on pointer down with ctrl key for multi-select', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl key should pass the event with ctrl key set
const ctrlDownEvent = createPointerEvent('pointerdown', {
ctrlKey: true,
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(ctrlDownEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
})
it('should select pinned node on pointer down but not start drag', async () => {
const mockNodeData = createMockVueNodeData({
flags: { pinned: true }
})
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down on pinned node
const downEvent = createPointerEvent('pointerdown')
pointerHandlers.onPointerdown(downEvent)
// Should select the node
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
// But should not start dragging
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('should select node immediately when drag starts', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down should select node immediately
const downEvent = createPointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(downEvent)
// Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Dragging state should be active
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
// Move the pointer (start dragging)
pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
)
// Selection should still only have been called once (on pointer down)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// End drag
pointerHandlers.onPointerup(
createPointerEvent('pointerup', { clientX: 150, clientY: 150 })
)
// Selection should still only have been called once
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
})
})

View File

@@ -7,16 +7,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
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 | null>,
onPointerUp: (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => void
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
) {
const nodeData = computed(() => {
const value = toValue(nodeDataMaybe)
@@ -84,8 +77,11 @@ export function useNodePointerInteractions(
return
}
// Don't allow dragging if node is pinned (but still record position for selection)
// Record position for drag threshold calculation
startPosition.value = { x: event.clientX, y: event.clientY }
onNodeSelect(event, nodeData.value)
if (nodeData.value.flags?.pinned) {
return
}
@@ -147,19 +143,11 @@ export function useNodePointerInteractions(
handleDragTermination(event, 'drag end')
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
// Don't handle pointer events 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 - startPosition.value.x
const dy = event.clientY - startPosition.value.y
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
if (!nodeData?.value) return
onPointerUp(event, nodeData.value, wasDragging)
}
/**

View File

@@ -2,6 +2,9 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
interface Size {
width: number
@@ -35,6 +38,12 @@ export function useNodeResize(
const resizeStartSize = ref<Size | null>(null)
const intrinsicMinSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -42,6 +51,9 @@ export function useNodeResize(
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
// Track shift key state and sync to canvas for snap preview
const stopShiftSync = trackShiftKey(event)
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
@@ -53,29 +65,16 @@ export function useNodeResize(
if (!(nodeElement instanceof HTMLElement)) return
const rect = nodeElement.getBoundingClientRect()
// Calculate intrinsic content size once at start
const originalWidth = nodeElement.style.width
const originalHeight = nodeElement.style.height
nodeElement.style.width = 'auto'
nodeElement.style.height = 'auto'
const intrinsicRect = nodeElement.getBoundingClientRect()
// Restore original size
nodeElement.style.width = originalWidth
nodeElement.style.height = originalHeight
// Convert to canvas coordinates using transform state
const scale = transformState.camera.z
// Calculate current size in canvas coordinates
resizeStartSize.value = {
width: rect.width / scale,
height: rect.height / scale
}
intrinsicMinSize.value = {
width: intrinsicRect.width / scale,
height: intrinsicRect.height / scale
}
// Calculate intrinsic content size (minimum based on content)
intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale)
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
@@ -95,19 +94,26 @@ export function useNodeResize(
const scaledDy = dy / scale
// Apply constraints: only minimum size based on content, no maximum
const newWidth = Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
)
const newHeight = Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
const constrainedSize = {
width: Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
),
height: Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
}
// Apply snap-to-grid if shift is held or always snap is enabled
const finalSize = shouldSnap(moveEvent)
? applySnapToSize(constrainedSize)
: constrainedSize
// Get the node element to apply size directly
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
resizeCallback({ width: newWidth, height: newHeight }, nodeElement)
resizeCallback(finalSize, nodeElement)
}
}
@@ -118,6 +124,9 @@ export function useNodeResize(
resizeStartSize.value = null
intrinsicMinSize.value = null
// Stop tracking shift key state
stopShiftSync()
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()

View File

@@ -0,0 +1,73 @@
import { computed } from 'vue'
import { snapPoint } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Composable for node snap-to-grid functionality
*
* Provides reactive access to snap settings and utilities for applying
* snap-to-grid behavior to Vue nodes during drag and resize operations.
*/
export function useNodeSnap() {
const settingStore = useSettingStore()
// Reactive snap settings
const gridSize = computed(() => settingStore.get('Comfy.SnapToGrid.GridSize'))
const alwaysSnap = computed(() => settingStore.get('pysssss.SnapToGrid'))
/**
* Determines if snap-to-grid should be applied based on shift key and settings
* @param event - The pointer event to check for shift key
* @returns true if snapping should be applied
*/
function shouldSnap(event: PointerEvent): boolean {
return event.shiftKey || alwaysSnap.value
}
/**
* Applies snap-to-grid to a position
* @param position - Position object with x, y coordinates
* @returns The snapped position as a new object
*/
function applySnapToPosition(position: { x: number; y: number }): {
x: number
y: number
} {
const size = gridSize.value
if (!size) return { ...position }
const posArray: [number, number] = [position.x, position.y]
if (snapPoint(posArray, size)) {
return { x: posArray[0], y: posArray[1] }
}
return { ...position }
}
/**
* Applies snap-to-grid to a size (width/height)
* @param size - Size object with width, height
* @returns The snapped size as a new object
*/
function applySnapToSize(size: { width: number; height: number }): {
width: number
height: number
} {
const gridSizeValue = gridSize.value
if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height]
if (snapPoint(sizeArray, gridSizeValue)) {
return { width: sizeArray[0], height: sizeArray[1] }
}
return { ...size }
}
return {
gridSize,
alwaysSnap,
shouldSnap,
applySnapToPosition,
applySnapToSize
}
}

View File

@@ -0,0 +1,107 @@
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import { shallowRef } from 'vue'
import { app } from '@/scripts/app'
/**
* Composable for synchronizing shift key state from Vue nodes to LiteGraph canvas.
*
* Enables snap-to-grid preview rendering in LiteGraph during Vue node drag/resize operations
* by dispatching synthetic keyboard events to the canvas element.
*
* @returns Object containing trackShiftKey function for shift state synchronization lifecycle
*
* @example
* ```ts
* const { trackShiftKey } = useShiftKeySync()
*
* function startDrag(event: PointerEvent) {
* const stopTracking = trackShiftKey(event)
* // ... drag logic
* // Call stopTracking() on pointerup to cleanup listeners
* }
* ```
*/
export function useShiftKeySync() {
const shiftKeyState = shallowRef(false)
let canvasEl: HTMLCanvasElement | null = null
/**
* Synchronizes shift key state to LiteGraph canvas by dispatching synthetic keyboard events.
*
* Only dispatches events when shift state actually changes to minimize overhead.
* Canvas reference is lazily initialized on first sync.
*
* @param isShiftPressed - Current shift key state to synchronize
*/
function syncShiftState(isShiftPressed: boolean) {
if (isShiftPressed === shiftKeyState.value) return
// Lazy-initialize canvas reference on first use
if (!canvasEl) {
canvasEl = app.canvas?.canvas ?? null
if (!canvasEl) return // Canvas not ready yet
}
shiftKeyState.value = isShiftPressed
canvasEl.dispatchEvent(
new KeyboardEvent(isShiftPressed ? 'keydown' : 'keyup', {
key: 'Shift',
shiftKey: isShiftPressed,
bubbles: true
})
)
}
/**
* Tracks shift key state during drag/resize operations and synchronizes to canvas.
*
* Attaches window-level keyboard event listeners for the duration of the operation.
* Listeners are automatically cleaned up when the returned function is called.
*
* @param initialEvent - Initial pointer event containing shift key state at drag/resize start
* @returns Cleanup function that removes event listeners - must be called when operation ends
*
* @example
* ```ts
* function startDrag(event: PointerEvent) {
* const stopTracking = trackShiftKey(event)
*
* const handlePointerUp = () => {
* stopTracking() // Cleanup listeners
* }
* }
* ```
*/
function trackShiftKey(initialEvent: PointerEvent): () => void {
// Sync initial shift state
syncShiftState(initialEvent.shiftKey)
// Listen for shift key press/release during the operation
const handleKeyEvent = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
syncShiftState(e.shiftKey)
}
const stopKeydown = useEventListener(window, 'keydown', handleKeyEvent, {
passive: true
})
const stopKeyup = useEventListener(window, 'keyup', handleKeyEvent, {
passive: true
})
// Return cleanup function that stops both listeners
return () => {
stopKeydown()
stopKeyup()
}
}
// Cleanup on component unmount
tryOnScopeDispose(() => {
shiftKeyState.value = false
canvasEl = null
})
return { trackShiftKey }
}

View File

@@ -4,7 +4,7 @@ import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
@@ -13,15 +13,23 @@ import type {
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import {
clearCanvasPointerHistory,
toCanvasPointerEvent
} from '@/renderer/core/canvas/interaction/canvasPointerEvent'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragState'
import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
@@ -80,111 +88,54 @@ export function useSlotLinkInteraction({
index,
type
}: SlotInteractionOptions): SlotInteractionHandlers {
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
useSlotLinkDragState()
const {
state,
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
// Per-drag drag-state cache
const dragSession = createSlotLinkDragSession()
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) return rerouteLayout.position
const [x, y] = link.fromReroute.pos
return toPoint(x, y)
}
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const nodeId = link.node.id
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) return layout.position
}
const candidate: SlotDropCandidate = { layout, compatible: false }
const pos = link.fromPos
return toPoint(pos[0], pos[1])
}
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (graph && adapter) {
const cached = dragSession.compatCache.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(layout.nodeId, layout.index)
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
dragSession.compatCache.set(key, compatible)
candidate.compatible = compatible
const syncRenderLinkOrigins = () => {
if (!activeAdapter) return
for (const link of activeAdapter.renderLinks) {
const origin = resolveRenderLinkSource(link)
if (!origin) continue
const x = origin.x
const y = origin.y
if (link.fromPos[0] !== x || link.fromPos[1] !== y) {
link.fromPos[0] = x
link.fromPos[1] = y
}
}
return candidate
}
function candidateFromNodeTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdStr = elWithNode?.dataset['nodeId']
if (!nodeIdStr) return null
const adapter = ensureActiveAdapter()
const graph = app.canvas?.graph
if (!adapter || !graph) return null
const nodeId = Number(nodeIdStr)
// Cached preferred slot for this node within this drag
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
if (cachedPreferred !== undefined) {
return cachedPreferred
? { layout: cachedPreferred.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const res = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = res?.index
if (index == null) return null
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
if (compatible) {
dragSession.compatCache.set(key, true)
const preferred = { index, key, layout }
dragSession.nodePreferred.set(nodeId, preferred)
return { layout, compatible: true }
} else {
dragSession.compatCache.set(key, false)
dragSession.nodePreferred.set(nodeId, null)
return null
}
}
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
return activeAdapter
}
function hasCanConnectToReroute(
@@ -308,12 +259,16 @@ export function useSlotLinkInteraction({
}
const cleanupInteraction = () => {
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
activeAdapter?.reset()
pointerSession.clear()
endDrag()
activeAdapter = null
raf.cancel()
dragSession.dispose()
dragContext.dispose()
clearCompatible()
}
const updatePointerState = (event: PointerEvent) => {
@@ -328,9 +283,9 @@ export function useSlotLinkInteraction({
}
const processPointerMoveFrame = () => {
const data = dragSession.pendingMove
const data = dragContext.pendingPointerMove
if (!data) return
dragSession.pendingMove = null
dragContext.pendingPointerMove = null
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
data.clientX,
@@ -338,34 +293,61 @@ export function useSlotLinkInteraction({
])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
syncRenderLinkOrigins()
let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null
let hoveredNodeId: NodeId | null = null
const target = data.target
if (target instanceof HTMLElement) {
hoveredSlotKey =
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
null
if (!hoveredSlotKey) {
const nodeIdStr =
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
}
if (target === dragContext.lastPointerEventTarget) {
hoveredSlotKey = dragContext.lastPointerTargetSlotKey
hoveredNodeId = dragContext.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target.closest<HTMLElement>('[data-slot-key]')
const elWithNode = elWithSlot
? null
: target.closest<HTMLElement>('[data-node-id]')
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
hoveredNodeId = hoveredSlotKey
? null
: elWithNode?.dataset['nodeId'] ?? null
dragContext.lastPointerEventTarget = target
dragContext.lastPointerTargetSlotKey = hoveredSlotKey
dragContext.lastPointerTargetNodeId = hoveredNodeId
}
const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId
hoveredSlotKey !== dragContext.lastHoverSlotKey ||
hoveredNodeId !== dragContext.lastHoverNodeId
let candidate: SlotDropCandidate | null = state.candidate
if (hoverChanged) {
const slotCandidate = candidateFromTarget(target)
const adapter = activeAdapter
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const slotCandidate = resolveSlotTargetCandidate(target, context)
const nodeCandidate = slotCandidate
? null
: candidateFromNodeTarget(target)
: resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate ?? nodeCandidate
dragSession.lastHoverSlotKey = hoveredSlotKey
dragSession.lastHoverNodeId = hoveredNodeId
dragContext.lastHoverSlotKey = hoveredSlotKey
dragContext.lastHoverNodeId = hoveredNodeId
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!slotCandidate.compatible)
} else if (nodeCandidate) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!nodeCandidate.compatible)
}
}
const newCandidate = candidate?.compatible ? candidate : null
@@ -377,18 +359,36 @@ export function useSlotLinkInteraction({
)
: null
if (newCandidateKey !== dragSession.lastCandidateKey) {
const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey
if (candidateChanged) {
setCandidate(newCandidate)
dragSession.lastCandidateKey = newCandidateKey
dragContext.lastCandidateKey = newCandidateKey
}
app.canvas?.setDirty(true)
let snapPosChanged = false
if (activeAdapter) {
const snapX = newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
snapPosChanged =
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
if (snapPosChanged) {
activeAdapter.linkConnector.state.snapLinksPos = [snapX, snapY]
}
}
const shouldRedraw = candidateChanged || snapPosChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
}
const raf = createRafBatch(processPointerMoveFrame)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
dragSession.pendingMove = {
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,
target: event.target
@@ -402,10 +402,10 @@ export function useSlotLinkInteraction({
): boolean => {
if (!candidate?.compatible) return false
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
const adapter = activeAdapter
if (!graph || !adapter) return false
const nodeId = Number(candidate.layout.nodeId)
const nodeId: NodeId = candidate.layout.nodeId
const targetNode = graph.getNodeById(nodeId)
if (!targetNode) return false
@@ -435,7 +435,7 @@ export function useSlotLinkInteraction({
y: state.pointer.canvas.y
})
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
const adapter = activeAdapter
if (!rerouteLayout || !graph || !adapter) return false
const reroute = graph.getReroute(rerouteLayout.id)
@@ -483,43 +483,31 @@ export function useSlotLinkInteraction({
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
const canvasEvent = toCanvasPointerEvent(event)
canvasEvent.preventDefault()
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
return
}
// Prefer using the snapped candidate captured during hover for perf + consistency
const snappedCandidate = state.candidate?.compatible
? state.candidate
: null
let connected = tryConnectToCandidate(snappedCandidate)
const hasConnected = connectByPriority(canvasEvent.target, snappedCandidate)
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
if (!connected) {
const domCandidate = candidateFromTarget(event.target)
connected = tryConnectToCandidate(domCandidate)
}
if (!connected) {
const nodeCandidate = candidateFromNodeTarget(event.target)
connected = tryConnectToCandidate(nodeCandidate)
}
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !snappedCandidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
if (!hasConnected) {
activeAdapter?.dropOnCanvas(canvasEvent)
}
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
}
const handlePointerUp = (event: PointerEvent) => {
@@ -530,8 +518,37 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return
raf.flush()
toCanvasPointerEvent(event)
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
}
function connectByPriority(
target: EventTarget | null,
snappedCandidate: SlotDropCandidate | null
): boolean {
const adapter = activeAdapter
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
target,
context
)
const attemptNodeSurface = () =>
tryConnectToCandidate(nodeSurfaceSlotCandidate)
const attemptReroute = () => tryConnectViaRerouteAtPointer()
if (attemptSnapped()) return true
if (attemptDomSlot()) return true
if (attemptNodeSurface()) return true
if (attemptReroute()) return true
return false
}
const onPointerDown = (event: PointerEvent) => {
@@ -543,20 +560,21 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph
if (!canvas || !graph) return
ensureActiveAdapter()
activeAdapter = createLinkConnectorAdapter()
if (!activeAdapter) return
raf.cancel()
dragSession.reset()
dragContext.reset()
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const numericNodeId = Number(nodeId)
const localNodeId: NodeId = nodeId
const isInputSlot = type === 'input'
const isOutputSlot = type === 'output'
const resolvedNode = graph.getNodeById(numericNodeId)
const resolvedNode = graph.getNodeById(localNodeId)
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
@@ -601,19 +619,24 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
const adapter = ensureActiveAdapter()
if (adapter) {
if (activeAdapter) {
if (isOutputSlot) {
adapter.beginFromOutput(numericNodeId, index, {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
adapter.beginFromInput(numericNodeId, index, {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
}
syncRenderLinkOrigins()
const direction = existingAnchor?.direction ?? baseDirection
const startPosition = existingAnchor?.position ?? {
x: layout.position.x,
@@ -637,8 +660,16 @@ export function useSlotLinkInteraction({
pointerSession.begin(event.pointerId)
toCanvasPointerEvent(event)
updatePointerState(event)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, {
capture: true
@@ -650,7 +681,21 @@ export function useSlotLinkInteraction({
capture: true
})
)
app.canvas?.setDirty(true)
const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input'
const allKeys = layoutStore.getAllSlotKeys()
clearCompatible()
for (const key of allKeys) {
const slotLayout = layoutStore.getSlotLayout(key)
if (!slotLayout) continue
if (slotLayout.type !== targetType) continue
const idx = slotLayout.index
const ok =
targetType === 'input'
? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx)
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
}

View File

@@ -7,7 +7,9 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
/**
* Composable for individual Vue node components
@@ -21,6 +23,12 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Snap-to-grid functionality
const { shouldSnap, applySnapToPosition } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
// Get the customRef for this node (shared write access)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
@@ -50,6 +58,8 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
/**
* Start dragging the node
@@ -57,6 +67,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
function startDrag(event: PointerEvent) {
if (!layoutRef.value || !transformState) return
// Track shift key state and sync to canvas for snap preview
stopShiftSync = trackShiftKey(event)
isDragging.value = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
@@ -100,42 +113,54 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
rafId = requestAnimationFrame(() => {
rafId = null
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
if (!dragStartPos || !dragStartMouse || !transformState) return
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
})
}
/**
@@ -144,11 +169,82 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
function endDrag(event: PointerEvent) {
if (!isDragging.value) return
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event)) {
const boundsUpdates: NodeBoundsUpdate[] = []
// Snap main node
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (currentLayout) {
const currentPos = currentLayout.position
const snappedPos = applySnapToPosition({ ...currentPos })
// Only add update if position actually changed
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
boundsUpdates.push({
nodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: currentLayout.size.width,
height: currentLayout.size.height
}
})
}
}
// Also snap other selected nodes
// Capture all positions at the start to ensure consistent state
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
if (nodeLayout) {
const currentPos = { ...nodeLayout.position }
const snappedPos = applySnapToPosition(currentPos)
// Only add update if position actually changed
if (
snappedPos.x !== currentPos.x ||
snappedPos.y !== currentPos.y
) {
boundsUpdates.push({
nodeId: otherNodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: nodeLayout.size.width,
height: nodeLayout.size.height
}
})
}
}
}
}
// Apply all snap updates in a single batched transaction
if (boundsUpdates.length > 0) {
layoutStore.batchUpdateNodeBounds(boundsUpdates)
}
}
isDragging.value = false
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
// Release pointer
if (!(event.target instanceof HTMLElement)) return
event.target.releasePointerCapture(event.pointerId)
@@ -162,14 +258,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
mutations.moveNode(nodeId, position)
}
/**
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource(LayoutSource.Vue)
mutations.resizeNode(nodeId, newSize)
}
return {
// Reactive state (via customRef)
layoutRef,
@@ -182,7 +270,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { calculateIntrinsicSize } from './calculateIntrinsicSize'
describe('calculateIntrinsicSize', () => {
let element: HTMLElement
beforeEach(() => {
// Create a test element
element = document.createElement('div')
element.style.width = '200px'
element.style.height = '100px'
document.body.appendChild(element)
})
afterEach(() => {
document.body.removeChild(element)
})
it('should calculate intrinsic size and convert to canvas coordinates', () => {
// Mock getBoundingClientRect to return specific dimensions
const originalGetBoundingClientRect = element.getBoundingClientRect
element.getBoundingClientRect = () => ({
width: 300,
height: 150,
top: 0,
left: 0,
bottom: 150,
right: 300,
x: 0,
y: 0,
toJSON: () => ({})
})
const scale = 2
const result = calculateIntrinsicSize(element, scale)
// Should divide by scale to convert from screen to canvas coordinates
expect(result).toEqual({
width: 150, // 300 / 2
height: 75 // 150 / 2
})
element.getBoundingClientRect = originalGetBoundingClientRect
})
it('should restore original size after measuring', () => {
const originalWidth = element.style.width
const originalHeight = element.style.height
element.getBoundingClientRect = () => ({
width: 300,
height: 150,
top: 0,
left: 0,
bottom: 150,
right: 300,
x: 0,
y: 0,
toJSON: () => ({})
})
calculateIntrinsicSize(element, 1)
// Should restore original styles
expect(element.style.width).toBe(originalWidth)
expect(element.style.height).toBe(originalHeight)
})
it('should handle scale of 1 correctly', () => {
element.getBoundingClientRect = () => ({
width: 400,
height: 200,
top: 0,
left: 0,
bottom: 200,
right: 400,
x: 0,
y: 0,
toJSON: () => ({})
})
const result = calculateIntrinsicSize(element, 1)
expect(result).toEqual({
width: 400,
height: 200
})
})
it('should handle fractional scales', () => {
element.getBoundingClientRect = () => ({
width: 300,
height: 150,
top: 0,
left: 0,
bottom: 150,
right: 300,
x: 0,
y: 0,
toJSON: () => ({})
})
const result = calculateIntrinsicSize(element, 0.5)
expect(result).toEqual({
width: 600, // 300 / 0.5
height: 300 // 150 / 0.5
})
})
it('should temporarily set width and height to auto during measurement', () => {
let widthDuringMeasurement = ''
let heightDuringMeasurement = ''
element.getBoundingClientRect = function (this: HTMLElement) {
widthDuringMeasurement = this.style.width
heightDuringMeasurement = this.style.height
return {
width: 300,
height: 150,
top: 0,
left: 0,
bottom: 150,
right: 300,
x: 0,
y: 0,
toJSON: () => ({})
}
}
calculateIntrinsicSize(element, 1)
// During measurement, styles should be set to 'auto'
expect(widthDuringMeasurement).toBe('auto')
expect(heightDuringMeasurement).toBe('auto')
})
})

View File

@@ -0,0 +1,34 @@
/**
* Calculate the intrinsic (minimum content-based) size of a node element
*
* Temporarily sets the element to auto-size to measure its natural content dimensions,
* then converts from screen coordinates to canvas coordinates using the camera scale.
*
* @param element - The node element to measure
* @param scale - Camera zoom scale for coordinate conversion
* @returns The intrinsic minimum size in canvas coordinates
*/
export function calculateIntrinsicSize(
element: HTMLElement,
scale: number
): { width: number; height: number } {
// Store original size to restore later
const originalWidth = element.style.width
const originalHeight = element.style.height
// Temporarily set to auto to measure natural content size
element.style.width = 'auto'
element.style.height = 'auto'
const intrinsicRect = element.getBoundingClientRect()
// Restore original size
element.style.width = originalWidth
element.style.height = originalHeight
// Convert from screen coordinates to canvas coordinates
return {
width: intrinsicRect.width / scale,
height: intrinsicRect.height / scale
}
}

View File

@@ -0,0 +1,60 @@
<template>
<div class="w-full">
<WidgetSelect v-model="modelValue" :widget />
<div class="my-4">
<AudioPreviewPlayer
:audio-url="audioUrlFromWidget"
:readonly="readonly"
:hide-when-empty="isOutputNodeRef"
:show-options-button="true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isOutputNode } from '@/utils/nodeFilterUtil'
import { getAudioUrlFromPath } from '../utils/audioUtils'
import WidgetSelect from './WidgetSelect.vue'
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
nodeId: string
}>()
const modelValue = defineModel<string>('modelValue')
defineEmits<{
'update:modelValue': [value: string]
}>()
// Get litegraph node
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
// Check if this is an output node (PreviewAudio, SaveAudio, etc)
const isOutputNodeRef = computed(() => {
const node = litegraphNode.value
if (!node) return false
return isOutputNode(node)
})
const audioFilePath = computed(() => props.widget.value as string)
// Computed audio URL from widget value (for input files)
const audioUrlFromWidget = computed(() => {
const path = audioFilePath.value
if (!path) return ''
return getAudioUrlFromPath(path, 'input')
})
</script>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isDOMWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
nodeId: string
readonly?: boolean
}>()
const domEl = ref<HTMLElement>()
const { canvas } = useCanvasStore()
onMounted(() => {
if (!domEl.value) return
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
if (!node) return
const widget = node.widgets?.find((w) => w.name === props.widget.name)
if (!widget || !isDOMWidget(widget)) return
domEl.value.replaceChildren(widget.element)
})
</script>
<template>
<div ref="domEl" />
</template>

View File

@@ -90,6 +90,7 @@ const buttonTooltip = computed(() => {
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',

View File

@@ -0,0 +1,320 @@
<template>
<div class="relative">
<div class="mb-4">
<Button
class="w-[413px] border-0 bg-zinc-500/10 text-zinc-400 dark-theme:bg-charcoal-600 dark-theme:text-white"
:disabled="isRecording || readonly"
@click="handleStartRecording"
>
{{ t('g.startRecording', 'Start Recording') }}
<i-lucide:mic class="ml-1" />
</Button>
</div>
<div
v-if="isRecording || isPlaying || recordedURL"
class="flex h-14 w-[413px] items-center gap-4 rounded-lg bg-zinc-500/10 px-4 text-zinc-400 dark-theme:bg-node-component-surface dark-theme:text-white"
>
<!-- Recording Status -->
<div class="flex min-w-30 items-center gap-2">
<span class="min-w-20 text-xs">
{{
isRecording
? t('g.listening', 'Listening...')
: isPlaying
? t('g.playing', 'Playing...')
: recordedURL
? t('g.ready', 'Ready')
: ''
}}
</span>
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span>
</div>
<!-- Waveform Visualization -->
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip">
<div
v-for="(bar, index) in waveformBars"
:key="index"
class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-slate-100 transition-all duration-100"
:style="{ height: bar.height + 'px' }"
:title="`Bar ${index + 1}: ${bar.height}px`"
/>
</div>
<!-- Control Button -->
<button
v-if="isRecording"
:title="t('g.stopRecording', 'Stop Recording')"
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handleStopRecording"
>
<div class="size-2.5 rounded-sm bg-[#C02323]" />
</button>
<button
v-else-if="!isRecording && recordedURL && !isPlaying"
:title="t('g.playRecording') || 'Play Recording'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handlePlayRecording"
>
<i
class="icon-[lucide--play] size-4 text-zinc-400 dark-theme:text-white"
/>
</button>
<button
v-else-if="isPlaying"
:title="t('g.stopPlayback') || 'Stop Playback'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handleStopPlayback"
>
<i
class="icon-[lucide--square] size-4 text-zinc-400 dark-theme:text-white"
/>
</button>
</div>
<audio
v-if="recordedURL"
ref="audioRef"
:key="audioElementKey"
:src="recordedURL"
class="hidden"
@ended="playback.onPlaybackEnded"
@loadedmetadata="playback.onMetadataLoaded"
/>
<LODFallback />
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { Button } from 'primevue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '../utils/audioUtils'
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
modelValue: string
nodeId: string
}>()
// Audio element ref
const audioRef = ref<HTMLAudioElement>()
// Keep track of the last uploaded path as a backup
let lastUploadedPath = ''
// Composables
const recorder = useAudioRecorder({
onRecordingComplete: handleRecordingComplete,
onError: () => {
useToastStore().addAlert(
t('g.micPermissionDenied') || 'Microphone permission denied'
)
}
})
const waveform = useAudioWaveform({
barCount: 18,
minHeight: 4,
maxHeight: 32
})
const playback = useAudioPlayback(audioRef, {
onPlaybackEnded: handlePlaybackEnded,
onMetadataLoaded: (duration) => {
if (!isPlaying.value && !isRecording.value) {
timer.value = Math.floor(duration)
}
}
})
// Timer for recording
const timer = ref(0)
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(
() => {
timer.value += 1
},
1000,
{ immediate: false }
)
// Destructure for template access
const { isRecording, recordedURL } = recorder
const { waveformBars } = waveform
const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const { localValue, onChange } = useStringWidgetValue(
props.widget as SimplifiedWidget<string, Record<string, string>>,
props.modelValue,
emit
)
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
async function handleRecordingComplete(blob: Blob) {
try {
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
localValue.value = path
lastUploadedPath = path
onChange(path)
} catch (e) {
useToastStore().addAlert('Failed to upload recorded audio')
}
}
async function handleStartRecording() {
if (props.readonly) return
try {
await waveform.setupAudioContext()
await recorder.startRecording()
// Setup waveform visualization for recording
if (recorder.mediaRecorder.value) {
const stream = recorder.mediaRecorder.value.stream
if (stream) {
await waveform.setupRecordingVisualization(stream)
}
}
// Start timer
timer.value = 0
resumeTimer()
waveform.initWaveform()
waveform.updateWaveform(isWaveformActive)
} catch (err) {
console.error('Failed to start recording:', err)
}
}
function handleStopRecording() {
recorder.stopRecording()
pauseTimer()
waveform.stopWaveform()
}
async function handlePlayRecording() {
if (!recordedURL.value) return
// Reset timer
timer.value = 0
// Reset and setup audio element
await playback.resetAudioElement()
// Wait for audio element to be ready
await new Promise((resolve) => setTimeout(resolve, 50))
if (!audioRef.value) return
// Setup waveform visualization for playback
const setupSuccess = await waveform.setupPlaybackVisualization(audioRef.value)
if (!setupSuccess) return
// Start playback
await playback.play()
// Update waveform
waveform.initWaveform()
waveform.updateWaveform(isWaveformActive)
// Update timer from audio current time
const timerInterval = setInterval(() => {
timer.value = Math.floor(playback.getCurrentTime())
}, 100)
// Store interval for cleanup
playback.playbackTimerInterval.value = timerInterval
}
function handleStopPlayback() {
playback.stop()
handlePlaybackEnded()
}
function handlePlaybackEnded() {
waveform.stopWaveform()
// Clear playback timer interval
if (playback.playbackTimerInterval.value !== null) {
clearInterval(playback.playbackTimerInterval.value)
playback.playbackTimerInterval.value = null
}
const duration = playback.getDuration()
if (duration) {
timer.value = Math.floor(duration)
} else {
timer.value = 0
}
}
// Serialization function for workflow execution
async function serializeValue() {
if (isRecording.value && recorder.mediaRecorder.value) {
recorder.mediaRecorder.value.stop()
await new Promise((resolve, reject) => {
let attempts = 0
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
const checkRecording = () => {
if (!isRecording.value && props.modelValue) {
resolve(undefined)
} else if (++attempts >= maxAttempts) {
reject(new Error('Recording serialization timeout after 5 seconds'))
} else {
setTimeout(checkRecording, 100)
}
}
checkRecording()
})
}
return props.modelValue || lastUploadedPath || ''
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
onMounted(() => {
waveform.initWaveform()
registerWidgetSerialization()
})
onUnmounted(() => {
if (playback.playbackTimerInterval.value !== null) {
clearInterval(playback.playbackTimerInterval.value)
playback.playbackTimerInterval.value = null
}
})
</script>

View File

@@ -0,0 +1,393 @@
<template>
<div class="relative">
<div
v-if="!hidden"
:class="
cn(
'bg-zinc-500/10 dark-theme:bg-charcoal-600 box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
{ hidden: hideWhenEmpty && !hasAudio }
)
"
>
<!-- Hidden audio element -->
<audio
ref="audioRef"
@loadedmetadata="handleLoadedMetadata"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
/>
<!-- Left Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Play/Pause Button -->
<div
role="button"
:tabindex="0"
aria-label="Play/Pause"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="icon-[lucide--play] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else
class="icon-[lucide--pause] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
<!-- Time Display -->
<div
class="text-sm font-normal text-nowrap text-black dark-theme:text-white"
>
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<!-- Progress Bar -->
<div
class="relative h-0.5 flex-1 rounded-full bg-gray-300 dark-theme:bg-stone-200"
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-gray-600 transition-all dark-theme:bg-white/50"
:style="{ width: `${progressPercentage}%` }"
/>
<input
type="range"
:value="progressPercentage"
min="0"
max="100"
step="0.1"
class="absolute inset-0 w-full cursor-pointer opacity-0"
@input="handleSeek"
/>
</div>
<!-- Right Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Volume Button -->
<div
role="button"
:tabindex="0"
aria-label="Volume"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleMute"
>
<i
v-if="showVolumeTwo"
class="icon-[lucide--volume-2] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else-if="showVolumeOne"
class="icon-[lucide--volume-1] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else
class="icon-[lucide--volume-x] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
<!-- Options Button -->
<div
v-if="showOptionsButton"
ref="optionsButtonRef"
role="button"
:tabindex="0"
aria-label="More Options"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleOptionsMenu"
>
<i
class="icon-[lucide--more-vertical] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
</div>
<!-- Options Menu -->
<TieredMenu
ref="optionsMenu"
:model="menuItems"
popup
class="audio-player-menu"
pt:root:class="!bg-white dark-theme:!bg-charcoal-800 !border-sand-100 dark-theme:!border-charcoal-600"
pt:submenu:class="!bg-white dark-theme:!bg-charcoal-800"
>
<template #item="{ item }">
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
<label
class="mb-2 block text-xs text-black dark-theme:text-white"
>{{ item.label }}</label
>
<Slider
:model-value="volume * 10"
:min="0"
:max="10"
:step="1"
class="w-full"
@update:model-value="handleVolumeChange"
/>
</div>
<div
v-else
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
@click="item.onClick?.()"
>
<span class="text-black dark-theme:text-white">{{
item.label
}}</span>
<i
v-if="item.selected"
class="ml-auto icon-[lucide--check] size-4 text-black dark-theme:text-white"
/>
</div>
</template>
</TieredMenu>
</div>
<LODFallback />
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import TieredMenu from 'primevue/tieredmenu'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
import { isOutputNode } from '@/utils/nodeFilterUtil'
import { cn } from '@/utils/tailwindUtil'
import { formatTime, getResourceURL } from '../../utils/audioUtils'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
readonly?: boolean
hideWhenEmpty?: boolean
showOptionsButton?: boolean
modelValue?: string
nodeId?: string
audioUrl?: string
}>(),
{
hideWhenEmpty: true
}
)
// Refs
const audioRef = ref<HTMLAudioElement>()
const optionsMenu = ref()
const optionsButtonRef = ref<HTMLElement>()
const isPlaying = ref(false)
const isMuted = ref(false)
const volume = ref(1)
const currentTime = ref(0)
const duration = ref(0)
const hasAudio = ref(false)
const playbackRate = ref(1)
// Computed
const progressPercentage = computed(() => {
if (!duration.value || duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const showVolumeTwo = computed(() => !isMuted.value && volume.value > 0.5)
const showVolumeOne = computed(() => isMuted.value && volume.value > 0)
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
const hidden = computed(() => {
if (!litegraphNode.value) return false
// dont show if its a LoadAudio and we have nodeId
const isLoadAudio =
litegraphNode.value.constructor?.comfyClass === 'LoadAudio'
return isLoadAudio && !!props.nodeId
})
// Check if this is an output node
const isOutputNodeRef = computed(() => {
const node = litegraphNode.value
return !!node && isOutputNode(node)
})
const nodeLocatorId = computed(() => {
const node = litegraphNode.value
if (!node) return null
return getLocatorIdFromNodeData(node)
})
const nodeOutputStore = useNodeOutputStore()
// Computed audio URL from node output (for output nodes)
const audioUrlFromOutput = computed(() => {
if (!isOutputNodeRef.value || !nodeLocatorId.value) return ''
const nodeOutput = nodeOutputStore.nodeOutputs[nodeLocatorId.value]
if (!nodeOutput?.audio || nodeOutput.audio.length === 0) return ''
const audio = nodeOutput.audio[0]
if (!audio.filename) return ''
return api.apiURL(
getResourceURL(
audio.subfolder || '',
audio.filename,
audio.type || 'output'
)
)
})
// Combined audio URL (output takes precedence for output nodes)
const finalAudioUrl = computed(() => {
return audioUrlFromOutput.value || props.audioUrl || ''
})
// Playback controls
const togglePlayPause = () => {
if (!audioRef.value || !audioRef.value.src) {
return
}
if (isPlaying.value) {
audioRef.value.pause()
} else {
void audioRef.value.play()
}
isPlaying.value = !isPlaying.value
}
const toggleMute = () => {
if (audioRef.value) {
isMuted.value = !isMuted.value
audioRef.value.muted = isMuted.value
}
}
const handleSeek = (event: Event) => {
const target = event.target as HTMLInputElement
const value = parseFloat(target.value)
if (audioRef.value && duration.value > 0) {
const newTime = (value / 100) * duration.value
audioRef.value.currentTime = newTime
currentTime.value = newTime
}
}
// Audio events
const handleLoadedMetadata = () => {
if (audioRef.value) {
duration.value = audioRef.value.duration
}
}
const handleTimeUpdate = () => {
if (audioRef.value) {
currentTime.value = audioRef.value.currentTime
}
}
const handleEnded = () => {
isPlaying.value = false
currentTime.value = 0
}
// Options menu
const toggleOptionsMenu = (event: Event) => {
optionsMenu.value?.toggle(event)
}
const setPlaybackSpeed = (speed: number) => {
playbackRate.value = speed
if (audioRef.value) {
audioRef.value.playbackRate = speed
}
}
const handleVolumeChange = (value: number | number[]) => {
const numValue = Array.isArray(value) ? value[0] : value
volume.value = numValue / 10
if (audioRef.value) {
audioRef.value.volume = volume.value
if (volume.value > 0 && isMuted.value) {
isMuted.value = false
audioRef.value.muted = false
}
}
}
const menuItems = computed(() => [
{
label: t('g.playbackSpeed'),
items: [
{
label: t('g.halfSpeed'),
onClick: () => setPlaybackSpeed(0.5),
selected: playbackRate.value === 0.5
},
{
label: t('g.1x'),
onClick: () => setPlaybackSpeed(1),
selected: playbackRate.value === 1
},
{
label: t('g.2x'),
onClick: () => setPlaybackSpeed(2),
selected: playbackRate.value === 2
}
]
},
{
label: t('g.volume'),
key: 'volume'
}
])
// Load audio from URL
const loadAudioFromUrl = (url: string) => {
if (!audioRef.value) return
isPlaying.value = false
audioRef.value.pause()
audioRef.value.src = url
void audioRef.value.load()
hasAudio.value = !!url
}
// Watch for finalAudioUrl changes
watch(
finalAudioUrl,
(newUrl) => {
if (newUrl) {
void nextTick(() => {
loadAudioFromUrl(newUrl)
})
}
},
{ immediate: true }
)
// Cleanup
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.src = ''
}
})
</script>
<style scoped>
.audio-player-menu {
--p-tieredmenu-item-focus-background: rgba(255, 255, 255, 0.1);
--p-tieredmenu-item-active-background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,80 @@
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
interface AudioPlaybackOptions {
onPlaybackEnded?: () => void
onMetadataLoaded?: (duration: number) => void
}
export function useAudioPlayback(
audioRef: Ref<HTMLAudioElement | undefined>,
options: AudioPlaybackOptions = {}
) {
const isPlaying = ref(false)
const audioElementKey = ref(0)
const playbackTimerInterval = ref<ReturnType<typeof setInterval> | null>(null)
async function play() {
if (!audioRef.value) return false
try {
await audioRef.value.play()
isPlaying.value = true
return true
} catch (error) {
console.warn('Audio playback failed:', error)
isPlaying.value = false
return false
}
}
function stop() {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.currentTime = 0
}
isPlaying.value = false
if (options.onPlaybackEnded) {
options.onPlaybackEnded()
}
}
function onPlaybackEnded() {
isPlaying.value = false
if (options.onPlaybackEnded) {
options.onPlaybackEnded()
}
}
function onMetadataLoaded() {
if (audioRef.value?.duration && options.onMetadataLoaded) {
options.onMetadataLoaded(audioRef.value.duration)
}
}
async function resetAudioElement() {
audioElementKey.value += 1
await nextTick()
}
function getCurrentTime() {
return audioRef.value?.currentTime || 0
}
function getDuration() {
return audioRef.value?.duration || 0
}
return {
isPlaying,
audioElementKey,
play,
stop,
onPlaybackEnded,
onMetadataLoaded,
resetAudioElement,
getCurrentTime,
getDuration,
playbackTimerInterval
}
}

View File

@@ -0,0 +1,107 @@
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { onUnmounted, ref } from 'vue'
import { useAudioService } from '@/services/audioService'
interface AudioRecorderOptions {
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
onError?: (error: Error) => void
}
export function useAudioRecorder(options: AudioRecorderOptions = {}) {
const isRecording = ref(false)
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
const stream = ref<MediaStream | null>(null)
const recordedURL = ref<string | null>(null)
async function startRecording() {
try {
// Clean up previous recording
if (recordedURL.value?.startsWith('blob:')) {
URL.revokeObjectURL(recordedURL.value)
}
// Initialize
audioChunks.value = []
recordedURL.value = null
// Register wav encoder and get media stream
await useAudioService().registerWavEncoder()
stream.value = await navigator.mediaDevices.getUserMedia({ audio: true })
// Create media recorder
mediaRecorder.value = new ExtendableMediaRecorder(stream.value, {
mimeType: 'audio/wav'
}) as unknown as MediaRecorder
mediaRecorder.value.ondataavailable = (e) => {
audioChunks.value.push(e.data)
}
mediaRecorder.value.onstop = async () => {
const blob = new Blob(audioChunks.value, { type: 'audio/wav' })
// Create blob URL for preview
if (recordedURL.value?.startsWith('blob:')) {
URL.revokeObjectURL(recordedURL.value)
}
recordedURL.value = URL.createObjectURL(blob)
// Notify completion
if (options.onRecordingComplete) {
await options.onRecordingComplete(blob)
}
cleanup()
}
// Start recording
mediaRecorder.value.start(100)
isRecording.value = true
} catch (err) {
if (options.onError) {
options.onError(err as Error)
}
throw err
}
}
function stopRecording() {
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
} else {
cleanup()
}
}
function cleanup() {
isRecording.value = false
if (stream.value) {
stream.value.getTracks().forEach((track) => track.stop())
stream.value = null
}
}
function dispose() {
stopRecording()
if (recordedURL.value) {
URL.revokeObjectURL(recordedURL.value)
recordedURL.value = null
}
}
onUnmounted(() => {
dispose()
})
return {
isRecording,
recordedURL,
mediaRecorder,
startRecording,
stopRecording,
dispose
}
}

View File

@@ -0,0 +1,145 @@
import { onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
interface WaveformBar {
height: number
}
interface AudioWaveformOptions {
barCount?: number
minHeight?: number
maxHeight?: number
}
export function useAudioWaveform(options: AudioWaveformOptions = {}) {
const { barCount = 18, minHeight = 4, maxHeight = 32 } = options
const waveformBars = ref<WaveformBar[]>(
Array.from({ length: barCount }, () => ({ height: 16 }))
)
const audioContext = ref<AudioContext | null>(null)
const analyser = ref<AnalyserNode | null>(null)
const dataArray = ref<Uint8Array | null>(null)
const animationId = ref<number | null>(null)
const mediaElementSource = ref<MediaElementAudioSourceNode | null>(null)
function initWaveform() {
waveformBars.value = Array.from({ length: barCount }, () => ({
height: Math.random() * (maxHeight - minHeight) + minHeight
}))
}
function updateWaveform(isActive: Ref<boolean>) {
if (!isActive.value) return
if (analyser.value && dataArray.value) {
updateWaveformFromAudio()
} else {
updateWaveformRandom()
}
animationId.value = requestAnimationFrame(() => updateWaveform(isActive))
}
function updateWaveformFromAudio() {
if (!analyser.value || !dataArray.value) return
analyser.value.getByteFrequencyData(
dataArray.value as Uint8Array<ArrayBuffer>
)
const samplesPerBar = Math.floor(dataArray.value.length / barCount)
waveformBars.value = waveformBars.value.map((_, i) => {
let sum = 0
for (let j = 0; j < samplesPerBar; j++) {
sum += dataArray.value![i * samplesPerBar + j] || 0
}
const average = sum / samplesPerBar
const normalizedHeight =
(average / 255) * (maxHeight - minHeight) + minHeight
return { height: normalizedHeight }
})
}
function updateWaveformRandom() {
waveformBars.value = waveformBars.value.map((bar) => ({
height: Math.max(
minHeight,
Math.min(maxHeight, bar.height + (Math.random() - 0.5) * 4)
)
}))
}
async function setupAudioContext() {
if (audioContext.value && audioContext.value.state !== 'closed') {
await audioContext.value.close()
}
audioContext.value = null
mediaElementSource.value = null
}
async function setupRecordingVisualization(stream: MediaStream) {
audioContext.value = new window.AudioContext()
analyser.value = audioContext.value.createAnalyser()
const source = audioContext.value.createMediaStreamSource(stream)
source.connect(analyser.value)
analyser.value.fftSize = 256
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
}
async function setupPlaybackVisualization(audioElement: HTMLAudioElement) {
if (audioContext.value && audioContext.value.state !== 'closed') {
await audioContext.value.close()
}
mediaElementSource.value = null
if (!audioElement) return false
audioContext.value = new window.AudioContext()
analyser.value = audioContext.value.createAnalyser()
mediaElementSource.value =
audioContext.value.createMediaElementSource(audioElement)
mediaElementSource.value.connect(analyser.value)
analyser.value.connect(audioContext.value.destination)
analyser.value.fftSize = 256
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
return true
}
function stopWaveform() {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
animationId.value = null
}
}
function dispose() {
stopWaveform()
if (audioContext.value && audioContext.value.state !== 'closed') {
void audioContext.value.close()
}
audioContext.value = null
mediaElementSource.value = null
}
onUnmounted(() => {
dispose()
})
return {
waveformBars,
initWaveform,
updateWaveform,
setupAudioContext,
setupRecordingVisualization,
setupPlaybackVisualization,
stopWaveform,
dispose
}
}

View File

@@ -0,0 +1,24 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
import type {
AudioRecordInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
const {
name,
default: defaultValue = '',
options = {}
} = inputSpec as AudioRecordInputSpec
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
serialize: true,
...options
}) as IAudioRecordWidget
return widget
}
}

View File

@@ -3,6 +3,9 @@
*/
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import WidgetAudioUI from '../components/WidgetAudioUI.vue'
import WidgetButton from '../components/WidgetButton.vue'
import WidgetChart from '../components/WidgetChart.vue'
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
@@ -13,11 +16,13 @@ import WidgetInputNumber from '../components/WidgetInputNumber.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetRecordAudio from '../components/WidgetRecordAudio.vue'
import WidgetSelect from '../components/WidgetSelect.vue'
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
import WidgetTextarea from '../components/WidgetTextarea.vue'
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
import AudioPreviewPlayer from '../components/audio/AudioPreviewPlayer.vue'
interface WidgetDefinition {
component: Component
@@ -108,9 +113,29 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
[
'markdown',
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
],
[
'audiorecord',
{
component: WidgetRecordAudio,
aliases: ['AUDIO_RECORD', 'AUDIORECORD'],
essential: false
}
],
[
'audioUI',
{
component: AudioPreviewPlayer,
aliases: ['AUDIOUI', 'AUDIO_UI'],
essential: false
}
]
]
const getComboWidgetAdditions = (): Map<string, Component> => {
return new Map([['audio', WidgetAudioUI]])
}
// Build lookup maps
const widgets = new Map<string, WidgetDefinition>()
const aliasMap = new Map<string, string>()
@@ -125,7 +150,13 @@ for (const [type, def] of coreWidgetDefinitions) {
// Utility functions
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
export const getComponent = (type: string): Component | null => {
export const getComponent = (type: string, name: string): Component | null => {
if (type == 'combo') {
const comboAdditions = getComboWidgetAdditions()
if (comboAdditions.has(name)) {
return comboAdditions.get(name) || null
}
}
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.component || null
}
@@ -140,11 +171,9 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
}): boolean => {
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
if (widget.options?.canvasOnly) return false
if (widget.isDOMWidget) return true
if (!widget.type) return false
return isSupported(widget.type)
}

View File

@@ -0,0 +1,54 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
/**
* Format time in MM:SS format
*/
export function formatTime(seconds: number): string {
if (isNaN(seconds) || seconds === 0) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* Get full audio URL from path
*/
export function getAudioUrlFromPath(
path: string,
type: ResultItemType = 'input'
): string {
const [subfolder, filename] = splitFilePath(path)
return api.apiURL(getResourceURL(subfolder, filename, type))
}
function getRandParam() {
return '&rand=' + Math.random()
}
export function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
export function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}

View File

@@ -152,6 +152,13 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
.optional()
})
const zAudioRecordInputSpec = zBaseInputOptions.extend({
type: z.literal('AUDIORECORD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -167,6 +174,7 @@ const zInputSpec = z.union([
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
zAudioRecordInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
@@ -222,6 +230,7 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>

View File

@@ -7,6 +7,7 @@ import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { st, t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import {
LGraph,
LGraphCanvas,
@@ -1667,6 +1668,28 @@ export class ComfyApp {
useExtensionService().registerExtension(extension)
}
/**
* Collects context menu items from all extensions for canvas menus
* @param canvas The canvas instance
* @returns Array of context menu items from all extensions
*/
collectCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] {
return useExtensionService()
.invokeExtensions('getCanvasMenuItems', canvas)
.flat() as IContextMenuValue[]
}
/**
* Collects context menu items from all extensions for node menus
* @param node The node being right-clicked
* @returns Array of context menu items from all extensions
*/
collectNodeMenuItems(node: LGraphNode): IContextMenuValue[] {
return useExtensionService()
.invokeExtensions('getNodeMenuItems', node)
.flat() as IContextMenuValue[]
}
/**
* Refresh combo list on whole nodes
*/

View File

@@ -6,6 +6,7 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -304,5 +305,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
}

View File

@@ -1,5 +1,8 @@
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
Positionable
} from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/schemas/keyBindingSchema'
@@ -106,6 +109,20 @@ export interface ComfyExtension {
*/
getSelectionToolboxCommands?(selectedItem: Positionable): string[]
/**
* Allows the extension to add context menu items to canvas right-click menus
* @param canvas The canvas instance
* @returns An array of context menu items to add
*/
getCanvasMenuItems?(canvas: LGraphCanvas): IContextMenuValue[]
/**
* Allows the extension to add context menu items to node right-click menus
* @param node The node being right-clicked
* @returns An array of context menu items to add
*/
getNodeMenuItems?(node: LGraphNode): IContextMenuValue[]
/**
* Allows the extension to add additional handling to the node before it is registered with **LGraph**
* @param nodeType The node class (not an instance)

View File

@@ -0,0 +1,200 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useExtensionService } from '@/services/extensionService'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
describe('Context Menu Extension API', () => {
let mockCanvas: LGraphCanvas
let mockNode: LGraphNode
let extensionStore: ReturnType<typeof useExtensionStore>
let extensionService: ReturnType<typeof useExtensionService>
// Mock menu items
const canvasMenuItem1: IContextMenuValue = {
content: 'Canvas Item 1',
callback: () => {}
}
const canvasMenuItem2: IContextMenuValue = {
content: 'Canvas Item 2',
callback: () => {}
}
const nodeMenuItem1: IContextMenuValue = {
content: 'Node Item 1',
callback: () => {}
}
const nodeMenuItem2: IContextMenuValue = {
content: 'Node Item 2',
callback: () => {}
}
// Mock extensions
const createCanvasMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getCanvasMenuItems: () => items
})
const createNodeMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getNodeMenuItems: () => items
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
extensionStore = useExtensionStore()
extensionService = useExtensionService()
mockCanvas = {
graph_mouse: [100, 100],
selectedItems: new Set()
} as unknown as LGraphCanvas
mockNode = {
id: 1,
type: 'TestNode',
pos: [0, 0]
} as unknown as LGraphNode
})
describe('collectCanvasMenuItems', () => {
it('should call getCanvasMenuItems and collect into flat array', () => {
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
const ext2 = createCanvasMenuExtension('Extension 2', [
canvasMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat() as IContextMenuValue[]
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
expect(items[2]).toMatchObject({ content: 'Item 3' })
})
it('should support submenus and separators', () => {
const extension = createCanvasMenuExtension('Test Extension', [
{
content: 'Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Submenu Item 1', callback: () => {} },
{ content: 'Submenu Item 2', callback: () => {} }
]
}
},
null as unknown as IContextMenuValue,
{ content: 'After Separator', callback: () => {} }
])
extensionStore.registerExtension(extension)
const items = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat() as IContextMenuValue[]
expect(items).toHaveLength(3)
expect(items[0].content).toBe('Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
expect(items[1]).toBeNull()
expect(items[2].content).toBe('After Separator')
})
it('should skip extensions without getCanvasMenuItems', () => {
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
canvasMenuItem1
])
const extensionWithoutCanvasMenu: ComfyExtension = {
name: 'No Canvas Menu'
}
extensionStore.registerExtension(canvasExtension)
extensionStore.registerExtension(extensionWithoutCanvasMenu)
const items = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat() as IContextMenuValue[]
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Canvas Item 1')
})
})
describe('collectNodeMenuItems', () => {
it('should call getNodeMenuItems and collect into flat array', () => {
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
const ext2 = createNodeMenuExtension('Extension 2', [
nodeMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat() as IContextMenuValue[]
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
})
it('should support submenus', () => {
const extension = createNodeMenuExtension('Submenu Extension', [
{
content: 'Node Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Node Submenu 1', callback: () => {} },
{ content: 'Node Submenu 2', callback: () => {} }
]
}
}
])
extensionStore.registerExtension(extension)
const items = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat() as IContextMenuValue[]
expect(items[0].content).toBe('Node Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
})
it('should skip extensions without getNodeMenuItems', () => {
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
const extensionWithoutNodeMenu: ComfyExtension = {
name: 'No Node Menu'
}
extensionStore.registerExtension(nodeExtension)
extensionStore.registerExtension(extensionWithoutNodeMenu)
const items = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat() as IContextMenuValue[]
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Node Item 1')
})
})
})

View File

@@ -283,6 +283,7 @@ LGraph {
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
"revision": 0,
"runningtime": 0,
"starttime": 0,

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
@@ -165,10 +165,11 @@ describe('layoutStore CRDT operations', () => {
actor: layoutStore.getCurrentActor()
})
// Wait for async notification
await new Promise((resolve) => setTimeout(resolve, 50))
// Wait for onChange callback to be called (uses setTimeout internally)
await vi.waitFor(() => {
expect(changes.length).toBeGreaterThanOrEqual(1)
})
expect(changes.length).toBeGreaterThanOrEqual(1)
const lastChange = changes[changes.length - 1]
expect(lastChange.source).toBe('vue')
expect(lastChange.operation.actor).toBe('user-123')
@@ -176,6 +177,48 @@ describe('layoutStore CRDT operations', () => {
unsubscribe()
})
it('should emit change when batch updating node bounds', async () => {
const nodeId = 'test-node-6'
const layout = createTestNode(nodeId)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
const changes: LayoutChange[] = []
const unsubscribe = layoutStore.onChange((change) => {
changes.push(change)
})
const newBounds = { x: 40, y: 60, width: 220, height: 120 }
layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }])
// Wait for onChange callback to be called (uses setTimeout internally)
await vi.waitFor(() => {
expect(changes.length).toBeGreaterThan(0)
const lastChange = changes[changes.length - 1]
expect(lastChange.operation.type).toBe('batchUpdateBounds')
})
const lastChange = changes[changes.length - 1]
if (lastChange.operation.type === 'batchUpdateBounds') {
expect(lastChange.nodeIds).toContain(nodeId)
expect(lastChange.operation.bounds[nodeId]?.bounds).toEqual(newBounds)
}
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.position).toEqual({ x: 40, y: 60 })
expect(nodeRef.value?.size).toEqual({ width: 220, height: 120 })
unsubscribe()
})
it('should query nodes by spatial bounds', () => {
const nodes = [
{ id: 'node-a', position: { x: 0, y: 0 } },

View File

@@ -8,7 +8,6 @@ import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
const mockData = vi.hoisted(() => ({
@@ -205,18 +204,4 @@ describe('LGraphNode', () => {
expect(wrapper.classes()).toContain('animate-pulse')
})
it('should emit node-click event on pointer up', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerup')
expect(handleNodeSelect).toHaveBeenCalledOnce()
expect(handleNodeSelect).toHaveBeenCalledWith(
expect.any(PointerEvent),
mockNodeData,
expect.any(Boolean)
)
})
})

View File

@@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
liveSamplingPreview: 'Live sampling preview',
imageFailedToLoad: 'Image failed to load',
errorLoadingImage: 'Error loading image',
calculatingDimensions: 'Calculating dimensions'
}
}
}
})
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
}
const mountLivePreview = (props = {}) => {
return mount(LivePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:image-off': true
}
}
})
}
it('renders preview when imageUrl provided', () => {
const wrapper = mountLivePreview()
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(defaultProps.imageUrl)
})
it('does not render when no imageUrl provided', () => {
const wrapper = mountLivePreview({ imageUrl: null })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountLivePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('has proper accessibility attributes', () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Live sampling preview')
})
it('handles image load event', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Mock the naturalWidth and naturalHeight properties on the img element
Object.defineProperty(img.element, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img.element, 'naturalHeight', {
writable: false,
value: 512
})
// Trigger the load event
await img.trigger('load')
expect(wrapper.text()).toContain('512 x 512')
})
it('handles image error state', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger the error event
await img.trigger('error')
// Check that the image is hidden and error content is shown
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
})
it('resets state when imageUrl changes', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Set error state via event
await img.trigger('error')
expect(wrapper.text()).toContain('Error loading image')
// Change imageUrl prop
await wrapper.setProps({ imageUrl: '/new-image.png' })
await nextTick()
// State should be reset - dimensions text should show calculating
expect(wrapper.text()).toContain('Calculating dimensions')
expect(wrapper.text()).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger error event
await img.trigger('error')
// Should show error state instead of image
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
expect(wrapper.text()).toContain('Error loading image')
})
})

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