Compare commits

..

17 Commits

Author SHA1 Message Date
Benjamin Lu
799ab5e5aa Refactor slotkey from bespoke string key 2025-10-10 12:58:25 -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
90 changed files with 3764 additions and 925 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: 95 KiB

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -67,8 +66,11 @@ function slotLocator(
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
const type = isInput ? 'input' : 'output'
const id = String(nodeId)
return page.locator(
`[data-node-id="${id}"][data-slot-type="${type}"][data-slot-index="${slotIndex}"]`
)
}
async function expectVisibleAll(...locators: Locator[]) {
@@ -788,4 +790,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: 47 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: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 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: 73 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)
})
})

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: 68 KiB

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

@@ -15,7 +15,19 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
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 +37,7 @@ export interface SafeWidgetData {
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
}
export interface VueNodeData {
@@ -68,6 +81,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 +120,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 +146,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 +155,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec
spec,
slotMetadata: slotInfo
}
} catch (error) {
return {
@@ -375,7 +431,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 +463,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 +483,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
collapsed: Boolean(propertyEvent.newValue)
}
})
break
@@ -446,22 +492,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 +518,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

@@ -182,7 +182,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,132 @@
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 { 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 { setCompatibleFor, getCompatible } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithSlot = target.closest<HTMLElement>(
'[data-node-id][data-slot-type][data-slot-index]'
)
const nodeId = elWithSlot?.dataset['nodeId']
const typeAttr = elWithSlot?.dataset['slotType'] as
| 'input'
| 'output'
| undefined
const indexAttr = elWithSlot?.dataset['slotIndex']
if (!nodeId || !typeAttr || indexAttr == null) return null
const index = Number.parseInt(indexAttr, 10)
if (!Number.isFinite(index)) return null
const layout = layoutStore.getSlotLayoutBy(nodeId, typeAttr, index)
if (!layout) return null
const candidate: SlotDropCandidate = { layout, compatible: false }
if (adapter && graph) {
const cached = getCompatible(nodeId, layout.type, layout.index)
if (cached != null) {
candidate.compatible = cached
} else {
const layoutNodeId: NodeId = layout.nodeId
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(layoutNodeId, layout.index)
: adapter.isOutputValidDrop(layoutNodeId, layout.index)
setCompatibleFor(layoutNodeId, layout.type, layout.index, compatible)
candidate.compatible = compatible
}
}
return candidate
}
export const resolveNodeSurfaceSlotCandidate = (
target: EventTarget | null,
{ adapter, graph, session }: DropResolutionContext
): SlotDropCandidate | null => {
const { setCompatibleFor } = 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 layout = layoutStore.getSlotLayoutBy(
String(nodeId),
isInput ? 'input' : 'output',
index
)
if (!layout) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
setCompatibleFor(layout.nodeId, layout.type, layout.index, compatible)
if (!compatible) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const preferred = {
index,
identity: { nodeId: layout.nodeId, type: layout.type, index },
layout
}
session.preferredSlotForNode.set(nodeId, preferred)
return { layout, compatible: true }
}

View File

@@ -1,10 +1,17 @@
import { reactive, readonly } from 'vue'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
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 +40,10 @@ interface SlotDragState {
source: SlotDragSource | null
pointer: PointerPosition
candidate: SlotDropCandidate | null
compatible: Map<
string,
{ input: Map<number, boolean>; output: Map<number, boolean> }
>
}
const state = reactive<SlotDragState>({
@@ -43,7 +54,8 @@ const state = reactive<SlotDragState>({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
},
candidate: null
candidate: null,
compatible: new Map()
})
function updatePointerPosition(
@@ -67,6 +79,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) {
state.source = source
state.pointerId = pointerId
state.candidate = null
state.compatible.clear()
}
function endDrag() {
@@ -78,20 +91,61 @@ 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) {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
return layoutStore.getSlotLayout(slotKey)
return layoutStore.getSlotLayoutBy(
nodeId,
isInput ? 'input' : 'output',
slotIndex
)
}
export function useSlotLinkDragState() {
function ensureCompatibleNode(nodeId: string) {
let entry = state.compatible.get(nodeId)
if (!entry) {
entry = {
input: reactive(new Map<number, boolean>()),
output: reactive(new Map<number, boolean>())
}
state.compatible.set(nodeId, entry)
}
return entry
}
function getCompatible(
nodeId: string,
type: 'input' | 'output',
index: number
): boolean | undefined {
const nodeEntry = state.compatible.get(nodeId)
if (!nodeEntry) return undefined
const map = type === 'input' ? nodeEntry.input : nodeEntry.output
return map.get(index)
}
function setCompatibleFor(
nodeId: string,
type: 'input' | 'output',
index: number,
value: boolean
) {
const nodeEntry = ensureCompatibleNode(nodeId)
const map = type === 'input' ? nodeEntry.input : nodeEntry.output
map.set(index, value)
}
export function useSlotLinkDragUIState() {
return {
state: readonly(state),
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
getSlotLayout
getSlotLayout,
getCompatible,
setCompatibleFor,
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

@@ -13,7 +13,6 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
export interface SlotPositionContext {
@@ -139,8 +138,11 @@ export function getSlotPosition(
isInput: boolean
): Point {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)
const slotLayout = layoutStore.getSlotLayoutBy(
String(node.id),
isInput ? 'input' : 'output',
slotIndex
)
if (slotLayout) {
return [slotLayout.position.x, slotLayout.position.y]
}

View File

@@ -15,8 +15,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
/**
* Register slot layout with the layout store for hit testing
* @param nodeId The node ID
@@ -30,8 +28,6 @@ function registerSlotLayout(
isInput: boolean,
position: Point
): void {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Calculate bounds for the slot using LiteGraph's standard slot height
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
const halfSize = slotSize / 2
@@ -49,7 +45,12 @@ function registerSlotLayout(
}
}
layoutStore.updateSlotLayout(slotKey, slotLayout)
layoutStore.updateSlotLayoutBy(
nodeId,
isInput ? 'input' : 'output',
slotIndex,
slotLayout
)
}
/**

View File

@@ -1,40 +0,0 @@
/**
* Slot identifier utilities for consistent slot key generation and parsing
*
* Provides a centralized interface for slot identification across the layout system
*
* @TODO Replace this concatenated string with root cause fix
*/
interface SlotIdentifier {
nodeId: string
index: number
isInput: boolean
}
/**
* Generate a unique key for a slot
* Format: "{nodeId}-{in|out}-{index}"
*/
export function getSlotKey(identifier: SlotIdentifier): string
export function getSlotKey(
nodeId: string,
index: number,
isInput: boolean
): string
export function getSlotKey(
nodeIdOrIdentifier: string | SlotIdentifier,
index?: number,
isInput?: boolean
): string {
if (typeof nodeIdOrIdentifier === 'object') {
const { nodeId, index, isInput } = nodeIdOrIdentifier
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
}
if (index === undefined || isInput === undefined) {
throw new Error('Missing required parameters for slot key generation')
}
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
}

View File

@@ -35,6 +35,7 @@ import type {
RerouteLayout,
ResizeNodeOperation,
SetNodeZIndexOperation,
SlotIdentity,
SlotLayout
} from '@/renderer/core/layout/types'
import {
@@ -124,7 +125,11 @@ class LayoutStoreImpl implements LayoutStore {
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
private linkSegmentLayouts = new Map<string, LinkSegmentLayout>() // Internal string key: ${linkId}:${rerouteId ?? 'final'}
private slotLayouts = new Map<string, SlotLayout>()
private slotLayoutsByNode = new Map<
NodeId,
{ input: Map<number, SlotLayout>; output: Map<number, SlotLayout> }
>()
private slotIndexKeyToIdentity = new Map<string, SlotIdentity>()
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// Spatial index managers
@@ -431,66 +436,82 @@ class LayoutStoreImpl implements LayoutStore {
}
}
/**
* Update slot layout data
*/
updateSlotLayout(key: string, layout: SlotLayout): void {
const existing = this.slotLayouts.get(key)
private makeSlotIndexKey(
nodeId: NodeId,
type: 'input' | 'output',
index: number
): string {
return `${nodeId}::${type}::${index}`
}
private getOrCreateNodeSlotMaps(nodeId: NodeId) {
let entry = this.slotLayoutsByNode.get(nodeId)
if (!entry) {
entry = { input: new Map(), output: new Map() }
this.slotLayoutsByNode.set(nodeId, entry)
}
return entry
}
updateSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number,
layout: SlotLayout
): void {
const indexKey = this.makeSlotIndexKey(nodeId, type, index)
const nodeMaps = this.getOrCreateNodeSlotMaps(nodeId)
const map = type === 'input' ? nodeMaps.input : nodeMaps.output
const existing = map.get(index)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
return
}
// Update spatial index
this.slotSpatialIndex.update(key, layout.bounds)
this.slotSpatialIndex.update(indexKey, layout.bounds)
} else {
// Insert into spatial index
this.slotSpatialIndex.insert(key, layout.bounds)
this.slotSpatialIndex.insert(indexKey, layout.bounds)
}
this.slotLayouts.set(key, layout)
map.set(index, layout)
this.slotIndexKeyToIdentity.set(indexKey, { nodeId, type, index })
}
/**
* Batch update slot layouts and spatial index in one pass
*/
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
batchUpdateSlotLayoutsBy(
updates: Array<{
nodeId: NodeId
type: 'input' | 'output'
index: number
layout: SlotLayout
}>
): void {
if (!updates.length) return
// Update spatial index and map entries (skip unchanged)
for (const { key, layout } of updates) {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
continue
}
this.slotSpatialIndex.update(key, layout.bounds)
} else {
this.slotSpatialIndex.insert(key, layout.bounds)
}
this.slotLayouts.set(key, layout)
for (const { nodeId, type, index, layout } of updates) {
this.updateSlotLayoutBy(nodeId, type, index, layout)
}
}
/**
* Delete slot layout data
*/
deleteSlotLayout(key: string): void {
const deleted = this.slotLayouts.delete(key)
deleteSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number
): void {
const indexKey = this.makeSlotIndexKey(nodeId, type, index)
const nodeMaps = this.slotLayoutsByNode.get(nodeId)
if (!nodeMaps) return
const map = type === 'input' ? nodeMaps.input : nodeMaps.output
const deleted = map.delete(index)
if (deleted) {
// Remove from spatial index
this.slotSpatialIndex.remove(key)
this.slotSpatialIndex.remove(indexKey)
this.slotIndexKeyToIdentity.delete(indexKey)
}
}
@@ -498,17 +519,21 @@ class LayoutStoreImpl implements LayoutStore {
* Delete all slot layouts for a node
*/
deleteNodeSlotLayouts(nodeId: NodeId): void {
const keysToDelete: string[] = []
for (const [key, layout] of this.slotLayouts) {
if (layout.nodeId === nodeId) {
keysToDelete.push(key)
}
const nodeMaps = this.slotLayoutsByNode.get(nodeId)
if (!nodeMaps) return
for (const [idx] of nodeMaps.input) {
const indexKey = this.makeSlotIndexKey(nodeId, 'input', idx)
this.slotSpatialIndex.remove(indexKey)
this.slotIndexKeyToIdentity.delete(indexKey)
}
for (const key of keysToDelete) {
this.slotLayouts.delete(key)
// Remove from spatial index
this.slotSpatialIndex.remove(key)
for (const [idx] of nodeMaps.output) {
const indexKey = this.makeSlotIndexKey(nodeId, 'output', idx)
this.slotSpatialIndex.remove(indexKey)
this.slotIndexKeyToIdentity.delete(indexKey)
}
nodeMaps.input.clear()
nodeMaps.output.clear()
this.slotLayoutsByNode.delete(nodeId)
}
/**
@@ -516,7 +541,8 @@ class LayoutStoreImpl implements LayoutStore {
* Used when switching rendering modes (Vue ↔ LiteGraph)
*/
clearAllSlotLayouts(): void {
this.slotLayouts.clear()
this.slotLayoutsByNode.clear()
this.slotIndexKeyToIdentity.clear()
this.slotSpatialIndex.clear()
}
@@ -563,11 +589,15 @@ class LayoutStoreImpl implements LayoutStore {
return this.linkLayouts.get(linkId) || null
}
/**
* Get slot layout data
*/
getSlotLayout(key: string): SlotLayout | null {
return this.slotLayouts.get(key) || null
getSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number
): SlotLayout | null {
const nodeMaps = this.slotLayoutsByNode.get(nodeId)
if (!nodeMaps) return null
const map = type === 'input' ? nodeMaps.input : nodeMaps.output
return map.get(index) || null
}
/**
@@ -577,6 +607,19 @@ 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.
*/
getAllSlots(): ReadonlyArray<SlotLayout> {
const result: SlotLayout[] = []
for (const [, maps] of this.slotLayoutsByNode) {
for (const [, layout] of maps.input) result.push(layout)
for (const [, layout] of maps.output) result.push(layout)
}
return result
}
/**
* Update link segment layout data
*/
@@ -732,9 +775,14 @@ class LayoutStoreImpl implements LayoutStore {
}
const candidateSlotKeys = this.slotSpatialIndex.query(searchArea)
// Check precise bounds for candidates
for (const key of candidateSlotKeys) {
const slotLayout = this.slotLayouts.get(key)
const identity = this.slotIndexKeyToIdentity.get(key)
if (!identity) continue
const slotLayout = this.getSlotLayoutBy(
identity.nodeId,
identity.type,
identity.index
)
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
return slotLayout
}
@@ -791,7 +839,7 @@ class LayoutStoreImpl implements LayoutStore {
queryItemsInBounds(bounds: Bounds): {
nodes: NodeId[]
links: LinkId[]
slots: string[]
slots: SlotIdentity[]
reroutes: RerouteId[]
} {
// Query segments and union their linkIds
@@ -804,10 +852,15 @@ class LayoutStoreImpl implements LayoutStore {
}
}
const slotIdentities: SlotIdentity[] = this.slotSpatialIndex
.query(bounds)
.map((key) => this.slotIndexKeyToIdentity.get(key))
.filter((v): v is SlotIdentity => !!v)
return {
nodes: this.queryNodesInBounds(bounds),
links: Array.from(linkIds),
slots: this.slotSpatialIndex.query(bounds),
slots: slotIdentities,
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => asRerouteId(key))
@@ -950,10 +1003,12 @@ class LayoutStoreImpl implements LayoutStore {
this.spatialIndex.clear()
this.linkSegmentSpatialIndex.clear()
this.slotSpatialIndex.clear()
// Clear slot identity maps to avoid stale entries when re-initializing
this.slotLayoutsByNode.clear()
this.slotIndexKeyToIdentity.clear()
this.rerouteSpatialIndex.clear()
this.linkLayouts.clear()
this.linkSegmentLayouts.clear()
this.slotLayouts.clear()
this.rerouteLayouts.clear()
nodes.forEach((node, index) => {

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

@@ -59,6 +59,12 @@ export interface SlotLayout {
bounds: Bounds
}
export interface SlotIdentity {
nodeId: NodeId
type: 'input' | 'output'
index: number
}
export interface LinkLayout {
id: LinkId
path: Path2D
@@ -282,7 +288,7 @@ export interface LayoutStore {
queryItemsInBounds(bounds: Bounds): {
nodes: NodeId[]
links: LinkId[]
slots: string[]
slots: SlotIdentity[]
reroutes: RerouteId[]
}
@@ -293,22 +299,38 @@ export interface LayoutStore {
rerouteId: RerouteId | null,
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): void
updateSlotLayout(key: string, layout: SlotLayout): void
updateSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number,
layout: SlotLayout
): void
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void
// Delete methods for cleanup
deleteLinkLayout(linkId: LinkId): void
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void
deleteSlotLayout(key: string): void
deleteSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number
): void
deleteNodeSlotLayouts(nodeId: NodeId): void
deleteRerouteLayout(rerouteId: RerouteId): void
clearAllSlotLayouts(): void
// Get layout data
getLinkLayout(linkId: LinkId): LinkLayout | null
getSlotLayout(key: string): SlotLayout | null
getSlotLayoutBy(
nodeId: NodeId,
type: 'input' | 'output',
index: number
): SlotLayout | null
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
// Returns all slot layouts currently tracked by the store
getAllSlots(): ReadonlyArray<SlotLayout>
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void
@@ -331,7 +353,12 @@ export interface LayoutStore {
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
batchUpdateSlotLayoutsBy(
updates: Array<{
nodeId: NodeId
type: 'input' | 'output'
index: number
layout: SlotLayout
}>
): void
}

View File

@@ -31,6 +31,7 @@ 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 { 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 +104,14 @@ const slotColor = computed(() => {
return getSlotColor(props.slotData.type)
})
const { state: dragState, getCompatible } = useSlotLinkDragUIState()
const shouldDim = computed(() => {
if (!dragState.active) return false
const nodeId = props.nodeId ?? ''
const value = getCompatible(nodeId, 'input', props.index)
return !value
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
@@ -112,7 +121,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

@@ -124,6 +124,7 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
@@ -211,6 +212,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
@@ -290,8 +292,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 +401,5 @@ const nodeMedia = computed(() => {
return { type, urls } as const
})
const nodeContainerRef = ref()
const nodeContainerRef = ref<HTMLDivElement>()
</script>

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,12 @@ 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 WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
@@ -110,6 +110,7 @@ interface ProcessedWidget {
value: WidgetValue
updateHandler: (value: unknown) => void
tooltipConfig: any
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
@@ -119,24 +120,41 @@ 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) || 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 +170,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,7 @@ 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 { 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 +74,14 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const { state: dragState, getCompatible } = useSlotLinkDragUIState()
const shouldDim = computed(() => {
if (!dragState.active) return false
const nodeId = props.nodeId ?? ''
const value = getCompatible(nodeId, 'output', props.index)
return !value
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
@@ -82,7 +91,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,63 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SlotIdentity, 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
identity: SlotIdentity
layout: SlotLayout
} | null
>
lastHoverSlotIdentity: SlotIdentity | null
lastHoverNodeId: NodeId | null
lastCandidateIdentity: SlotIdentity | null
pendingPointerMove: PendingPointerMoveData | null
lastPointerEventTarget: EventTarget | null
lastPointerTargetSlotIdentity: SlotIdentity | null
lastPointerTargetNodeId: NodeId | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragContext(): SlotLinkDragContext {
const state: SlotLinkDragContext = {
preferredSlotForNode: new Map(),
lastHoverSlotIdentity: null,
lastHoverNodeId: null,
lastCandidateIdentity: null,
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotIdentity: null,
lastPointerTargetNodeId: null,
reset: () => {
state.preferredSlotForNode = new Map()
state.lastHoverSlotIdentity = null
state.lastHoverNodeId = null
state.lastCandidateIdentity = null
state.pendingPointerMove = null
state.lastPointerEventTarget = null
state.lastPointerTargetSlotIdentity = 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,8 @@ 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'
interface Size {
width: number
@@ -35,6 +37,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 +50,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)
@@ -95,19 +106,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 +136,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

@@ -10,7 +10,7 @@ import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
// Slot keys migrated to identity attributes
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import {
@@ -50,9 +50,14 @@ export function syncNodeSlotLayoutsFromDOM(
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{ key: string; layout: SlotLayout }> = []
const batch: Array<{
nodeId: string
type: 'input' | 'output'
index: number
layout: SlotLayout
}> = []
for (const [slotKey, entry] of node.slots) {
for (const [index, entry] of node.slots.input) {
const rect = entry.el.getBoundingClientRect()
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
@@ -73,7 +78,9 @@ export function syncNodeSlotLayoutsFromDOM(
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
key: slotKey,
nodeId,
type: 'input',
index,
layout: {
nodeId,
index: entry.index,
@@ -88,7 +95,43 @@ export function syncNodeSlotLayoutsFromDOM(
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
for (const [index, entry] of node.slots.output) {
const rect = entry.el.getBoundingClientRect()
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2
]
const [x, y] = (
conv ?? useSharedCanvasPositionConversion()
).clientPosToCanvasPos(screenCenter)
const centerCanvas = { x, y }
entry.cachedOffset = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'output',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(batch)
}
function updateNodeSlotsFromCache(nodeId: string) {
@@ -98,9 +141,14 @@ function updateNodeSlotsFromCache(nodeId: string) {
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{ key: string; layout: SlotLayout }> = []
const batch: Array<{
nodeId: string
type: 'input' | 'output'
index: number
layout: SlotLayout
}> = []
for (const [slotKey, entry] of node.slots) {
for (const [index, entry] of node.slots.input) {
if (!entry.cachedOffset) {
// schedule a sync to seed offset
scheduleSlotLayoutSync(nodeId)
@@ -114,7 +162,39 @@ function updateNodeSlotsFromCache(nodeId: string) {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
key: slotKey,
nodeId,
type: 'input',
index,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
for (const [index, entry] of node.slots.output) {
if (!entry.cachedOffset) {
scheduleSlotLayoutSync(nodeId)
continue
}
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
nodeId,
type: 'output',
index,
layout: {
nodeId,
index: entry.index,
@@ -130,7 +210,7 @@ function updateNodeSlotsFromCache(nodeId: string) {
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(batch)
}
export function useSlotElementTracking(options: {
@@ -180,11 +260,14 @@ export function useSlotElementTracking(options: {
}
}
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
el.dataset.slotKey = slotKey
node.slots.set(slotKey, { el, index, type })
el.dataset.nodeId = nodeId
el.dataset.slotType = type
el.dataset.slotIndex = String(index)
if (type === 'input') {
node.slots.input.set(index, { el, index, type })
} else {
node.slots.output.set(index, { el, index, type })
}
// Seed initial sync from DOM
scheduleSlotLayoutSync(nodeId)
@@ -201,17 +284,18 @@ export function useSlotElementTracking(options: {
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, type === 'input')
const entry = node.slots.get(slotKey)
const collection = type === 'input' ? node.slots.input : node.slots.output
const entry = collection.get(index)
if (entry) {
delete entry.el.dataset.slotKey
node.slots.delete(slotKey)
delete entry.el.dataset.nodeId
delete entry.el.dataset.slotType
delete entry.el.dataset.slotIndex
collection.delete(index)
}
layoutStore.deleteSlotLayout(slotKey)
layoutStore.deleteSlotLayoutBy(nodeId, type, index)
// If node has no more slots, clean up
if (node.slots.size === 0) {
if (node.slots.input.size === 0 && node.slots.output.size === 0) {
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}

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,22 @@ 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 { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
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 { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import type { Point, SlotIdentity } 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 +87,57 @@ export function useSlotLinkInteraction({
index,
type
}: SlotInteractionOptions): SlotInteractionHandlers {
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
useSlotLinkDragState()
const {
state,
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
setCompatibleFor,
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 layout = layoutStore.getSlotLayoutBy(
String(nodeId),
isInputFrom ? 'input' : 'output',
link.fromSlotIndex
)
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(
@@ -241,8 +194,11 @@ export function useSlotLinkInteraction({
): { position: Point; direction: LinkDirection } | null => {
if (!link) return null
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
const layout = layoutStore.getSlotLayout(slotKey)
const layout = layoutStore.getSlotLayoutBy(
String(link.origin_id),
'output',
link.origin_slot
)
if (!layout) return null
return { position: { ...layout.position }, direction: LinkDirection.NONE }
@@ -308,12 +264,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 +288,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,57 +298,128 @@ export function useSlotLinkInteraction({
])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null
syncRenderLinkOrigins()
let hoveredSlotIdentity: SlotIdentity | 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) {
hoveredSlotIdentity = dragContext.lastPointerTargetSlotIdentity
hoveredNodeId = dragContext.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target.closest<HTMLElement>(
'[data-node-id][data-slot-type][data-slot-index]'
)
const elWithNode = elWithSlot
? null
: target.closest<HTMLElement>('[data-node-id]')
if (elWithSlot) {
const nodeIdAttr = elWithSlot.dataset['nodeId']
const typeAttr = elWithSlot.dataset['slotType'] as
| 'input'
| 'output'
| undefined
const indexAttr = elWithSlot.dataset['slotIndex']
if (nodeIdAttr && typeAttr && indexAttr != null) {
const indexVal = Number.parseInt(indexAttr, 10)
if (Number.isFinite(indexVal)) {
hoveredSlotIdentity = {
nodeId: nodeIdAttr,
type: typeAttr,
index: indexVal
}
}
}
}
hoveredNodeId = hoveredSlotIdentity
? null
: elWithNode?.dataset['nodeId'] ?? null
dragContext.lastPointerEventTarget = target
dragContext.lastPointerTargetSlotIdentity = hoveredSlotIdentity
dragContext.lastPointerTargetNodeId = hoveredNodeId
}
const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId
hoveredSlotIdentity?.nodeId !==
dragContext.lastHoverSlotIdentity?.nodeId ||
hoveredSlotIdentity?.type !== dragContext.lastHoverSlotIdentity?.type ||
hoveredSlotIdentity?.index !== dragContext.lastHoverSlotIdentity?.index ||
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.lastHoverSlotIdentity = hoveredSlotIdentity
dragContext.lastHoverNodeId = hoveredNodeId
if (slotCandidate) {
setCompatibleFor(
slotCandidate.layout.nodeId,
slotCandidate.layout.type,
slotCandidate.layout.index,
!!slotCandidate.compatible
)
} else if (nodeCandidate) {
setCompatibleFor(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.type,
nodeCandidate.layout.index,
!!nodeCandidate.compatible
)
}
}
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)
const newCandidateIdentity: SlotIdentity | null = newCandidate
? {
nodeId: newCandidate.layout.nodeId,
type: newCandidate.layout.type,
index: newCandidate.layout.index
}
: null
if (newCandidateKey !== dragSession.lastCandidateKey) {
const candidateChanged =
newCandidateIdentity?.nodeId !==
dragContext.lastCandidateIdentity?.nodeId ||
newCandidateIdentity?.type !== dragContext.lastCandidateIdentity?.type ||
newCandidateIdentity?.index !== dragContext.lastCandidateIdentity?.index
if (candidateChanged) {
setCandidate(newCandidate)
dragSession.lastCandidateKey = newCandidateKey
dragContext.lastCandidateIdentity = newCandidateIdentity
}
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 +433,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 +466,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 +514,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 +549,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 +591,19 @@ 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')
)
const layout = layoutStore.getSlotLayoutBy(nodeId, type, index)
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 +648,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 +689,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 +710,19 @@ export function useSlotLinkInteraction({
capture: true
})
)
app.canvas?.setDirty(true)
const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input'
const allSlots = layoutStore.getAllSlots()
clearCompatible()
for (const slotLayout of allSlots) {
if (slotLayout.type !== targetType) continue
const idx = slotLayout.index
const ok =
targetType === 'input'
? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx)
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleFor(slotLayout.nodeId, slotLayout.type, idx, 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)

View File

@@ -10,7 +10,7 @@ type SlotEntry = {
type NodeEntry = {
nodeId: string
slots: Map<string, SlotEntry>
slots: { input: Map<number, SlotEntry>; output: Map<number, SlotEntry> }
stopWatch?: () => void
}
@@ -26,7 +26,10 @@ export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
if (!node) {
node = {
nodeId,
slots: markRaw(new Map<string, SlotEntry>())
slots: markRaw({
input: new Map<number, SlotEntry>(),
output: new Map<number, SlotEntry>()
})
}
registry.set(nodeId, node)
}

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

@@ -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,7 @@
*/
import type { Component } from 'vue'
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 +14,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 +111,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 +148,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
}

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

@@ -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

@@ -102,7 +102,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(event, testNodeData, false)
handleNodeSelect(event, testNodeData)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -122,7 +122,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData, false)
handleNodeSelect(ctrlClickEvent, testNodeData)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -141,7 +141,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData, false)
handleNodeSelect(ctrlClickEvent, testNodeData)
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(canvas?.select).not.toHaveBeenCalled()
@@ -159,7 +159,7 @@ describe('useNodeEventHandlers', () => {
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeData, false)
handleNodeSelect(metaClickEvent, testNodeData)
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
@@ -171,7 +171,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
handleNodeSelect(event, testNodeData)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
@@ -184,7 +184,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
handleNodeSelect(event, testNodeData)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import WidgetAudioUI from '@/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
@@ -26,81 +27,81 @@ describe('widgetRegistry', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetInputNumber)
expect(getComponent('INT')).toBe(WidgetInputNumber)
expect(getComponent('int', 'bar')).toBe(WidgetInputNumber)
expect(getComponent('INT', 'bar')).toBe(WidgetInputNumber)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
expect(getComponent('number')).toBe(WidgetInputNumber)
expect(getComponent('slider')).toBe(WidgetInputNumber)
expect(getComponent('float', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('number', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('slider', 'cfg')).toBe(WidgetInputNumber)
})
})
// Test text type mappings
describe('text types', () => {
it('should map text variations to input text widget', () => {
expect(getComponent('text')).toBe(WidgetInputText)
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
expect(getComponent('text', 'text')).toBe(WidgetInputText)
expect(getComponent('string', 'text')).toBe(WidgetInputText)
expect(getComponent('STRING', 'text')).toBe(WidgetInputText)
})
it('should map multiline text types to textarea widget', () => {
expect(getComponent('multiline')).toBe(WidgetTextarea)
expect(getComponent('textarea')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
expect(getComponent('customtext')).toBe(WidgetTextarea)
expect(getComponent('multiline', 'text')).toBe(WidgetTextarea)
expect(getComponent('textarea', 'text')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA', 'text')).toBe(WidgetTextarea)
expect(getComponent('customtext', 'text')).toBe(WidgetTextarea)
})
it('should map markdown to markdown widget', () => {
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
expect(getComponent('markdown')).toBe(WidgetMarkdown)
expect(getComponent('MARKDOWN', 'text')).toBe(WidgetMarkdown)
expect(getComponent('markdown', 'text')).toBe(WidgetMarkdown)
})
})
// Test selection type mappings
describe('selection types', () => {
it('should map combo types to select widget', () => {
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
expect(getComponent('COMBO', 'video')).toBe(WidgetSelect)
})
})
// Test boolean type mappings
describe('boolean types', () => {
it('should map boolean types to toggle switch widget', () => {
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
expect(getComponent('toggle', 'image')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean', 'image')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN', 'image')).toBe(WidgetToggleSwitch)
})
})
// Test advanced widget mappings
describe('advanced widgets', () => {
it('should map color types to color picker widget', () => {
expect(getComponent('color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
expect(getComponent('color', 'color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker)
})
it('should map file types to file upload widget', () => {
expect(getComponent('file')).toBe(WidgetFileUpload)
expect(getComponent('fileupload')).toBe(WidgetFileUpload)
expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload)
expect(getComponent('file', 'file')).toBe(WidgetFileUpload)
expect(getComponent('fileupload', 'file')).toBe(WidgetFileUpload)
expect(getComponent('FILEUPLOAD', 'file')).toBe(WidgetFileUpload)
})
it('should map button types to button widget', () => {
expect(getComponent('button')).toBe(WidgetButton)
expect(getComponent('BUTTON')).toBe(WidgetButton)
expect(getComponent('button', '')).toBe(WidgetButton)
expect(getComponent('BUTTON', '')).toBe(WidgetButton)
})
})
// Test fallback behavior
describe('fallback behavior', () => {
it('should return null for unknown types', () => {
expect(getComponent('unknown')).toBe(null)
expect(getComponent('custom_widget')).toBe(null)
expect(getComponent('')).toBe(null)
expect(getComponent('unknown', 'unknown')).toBe(null)
expect(getComponent('custom_widget', 'custom_widget')).toBe(null)
expect(getComponent('', '')).toBe(null)
})
})
})
@@ -165,10 +166,16 @@ describe('widgetRegistry', () => {
it('should handle case sensitivity correctly through aliases', () => {
// Test that both lowercase and uppercase work
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
expect(getComponent('string', '')).toBe(WidgetInputText)
expect(getComponent('STRING', '')).toBe(WidgetInputText)
expect(getComponent('combo', '')).toBe(WidgetSelect)
expect(getComponent('COMBO', '')).toBe(WidgetSelect)
})
it('should handle combo additional widgets', () => {
// Test that both lowercase and uppercase work
expect(getComponent('combo', 'audio')).toBe(WidgetAudioUI)
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
})
})
})

View File

@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import {
type LGraphCanvas,
LGraphNode,
type SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -66,14 +64,19 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[1].name
)
})
test('Will not modify existing widgets', () => {
test('Will serialize existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
proxyWidgets.push(['1', 'istringWidget'])
subgraphNode.properties.proxyWidgets = proxyWidgets
expect(subgraphNode.widgets.length).toBe(2)
subgraphNode.properties.proxyWidgets = []
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)

View File

@@ -30,10 +30,19 @@ export default defineConfig({
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
watch: {
ignored: [
'**/coverage/**',
'**/playwright-report/**',
'./browser_tests/**',
'./node_modules/**',
'./tests-ui/**',
'.eslintcache',
'*.config.{ts,mts}',
'**/.git/**',
'**/.github/**',
'**/.nx/**',
'**/*.{test,spec}.ts',
'*.config.{ts,mts}'
'**/coverage/**',
'**/dist/**',
'**/playwright-report/**',
'**/test-results/**'
]
},
proxy: {

View File

@@ -1,3 +1,4 @@
import { vi } from 'vitest'
import 'vue'
// Define global variables for tests
@@ -7,3 +8,12 @@ globalThis.__SENTRY_DSN__ = ''
globalThis.__ALGOLIA_APP_ID__ = ''
globalThis.__ALGOLIA_API_KEY__ = ''
globalThis.__USE_PROD_CONFIG__ = false
// Mock Worker for extendable-media-recorder
globalThis.Worker = vi.fn().mockImplementation(() => ({
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))