Compare commits

...

27 Commits

Author SHA1 Message Date
Talmaj Marinc
842e3d7541 Initial commit for DynamiGroupSupport. 2026-06-25 00:14:28 +02:00
AustinMroz
c406042215 More robust drag cleanup (#13084)
Under some circumstances, (particularly with pointerCancel events) a
drag operation could end without properly being cleaned up. When this
occurs, the bugged state would manifest in comical ways
- Nodes would 'run away' from the cursor
<img width="1024" height="1024" alt="AnimateDiff_00001"
src="https://github.com/user-attachments/assets/accfeac0-ce4c-4d8a-b3b8-6b243e8d5f8d"
/>

- Resizing the window could cause the zombie drag to move into the
autopan region which would result in nodes rapidly scrolling away.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/e30629f4-ddea-4981-83d8-0037b3010ad5"
/>


This is resolved by adding more robust cleanup for canceled drag events.

This PR also cleanups a sizeable chunk of dead TransformPane code which
was unused.
2026-06-24 17:49:30 +00:00
pythongosssss
395b0a1c89 fix: prevent NullGraphError on subgraph node removal (#11804)
## Summary

Various race conditions can cause `NullGraphError` to be thrown after
removing/converting a subgraph. This fix guards at call sites and
refactors to add a pre-removal phase before the graph is nulled.

## Changes

- **What**: 
- add pre-detach event (node:before-removed) so reactive consumers can
drop references before node.graph is nulled
- move selection and Vue node-manager teardown to this event to
eliminate stale panel/render evaluations against detached nodes
- guard SubgraphNode promoted-widget paths resilient on detached access
and add regression coverage
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus
Alternative considered approach:
- Guards: Guards were treating the symptom at every caller, and new
callers may appear that won't know about this edge case. Adding a new
hook for consumers to drop refs is safer than trying to guard every call
site - the ones that are left in are safetynets and not the primary fix.
- Large scale refactor (towards ADR0008) - requires additional
scaffolding to already be in place to implement effectively, this fix
simply adds a new hook and isnt incompatible with the projects future
goals
- Defer/remove/reorder graph null - The detach was explicitly added in
#8180 to ensure GC - delaying is fragile and may not resolve the issue,
difficult to prove and may surface a new race condition
- Make rootGraph nullable - would require 100s of references to be
updated, when `NullGraphError` was added in #8180 to throw a clear
message when the graph for a removed subgraph node was referenced,
potentially leading to other harder to track bugs without the exception

Tests:

- e2e test complexity is required to prove the issue happens, patching
calls to add artificial delays. This isn't great, but I could not find a
reliable way to recreate otherwise, unless we are happy to drop e2e and
keep only unit tests.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11804-fix-prevent-NullGraphError-on-subgraph-node-removal-3536d73d3650814e9183e17067cc0992)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-06-24 14:50:02 +00:00
Alexander Brown
6068571b35 Refactor: Brand node execution and locator IDs (#13071)
## Summary

- Brand `NodeExecutionId` and `NodeLocatorId` as distinct required
string types.
- Route execution/locator ID construction through existing helper
functions instead of minting raw strings at call sites.
- Update tests and boundary parsing to use branded IDs without
conflating them with local `NodeId` values.

## Validation

- `pnpm typecheck`
- `pnpm test:unit src/types/nodeIdentification.test.ts
src/stores/executionStore.test.ts
src/renderer/extensions/vueNodes/components/NodeSlots.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/nodeReplacement/missingNodeScan.test.ts -- --runInBand`
- `pnpm exec eslint src/types/nodeIdentification.ts
src/utils/graphTraversalUtil.ts
src/platform/workflow/management/stores/workflowStore.ts
src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts
src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts
src/stores/workspace/favoritedWidgetsStore.ts
src/stores/nodeOutputStore.ts
src/utils/__tests__/executionErrorTestUtils.ts
src/platform/nodeReplacement/missingNodeScan.test.ts
src/stores/executionStore.test.ts --cache`

Note: full `pnpm lint` timed out after 5 minutes while still in
stylelint startup, so targeted lint was run on changed files.

## Open Question

- Should root-level node IDs like `1` be considered valid
`NodeExecutionId` values, or should `isNodeExecutionId()` require a
colon and callers use a separate type/helper for root execution IDs?
2026-06-24 14:35:57 +00:00
Alexander Brown
e37f168eaa Add merge_group event to CLA workflow (#13093)
## Summary

So it runs in the merge queue.
2026-06-24 08:34:29 -07:00
Rizumu Ayaka
b165b3f999 fix: focus keybindings search when opening Manage Shortcuts (FE-845) (#12709)
## Summary

Opening the Keybinding panel from the **Manage Shortcuts** button now
focuses the **Search Keybindings** field instead of the **Search
Settings** field.

## Changes

- **What**: The Settings dialog's "Search Settings" input had an
unconditional `autofocus`, so opening directly to the keybinding panel
always stole focus to the wrong field. Made it conditional
(`:autofocus="activeCategoryKey !== 'keybinding'"`) and added
`autofocus` to the keybinding panel's own search input.

## Review Focus

- `autofocus` maps to the native attribute, which only fires on DOM
insertion — flipping the reactive `:autofocus` while navigating between
categories inside the dialog will not re-steal focus, so there is no
regression for in-dialog navigation.
- Added an E2E test verified in both directions: it fails on the
original code (Search Settings focused) and passes with the fix (Search
Keybindings focused).

Fixes FE-845

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 11:05:01 +00:00
Terry Jia
d7f9754393 feat: add bounding boxes and colors widgets (CORE-292) (#12960)
## Summary
Add two reusable node widgets backed by native (non-string) values:
- Bounding boxes editor (BOUNDING_BOXES): draw, select, resize, and
label regions over an optional background image. Value is a native list
of `{ x, y, width, height, metadata }` pixel boxes; the editor works in
normalized space internally and converts at the value boundary,
rescaling when the node's width/height change.
- Colors palette (COLORS): native `string[]` of hex colors, sharing the
PaletteSwatchRow component (usePaletteSwatchRow composable).

Both reactively hide the width/height widgets while a background image
is connected by writing through the widget value store so the Vue node
re-renders.

Some design refer to KJ's node

BE: https://github.com/Comfy-Org/ComfyUI/pull/14537

Screenshot
<img width="3019" height="1470" alt="image"
src="https://github.com/user-attachments/assets/06795772-97e6-4084-9205-e370f955fb28"
/>

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 11:00:18 +00:00
Terry Jia
48a3ea0e92 feat: add HDR/EXR image viewer for SaveImageAdvanced outputs (#13049)
## Summary
Browsers cannot render EXR/HDR in <img>, so these outputs showed as
broken images. Add a full-screen three.js viewer holding a single WebGL
context created on open and released on close, opened via an 'Open in
HDR Viewer' action on EXR/HDR outputs in ImagePreview. The layout
mirrors the 3D viewer: canvas on the left, grouped controls in a
right-hand sidebar.

The display pipeline (gamut -> exposure -> linear-to-sRGB -> dither ->
clamp, plus clip warnings) is adapted from
[HDRView](https://github.com/wkjarosz/hdrview). Source gamut is
auto-detected from the EXR chromaticities attribute (Rec.709/Rec.2020)
with a manual override.

Inspection tools operate on the EXR float data kept on the CPU by
EXRLoader:
- pixel inspector: hover to read raw RGBA values and coordinates
- statistics: min/max/mean/std-dev plus NaN and Inf counts
- auto-exposure: set exposure so the max value maps to 1
- channel isolation: view R/G/B/A or luminance individually

## Screenshots (if applicable)



https://github.com/user-attachments/assets/22b80718-4b15-41ee-86b5-8fe38a6a82e2
2026-06-24 06:43:30 -04:00
Rizumu Ayaka
a8f8ba7580 fix: clamp sidebar tab labels to two lines with tooltip fallback (#12755)
## Summary

Sidebar tab labels no longer overflow the rail: they wrap up to 2 lines
max, then truncate with an ellipsis, with the full name always
recoverable via the hover tooltip (per design spec from Alex Tov in
FE-698).

## Changes

- **What**:
- Labels in `SidebarIcon.vue` now use `line-clamp-2` + `overflow-wrap:
break-word` + `whitespace-normal`, contained within the rail width minus
`--sidebar-padding` so text keeps breathing room from the rail border
(the base Button's `whitespace-nowrap` previously prevented any
wrapping, causing labels like "Input & Output" to be clipped on both
sides)
- Near-fit built-in labels ("Workflows", "Templates", "Shortcuts" —
wider than the floating-mode line) get soft hyphens (`­`) in their en
label strings, so they break cleanly as "Work-/flows" in floating mode
and render as a single unhyphenated line in connected mode (56px).
`hyphens: auto` can't do this because Chromium skips hyphenation for
capitalized words. Title/tooltip strings are untouched
- Tooltip falls back to the label when an extension registers a sidebar
tab without a tooltip, so clamped text is always recoverable on hover

## Review Focus

- Labels never bleed past the rail or get clipped by the rail's
`overflow-hidden`; long unbroken extension names (e.g.
`WASNodeSuitePreprocessors`) break mid-token across 2 lines + ellipsis,
matching the design mockup
- Soft hyphens live only in `sideToolbar.labels.*`, not in the
title/tooltip keys, so command palette / tooltip text stays clean
- No E2E regression test: the fix is pure CSS layout (line
wrapping/clamping), and per `AGENTS.md` testing guidelines we don't
write tests that depend on non-behavioral styling. The one behavioral
change (tooltip falls back to label) is covered by a unit test in
`SidebarIcon.test.ts`

Fixes
[FE-698](https://linear.app/comfyorg/issue/FE-698/bug-input-and-outputs-text-not-wrapping-in-left-sidebar)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 10:19:23 +00:00
jaeone94
966659b303 fix: bind promoted asset modals (Legacy) to host widgets (#13075)
## Summary

Bind asset-browser modal selections to the widget that actually opened
the modal, so promoted subgraph asset widgets commit through the host
promoted widget instead of the internal source widget closure.

## Changes

- **What**: Makes the asset-browser modal commit path widget-owned:
after a valid selection, `openModal` writes to the widget passed into
the modal and notifies that widget's callback.
- **What**: Captures workflow state after a successful value-changing
asset selection, because the async modal `Use` action can run after the
global mouseup-based change capture has already fired.
- **What**: Preserves existing asset-browser filtering by keeping
`nodeTypeForBrowser` and `inputNameForBrowser` captured in the asset
widget's existing modal options closure.
- **What**: Avoids adding promoted-widget-specific rebinding code to
`litegraphService` and avoids changing LiteGraph core widget option
types.
- **What**: Only runs the source widget's `onValueChange` callback when
the selected widget is the original owner widget created by
`createAssetWidget`.
- **What**: For cloned/transient host widgets, such as promoted subgraph
asset widgets, dispatches `onWidgetChanged` through the widget's owning
node instead of the internal source node.
- **What**: Removes the duplicate PrimitiveNode callback dispatch
because the asset modal commit path now centrally notifies the selected
widget callback.
- **What**: Adds stable asset-browser `data-testid`s and a cloud E2E
regression for legacy promoted subgraph asset selection.
- **What**: Adds unit coverage for both regular asset widget commits and
cloned promoted-host asset modal commits, including workflow change
capture.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR supersedes #13074. The earlier direction treated the bug as a
missing callback bridge in the async asset-browser commit path, but the
ownership issue is more specific: promoted subgraph asset widgets reuse
modal options that were created from the deepest concrete source widget.
Those options still need to carry source metadata for filtering the
asset browser, but the modal's `Use` action must commit to the widget
that actually opened the modal.

This matters after the History ADR 0009 subgraph widget changes shipped
through #12197. In the 1.46 subgraph model, promoted widget values live
on the subgraph host node and are not synchronized back into the
internal widget. The internal source widget remains useful as the
provider of asset-browser metadata, because `SubgraphNode` already
resolves nested promotions down to the final concrete widget, but it
should not own the edit commit.

The final patch keeps that boundary narrow:

- no `IWidgetOptions` or LiteGraph core type changes;
- no asset-specific promoted-widget rebinding in `litegraphService`;
- no new promoted-widget traversal logic, because the existing subgraph
promotion path already resolves the final concrete source widget;
- the modal commit path uses the widget passed to `openModal` as the
value owner;
- successful async modal commits explicitly capture workflow state when
the selected value changes.

Please focus review on whether `createAssetWidget` now preserves regular
asset widget behavior while correctly handling cloned/transient host
widgets. The key distinction is that the source `onValueChange` path
only runs for the original owner widget; promoted host wrappers instead
rely on their callback bridge and owning node's `onWidgetChanged` hook.

A review pass also found that this PR makes an existing async modal
weakness more visible: asset-browser selection happens from the modal
button's `click` handler, while the global change tracker also captures
on `mouseup`. Depending on event ordering, the automatic capture can
occur before the selection mutates the widget. This PR now captures
workflow state immediately after a successful value-changing asset
selection so undo/modified tracking follows the same user-visible edit.

Local verification:

- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --reporter=dot`
- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --coverage
--reporter=dot --coverage.reporter=text
--coverage.include=src/platform/assets/utils/createAssetWidget.ts`
- `pnpm exec eslint src/platform/assets/utils/createAssetWidget.ts
src/platform/assets/utils/createAssetWidget.test.ts`
- `pnpm typecheck`
- `pnpm format:check`
- `pnpm build:cloud`

---------

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 10:16:43 +00:00
Alexis Rolland
a95dab2f59 Update allowlist in CLA workflow (#13091)
Update `allowlist` in CLA workflow to add
[actions-user](https://github.com/actions-user)
2026-06-24 10:14:16 +00:00
Alexis Rolland
5f90bacb73 ci: add CLA Assistant workflow (#13058)
Adds the CLA Assistant GitHub Actions workflow at
`.github/workflows/cla.yml`, copied from
https://github.com/Comfy-Org/comfy-cla/blob/main/.github/workflows/cla.yml

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-24 06:53:53 +00:00
ShihChi Huang
84319bea13 refactor: drop redundant isCloud guards around telemetry calls (#13082)
## Summary

Remove redundant `if (isCloud)` guards around `useTelemetry()?.x()`
calls. `useTelemetry()` already returns `null` in OSS builds, so the
optional-chain calls no-op there — the guards only duplicated that
central contract.

## Changes

- **What**: Drop the `isCloud` guard wrapping telemetry calls across 9
files and remove the 5 now-unused `isCloud` imports (pure dedent —
implementations unchanged). Add two-path (cloud + OSS) characterization
tests for the two previously-uncovered composables
(`useTemplateWorkflows`, `useSubscriptionActions`).

## e2e
In local/OSS mode, useTelemetry() returns null, so no telemetry-related
behavior occurs, and the workflow loads as expected. There are no
local/OSS flow regressions for the exact template workflow paths touched
by the branch.

| before | after |
| -- | -- |
| <img width="1280" height="800" alt="before-01-templates-open"
src="https://github.com/user-attachments/assets/1cccc686-4e3a-4cf0-a578-a653a1383e3c"
/> | <img width="1280" height="800" alt="after-01-templates-open"
src="https://github.com/user-attachments/assets/ff834a58-4375-432a-8cc1-6e04ceeece77"
/> |
| <img width="1280" height="800" alt="before-02-template-loaded"
src="https://github.com/user-attachments/assets/1abd301b-d66d-4819-a0f3-9dff1a1e23b5"
/> | <img width="1280" height="800" alt="after-02-template-loaded"
src="https://github.com/user-attachments/assets/9fbb6903-c085-4744-b683-39b01680c654"
/> |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Behavior is intended to be unchanged: OSS still no-ops via null
telemetry. Router page-view tracking may run in more build contexts but
remains guarded by optional chaining.
> 
> **Overview**
> Removes duplicate **`if (isCloud)`** wrappers around
**`useTelemetry()?.…()`** across onboarding, auth, templates,
subscription UI, and routing. Call sites now rely on
**`useTelemetry()`** returning **`null`** in OSS (optional chaining
stays a no-op there), and several unused **`isCloud`** imports are
dropped.
> 
> **`trackPageView`** in the router no longer bails early on cloud-only
or **`window`** checks; it always invokes
**`useTelemetry()?.trackPageView(...)`** on navigation.
> 
> Adds characterization tests for **`useTemplateWorkflows`** and
**`useSubscriptionActions`** that assert telemetry fires when the mock
dispatcher is registered and does not when the mock simulates OSS
(**`useTelemetry()` → null**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd6c9a56bd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: ShihChi Huang <shh@theonlyperson.com>
2026-06-24 06:06:19 +00:00
Simon Pinfold
f076106ca5 fix: expand grouped assets when downloading multi-selection on OSS backend (#13079)
*PR Created by the Glary-Bot Agent*

---

## Summary

When using the ComfyUI (non-cloud) backend, selecting a grouped asset
(`outputCount > 1`) and clicking Download only downloaded the
cover/preview image instead of every output in the group.

The cloud backend already handles this correctly by exporting a ZIP
server-side. The OSS path called `downloadFile(asset.preview_url, ...)`
once per selected `AssetItem` and never expanded grouped assets into
their individual outputs.

## Fix

In `useMediaAssetActions.downloadAssets`, when any selected asset has
`outputCount > 1` and we're on the OSS path, resolve each grouped asset
into its individual outputs via the existing `resolveOutputAssetItems`
utility and trigger one direct download per file. Non-grouped selections
keep the original single-shot behaviour. After expansion the file list
is deduplicated by `AssetItem.id` so a user who selects both an expanded
stack parent and one of its children does not download the child twice.
The success toast now reflects the actual number of files downloaded.

- Single asset, single output → unchanged (1 download).
- Multi-select of single-output assets → unchanged (N downloads).
- Any selection containing a grouped asset → expanded via
`resolveOutputAssetItems` (same code path the cloud ZIP and
stack-expansion UI use). If resolution returns nothing, falls back to
the preview download so the user still gets something.
- Grouped parent + one of its expanded children selected → deduped, no
double download.

## Tests

Added unit tests in `useMediaAssetActions.test.ts` for the OSS path:

- Expands a grouped asset into individual downloads.
- Mixes grouped and single-output assets in one selection.
- Falls back to the original asset when `resolveOutputAssetItems`
returns empty.
- Does not call `resolveOutputAssetItems` when no grouped assets are
selected.
- Deduplicates downloads when an expanded child is also selected
alongside its parent.
- Shows an error toast when resolution rejects.

All 40 tests in the file pass; all 508 tests under `src/platform/assets`
pass. `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxfmt --check`
all clean.

## Manual verification
Tested against a `master` ComfyUI instance with default settings.
Not tested against cloud - feature is gated to non cloud

Performed by @synap5e 
<img width="448" height="618" alt="image"
src="https://github.com/user-attachments/assets/daf32fa0-c5ec-47ca-bab3-d5ea3fb3d7cc"
/>


https://github.com/user-attachments/assets/a87ae1aa-836f-4cbc-9ef7-a35ed4f94ee7


https://github.com/user-attachments/assets/49d833bf-7b4e-4c53-b0d5-f16ff2108185

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-24 02:25:02 +00:00
Wei Hai
d7fa853c06 [chore] Update Comfy Registry API types from comfy-api@8d142eb (#13080)
Regenerates `packages/registry-types/src/comfyRegistryTypes.ts` from the
current comfy-api OpenAPI spec via `openapi-typescript`.

The FE registry types were last regenerated in May; this brings them up
to date. Notable addition consumed by #12924: the `createCustomer`
request body (`CreateCustomerRequest` with an optional
`turnstile_token`).

`pnpm typecheck` passes against the regenerated types.
2026-06-24 00:00:24 +00:00
CodeJuggernaut
07f881fc14 feat: float the Media Assets bulk-selection bar (#13043)
## Summary

Reskins the Media Assets bulk-selection bar into a prominent floating
pill so bulk-download actions are no longer easy to miss (Linear
FE-989).

## Changes

- **What**: The selection bar now floats over the bottom of the panel as
an inverted rounded pill — close · "{count} selected" · download ·
divider · delete — matching the Figma/prototype spacing, radius, and
shadow. Actions are icon-only with `v-tooltip` hints; the count uses
`tabular-nums` and reflects selected assets (not total outputs).
Extracted into a presentational `MediaAssetSelectionBar.vue` with a
Storybook story, a unit test, and an e2e guard that the count is
per-asset.

## Review Focus

- The pill floats via `position: absolute` (centered, `bottom-6`,
`z-40`) in the sidebar footer slot and overlays the bottom of the grid
by design.
- Count semantics changed from total outputs to selected-asset count
(`selectedAssets.length`).
- Out of scope, deferred per FE-989: marquee / select-all, pagination,
and the favorites / tags / label controls.

## Screenshots (if applicable)

<img width="1003" height="1767" alt="image"
src="https://github.com/user-attachments/assets/3a9ef884-e7f4-4d0b-a495-194ce0860db2"
/>

<img width="643" height="581" alt="image"
src="https://github.com/user-attachments/assets/1161884f-a9c2-4a2b-a20e-33ee3f189935"
/>

<img width="664" height="222" alt="image"
src="https://github.com/user-attachments/assets/b16b083c-bfd9-452d-b508-86b3cbfa9842"
/>

<img width="649" height="265" alt="image"
src="https://github.com/user-attachments/assets/a1076e34-58c9-4e7f-89c4-b21bb3281883"
/>
 
<img width="559" height="205" alt="image"
src="https://github.com/user-attachments/assets/09c24140-33ce-4629-b681-233c59916043"
/>

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-23 22:53:46 +00:00
Dante
e14b5c6f3f feat: wire /api/billing/events into usage history behind teamWorkspacesEnabled (#12955)
## Summary

Wire the previously-dead `workspaceApi.getBillingEvents` (`GET
/api/billing/events`) into the existing usage/activity table behind
`teamWorkspacesEnabled`, so billing history can converge onto the
unified cloud feed (FE-969, "wire `GET /api/billing/events`" half).

## Changes

- **What**: `UsageLogsTable` sources events from
`workspaceApi.getBillingEvents` when `teamWorkspacesEnabled` is on, else
the legacy `customerEventsService` (`/customers/events`) — no change for
flag-off (legacy/personal). The two feeds share an identical response
envelope (`{total, events, page, limit, totalPages}`) and event object
(`{event_type, event_id, params, createdAt}`), so the table view is
reused unchanged (no design change). `topupTracker` now also recognizes
`topup_completed` so credit top-up telemetry works on the unified feed.
- **Breaking**: none (flag-gated; legacy path retained).

## Review Focus

This is a **draft** — it intentionally does NOT retire
`customerEventsService` yet. Two BE confirmations gate the rest:

**1. Does the store actually accumulate events?** For a personal
workspace today, `GET /api/billing/events` appears to hold only
`gpu_usage` (written directly by inference per `workspace_id`).
Webhook-sourced events (topup/subscription/invoice/credit) are dropped
unless the workspace is provisioned in the cloud billing system —
`GetWorkspaceBy{Stripe,Metronome}CustomerID` → "Not our customer,
ignoring". That provisioning is the BE-1047 cutover. Please confirm
which `event_type`s land in the store per env (e.g. pr-4359), personal
vs team.

**2. Are the incompatible fields compatible (or can they be made so)?**
The feeds use different vocabularies / `params` shapes:
- legacy: `credit_added` / `account_created` / `api_usage_*`, params
`{amount, api_name, model}`
- unified: `topup_completed` / `gpu_usage` / `api_node_usage` /
`invoice_*` / `subscription_activated` / `seat_*`, params e.g.
`gpu_usage {gpu_seconds, gpu_type, ...}`

The table degrades gracefully (badge falls back to the raw `event_type`;
the generic params tooltip still renders), but per-type labels/severity
and the details column need the confirmed `params` shapes before full
convergence + `customerEventsService` retirement.

Notes:
- Surfacing: on cloud with `subscription_required`, the Credits panel
hosting `UsageLogsTable` is hidden when `teamWorkspacesEnabled` is on
(team users see the workspace Plan & Credits panel, which has no
activity table). So this is plumbing; an in-app team history surface is
a separate design item.
- Full retirement of `customerEventsService` should land with the
BE-1047 cutover.

Local gates: unit tests (topupTracker + UsageLogsTable) pass, typecheck
/ oxlint / oxfmt clean.

---------

Co-authored-by: dante01yoon <dante01yoon@naver.com>
2026-06-23 21:35:59 +00:00
CodeJuggernaut
065650b3bf fix: open Vue context menu when right-clicking a group (#12971)
## Summary

Right-clicking a frame (group) now opens the new Vue context menu
instead of
the legacy litegraph menu, matching the three-dot menu and node
right-click.

## Changes

- **What**: In Nodes 2.0 mode, a group right-click is routed to the
existing
  `showNodeOptions` flow (the same menu the three-dot button and node
right-click use) instead of litegraph's `processContextMenu`. The group
is
selected (unless already in the selection) so the menu targets it.
Nodes, the
  canvas background, reroutes, and legacy rendering are unchanged.

## Review Focus

- App-layer wrap of `LGraphCanvas.prototype.processContextMenu`,
mirroring the
  existing `useContextMenuTranslation` pattern: no new methods on
  `LGraphCanvas` (ADR 0008).
- Gated on `LiteGraph.vueNodesMode`; legacy rendering keeps the old
menu,
  consistent with legacy node right-click.
- Reroute guard: right-clicking a reroute inside a group still gets the
legacy
  "Delete Reroute" menu, not the group menu.

Fixes FE-1090

## Screenshots (if applicable)

<img width="719" height="788" alt="image"
src="https://github.com/user-attachments/assets/8d514c6d-b7d0-4ec1-841e-677793daf3c7"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 21:32:36 +00:00
Comfy Org PR Bot
a58f927871 1.47.3 (#12976)
Patch version increment to 1.47.3

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-23 21:12:26 +00:00
Wei Hai
c304f206a6 fix(ci): strip served bundle scripts from e2e coverage before genhtml (#13077)
## Problem

The **CI: E2E Coverage** `merge` job fails in the *Generate HTML
coverage report* step ([example
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28051468752)):

```
genhtml: ERROR: localhost-8188/assets/nodeDefs-BNhq_6cm.js is not readable or doesn't exist.
##[error]Process completed with exit code 1.
```

V8/Playwright coverage records scripts the test server serves at
`localhost-8188/assets/*.js` (built bundles), which are not source files
on disk. `genhtml` aborts on the missing source even with
`--ignore-errors source`, so the whole job fails.

## Change

Add a *Strip non-source entries from coverage* step that runs `lcov
--remove '*localhost-8188*'` on the merged lcov. It is placed **after**
the data-loss validation (so the merged-vs-shard integrity check stays
consistent) and **before** the Codecov upload and genhtml — which also
makes the Codecov report more accurate, since those served bundles
aren't repo source.

## Validation

- YAML parses cleanly; pre-commit lint/format/typecheck pass.
- The removed paths are non-source served assets only; real `src/**`
entries (absolute paths under the repo workspace) are untouched by the
`*localhost-8188*` pattern.
- `--ignore-errors unused` guards against runs where no such entries
exist.
2026-06-23 20:27:07 +00:00
Christian Byrne
75ea646090 chore: disable CodeRabbit docstring coverage pre-merge check (#13027)
## Summary

The **Docstring Coverage** pre-merge check is currently blocking merges.
It is not defined in our `.coderabbit.yaml` — it is enabled via
CodeRabbit **organization-level settings** (this repo has "use
organization settings" toggled on, but the repo config file overrides
specific keys).

This PR explicitly opts the repo out of that check by setting
`reviews.pre_merge_checks.docstrings.mode: 'off'`. A repo-level
`.coderabbit.yaml` takes precedence over org settings, so this disables
the check **only for this repo** — other org repos that want it keep it.

```yaml
reviews:
  pre_merge_checks:
    docstrings:
      mode: 'off'
```

## Why

- Docstring coverage is a poor fit for this TS/Vue frontend codebase and
was added at the org level, not chosen here.
- It's a blocking gate with no repo-level config, so it can't currently
be tuned from the dashboard without affecting other repos.

## References

- [Built-in pre-merge
checks](https://docs.coderabbit.ai/pr-reviews/pre-merge-checks)
- [Configuration
reference](https://docs.coderabbit.ai/reference/configuration)
- Prior art:
[crossplane/.coderabbit.yaml](https://github.com/crossplane/crossplane/blob/main/.coderabbit.yaml)
disables docstrings the same way
2026-06-23 13:35:43 -07:00
Benjamin Lu
a670944a05 Fix share auth attribution gap (#13064)
## Summary

Logged-out users who open a share link and then sign up/in were not
attributed to the share. The `share_id` capture lived in
`useSharedWorkflowUrlLoader`, which only runs after `GraphView` mounts —
i.e. after the cloud auth guard has already redirected the logged-out
user to login. The capture never happened, so `trackAuth` fired without
a `share_id`.

This moves the capture into the cloud auth guard (`router.beforeEach`),
so it runs on the initial navigation before any login redirect. The
`share_id` is preserved across the auth round-trip and consumed on auth
completion as before.

## Changes

- Capture logged-out share attribution in the router guard instead of
the share loader, via a new `preserveLoggedOutShareAuthAttribution` util
- Extract `isValidShareId` into the shared util and reuse it in the
loader (removes the duplicated regex)
- Gate capture on `isCloud` (matching the cloud-only consumption); drop
the now-dead capture branch from the loader
- Make the accepted share-id shape explicit: ASCII alphanumeric start,
ASCII alphanumeric/`_.-` after that, max 128 chars

## Notes

- Capture no-ops when `share` is absent, so param-less redirects do not
clear attribution
- If another valid share link is visited before auth completes, the
latest valid share replaces the previous attribution
- `SHARE` and `SHARE_AUTH` stay separate intentionally: `SHARE`
preserves the workflow-loading query, while `SHARE_AUTH` is consumed
once by auth telemetry attribution
- No behavior change for logged-in users or for share-dialog open/cancel

## Testing

- New unit tests for `isValidShareId` and
`preserveLoggedOutShareAuthAttribution`
(valid/invalid/array/logged-in/boundary cases)
- Auth store tests cover `share_id` propagation + consumption across
email signup/login, Google, and GitHub
- Updated loader and telemetry tests for the relocated capture and
`share_id` passthrough
- Cloud E2E regression covers logged-out `/?share=abc` redirecting to
login after capturing share auth attribution
2026-06-23 19:43:44 +00:00
Wei Hai
282b8cf819 fix(auth): show a clear message when sign-up is rejected by the backend (#13070)
## Problem

When the auth backend rejects a sign-up, the Firebase client SDK
surfaces the rejection wrapped in a generic error code (e.g.
`auth/internal-error`). `reportError` maps only by `error.code` →
`auth.errors.${code}`, so this case falls through to the generic
*"Something went wrong while signing you in. Please try again."* toast —
giving the user no actionable guidance.

## Change

- `useAuthActions.ts` — add a branch in `reportError` that matches a
stable `signup_blocked` token in `error.message` (matched
**case-insensitively** on the message, not `error.code`, which the SDK
wraps inconsistently across versions) and shows a dedicated message.
- `locales/en/main.json` — new `auth.errors.signupBlocked` string. Other
locales inherit it via `fallbackLocale: 'en'`.
- `useAuthActions.test.ts` — unit tests asserting the token path wins
over the generic fallback, and that the match is case-insensitive.

## Validation

- `vitest run src/composables/auth` — 12/12 pass (incl. token +
case-insensitive tests)
- `vue-tsc --noEmit` — clean

## Why no E2E (Playwright) test

This branch is reachable only when the **auth backend actively rejects a
Google-Workspace-SSO sign-up** (a server-side blocking decision) and the
Firebase SDK returns an error whose message carries the `signup_blocked`
token. Reproducing that in the browser E2E harness would require driving
a real federated-SSO sign-up against a backend configured to reject the
specific account — state the FE test environment cannot set up
deterministically. The logic under test is pure error-mapping with no
UI/navigation surface beyond the existing toast, so it is fully covered
by the unit tests above. The end-to-end path will instead be confirmed
via a one-time staging repro (see below).

## Draft — pending confirmation

Kept as **draft** until we confirm, via a staging repro, the exact error
shape the client receives for this rejection (the message-token match is
the robust hedge regardless of `error.code`, but a live repro makes it
deterministic). The backend that emits the `signup_blocked` token is a
separate change.
2026-06-23 19:27:44 +00:00
Matt Miller
70bc8dc6e6 fix(api): isolate event-listener errors from global telemetry (#12740)
## ELI-5

Custom nodes can hook into app events (like "this node finished
running"). If
one of those hooks crashes, the browser shouts the crash into our global
error
channel — and our monitoring logs it as an app error, on every single
execution.
One sloppy third-party node can bury the real errors in thousands of
copies of
its own bug. This wraps each hook so if it crashes, we log a quiet
warning for
the node author instead of screaming it into the error dashboard.

## What

`ComfyApi` extends `EventTarget`. When a listener throws, native
`dispatchEvent`
reports the exception to the **global** error handler (`window.onerror`
/
`reportError`) and continues. Our error monitoring (Datadog RUM)
collects those
as unhandled errors — so a single misbehaving custom-node listener (e.g.
`comfyui-enricos-nodes` reading `.type` on an undefined message inside
an
`executed` handler) produces thousands of non-actionable error events.

This wraps listeners registered via `addEventListener` /
`addCustomEventListener` in a try/catch:

- The wrapper is stored in a `WeakMap<original, wrapped>`, so
`removeEventListener`
/ `removeCustomEventListener` still match the installed wrapper. (GC'd
weakly.)
- Caught errors are logged at **`console.warn`**, not `console.error` —
RUM
collects `console.error` by default, which would just relabel the same
noise.

Native `EventTarget` already isolates listeners from one another, so
this does
**not** change dispatch/continuation behaviour — it only changes where a
thrown
error goes (a warn in the console, visible to node authors, instead of
an
unhandled error in global telemetry).

## Test plan

- [x] New `api.eventListeners.test.ts`: a throwing listener doesn't
abort
  dispatch to other listeners; errors log at `warn` not `error`;
  `removeEventListener` still removes a guarded listener. (3 tests)
- [x] Existing `api.featureFlags.test.ts` (15) and
`changeTracker.test.ts` (16)
  still pass — 34 total green.
- [x] oxfmt + oxlint clean.

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 16:55:23 +00:00
Matt Miller
ac56ecf009 refactor: unify image editor upload contract (FE-750) (#12318)
## Summary

Collapse the OSS vs Cloud branching in the mask editor and painter
uploads so both runtimes use the same contract — POST to `/upload/image`
with `type: input` and no `subfolder`, then reference the result by
filename only.

## Related

- Companion spec change (Comfy-Org/ComfyUI):
https://github.com/Comfy-Org/ComfyUI/pull/13968 deprecates
`/api/upload/mask` and documents `/api/upload/image` as the unified
upload contract. No runtime behavior changes on the server.

## Changes

- **What**:
- `useMaskEditorSaver.ts`: replace the separate `uploadMask` +
`uploadImage` helpers with a single `uploadLayer`. All four layers
(masked, paint, painted, paintedMasked) now go through `/upload/image`.
Dropped the `original_ref` form field and the `clipspace` subfolder.
`uploadLayer` throws on a non-ok status, on a non-JSON response body,
and on a 200 response missing `data.name` (no more silent fallback to
the pre-upload ref).
- `usePainter.ts`: removed the runtime branch on upload type/subfolder
and on the returned widget value. Always uploads as `type: input` with
no subfolder; widget value is `${filename} [input]`. Added a `data.name`
guard alongside the existing non-ok and JSON-parse guards.
- `usePainter.test.ts`: assert the upload FormData carries `type=input`
with no `subfolder`, plus new coverage for the missing-name and
JSON-parse-failure error branches.
- `browser_tests/tests/maskEditor.spec.ts`: drop the now-dead
`**/upload/mask` Playwright route interceptors and tighten the
save-success assertion to confirm exactly four `/upload/image` calls
(one per layer).
- **Breaking**: yes for downstream consumers — the mask editor no longer
calls `/upload/mask`, and saved widget values for both editors no longer
contain a subfolder prefix. Existing nodes will continue to load their
referenced inputs because the filename is preserved; new saves emit the
unified shape.

## Review Focus

- Confirm the mask editor's four-layer upload (masked, paint, painted,
paintedMasked) is still correct without `original_ref` — the layers are
now independent uploads rather than alpha-composited server-side. The
four blobs are composited client-side in `prepareOutputData` before
upload, so the previous `original_ref` chain was vestigial — but worth a
second look.
- OSS-side painter widget back-compat: previously stored as
`painter/foo.png [temp]`, new saves emit `foo.png [input]`. The old
format remains valid in saved workflows; only the *new save* shape
changes. Loading a pre-existing OSS workflow with a `painter/foo.png
[temp]` widget value still resolves via the existing parser path.

## Test plan

Unit:
- [x] `usePainter.test.ts` — 30 tests pass, including the three new ones
covering FormData payload shape, missing `data.name`, and bad JSON.

E2E:
- [x] `browser_tests/tests/maskEditor.spec.ts` — `save uploads all
layers and closes dialog` and `save failure keeps dialog open` updated
to the unified contract.

Smoke-tested manually against a Cloud staging instance:
- [x] **Painter node**: paint strokes → save → reload workflow → widget
value resolves and the canvas re-renders cleanly. Upload responds with
`subfolder: ""`, `type: "input"`.
- [x] **Mask editor**: open editor with a base image, paint + mask,
save. All four layers (`clipspace-mask-*`, `clipspace-paint-*`,
`clipspace-painted-*`, `clipspace-painted-masked-*`) POST successfully
to `/upload/image` and return 200 with the asset-aware response shape
(`{ name, subfolder: "", type: "input", asset: { id, hash, tags } }`).
The resulting `LoadImage` node references the painted-masked filename by
basename only and re-renders.

Not validated here: OSS ComfyUI core-side smoke. The contract is
symmetric (the OSS `/upload/image` endpoint accepts the same fields the
FE now sends), but a smoke against OSS HEAD is recommended before merge.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 16:54:59 +00:00
pythongosssss
e97cca9e4a feat: show node preview ghost when adding models from dialog & sidebar (#12765)
## Summary

Adds consistent ghost node behavior when clicking "use" on models from
either the model dialog or treeview - matching the Node Library & Node
Search.

## Changes

- **What**: 
- Split `createModelNodeFromAsset` into `resolveModelNodeFromAsset` and
`startModelNodeDragFromAsset` to allow sharing logic between the two
drag sources
- Move NodeDragPreview file & use from being node library tab specific
to app level in GraphCanvas so all drag sources share it
- drag listeners now attach on `startDrag` and detach on cancel instead
of living for the tab's lifetime
- Updated `LGraphNodePreview` to accept widgetValues and prepend combo
options with passed value so it shows in the preview as the default

## Review Focus
- refactored positioning to use RAF with useMouse and transform to fix
laggy-follow behavior present in Firefox

## Screenshots (if applicable)

Model dialog + Node library

https://github.com/user-attachments/assets/b227ac43-c6ea-4cf6-86ed-6cfb196fd80e

Model library sidebar

https://github.com/user-attachments/assets/bb546aee-5099-4df9-abe5-68bccd8fa2eb
2026-06-23 10:41:14 +00:00
Dante
49a7b7b558 feat(billing): UnifiedPricingTable — one table, personal/team plan toggle (B4 / FE-934) (#12666)
## What
**B4 (FE-934): `UnifiedPricingTable`** — one pricing table for the
**Jun-5 model** (typeless workspace; personal/team is a **plan**, shown
as a Gamma-style **plan toggle** on one workspace), per **DES-197**. A
new, flag-gated component that will replace the two legacy tables at
cutover (strangler).


### video 

https://github.com/user-attachments/assets/82b704a4-101e-4609-8ff5-06b7cf7f9cd7



> **Stacked on #12644 (FE-935 `CreditSlider`).** Base is the slider
branch — retarget to `main` once #12644 merges. The slider commits show
in the diff until then.

## Changes
- **`UnifiedPricingTable.vue`** — plan toggle (personal/team, flag-gated
on `teamWorkspacesEnabled`); personal tier cards (facade `plans` +
`TIER_PRICING` fallback, billing-cycle toggle); team column hosting
`<CreditSlider>`; Enterprise card. Reuses `useBillingContext`; emits
`subscribe`/`resubscribe` (personal) + `subscribeTeam` (team).
- **`SubscriptionRequiredDialogContentUnified.vue`** — host; wires
personal checkout through `useSubscriptionCheckout` (full flow incl.
preview/transition + **new `'success'` step**); team checkout stubbed.
- **Confirm/success screens (DES 3084-15873)** — aligned the reused
`SubscriptionAddPaymentPreviewWorkspace` /
`SubscriptionTransitionPreviewWorkspace` to DES-197 (drop `/member`,
`comfy--credits` icon, plan-specific CTAs **"Subscribe to {plan}" /
"Switch to {plan}"**); added **`SubscriptionSuccessWorkspace.vue`**
("You're all set") as the `'success'` checkout step.
- **`showPricingTable`** — renders the unified host when
`teamWorkspacesEnabled`, legacy `PricingTable.vue` for flag-off. (The
old workspace-variant fork is removed; `PricingTableWorkspace.vue`
becomes unused on flag-on → deleted at cutover.)
- **i18n** — `subscription.planScope` / `teamPlan` / `enterprise` +
`subscription.preview.{subscribeToPlan,switchToPlan}` +
`subscription.success.*` keys.

## Strategy (new component + cutover — per FE-934)
Build new, retire old at cutover (a later single PR deletes
`PricingTable.vue` + `PricingTableWorkspace.vue` + the legacy dialog
host + the dispatch fork; `TIER_PRICING` kept only as the flag-off/OSS
fallback). Avoids half-migrated conditional cruft in the live tables.

## Screenshots
Pricing captured live on the authenticated cloud-prod session
(`local.comfy.org`, flag on). Confirm/success captured in Storybook
(prop-driven) — a **personal** workspace can't reach these live until
**B1/FE-966** flips its billing path off the legacy `/customers/*`
adapter (whose `plans` is always empty); the screens themselves are
unchanged regardless of source.

**Pricing table**

| Personal | Team |
|---|---|
| <img width="420" alt="pricing-personal"
src="https://github.com/user-attachments/assets/2be3b8bc-ac54-41db-8c21-5c950d3e7338"
/> | <img width="420" alt="pricing-team"
src="https://github.com/user-attachments/assets/c4078eb4-ee7d-42f6-bcc3-375686ab7f1e"
/> |

**Confirm your payment / plan change**

| New subscription | Plan change (Pro → Creator) |
|---|---|
| <img width="380" alt="confirm-new-subscription"
src="https://github.com/user-attachments/assets/e371c744-dc64-43e8-b977-73f9f99f85bc"
/> | <img width="380" alt="confirm-plan-change"
src="https://github.com/user-attachments/assets/b1ee5ab3-c572-4c40-9b70-f078d66b78f4"
/> |

**You're all set (success)**

<img width="380" alt="success-all-set"
src="https://github.com/user-attachments/assets/8ec9e90f-8ba4-4cb0-81a7-6b4316e0c19e"
/>

## ⚠️ BE-blocked (deferred)
- **Team checkout**: the slider stop → plan-slug / subscribe-request
shape is undefined (doc **Open Q#2** / "Team-slider contract",
**BE-1254**). `subscribeTeam` is stubbed (toast "coming soon") until BE
provides it.
- **Live discount data**: stops come from the hardcoded **DES-197
fallback** (`teamPlanCreditStops.ts`) until `GET /api/billing/plans`
carries them.
- **Confirm "credits you'll get right away" line** + **async-success
routing** (Stripe-tab/`pending_payment` → in-dialog success) deferred —
see ticket notes; credits-cents unit is the open BE-1254 question.

## Verification
- `vue-tsc --noEmit`: clean (pre-commit).
- `oxlint`/`eslint`/`stylelint`/`oxfmt`: pass (pre-commit).
- `vitest` `useSubscriptionDialog.test.ts` (11) +
`useSubscriptionCheckout.test.ts` (17) + new
`SubscriptionSuccessWorkspace.test.ts` (2): pass.
- Personal checkout reuses the existing `useSubscriptionCheckout` flow;
subscribed → new `'success'` step.

## Not in scope
- Pixel-finalizing vs DES-197 (visual reference = Alex's
**reference-only** #12042); personal-card detail refinement.
- Settings / Misc-UX (FE-768/770); team-checkout wiring (BE contract).

Design: **DES-197** / **3084-15873**. Survey: *FE Billing API
Divergence* (B4 / P1 / P2 / P4).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-23 10:11:13 +00:00
285 changed files with 23515 additions and 2616 deletions

View File

@@ -15,6 +15,11 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -85,6 +85,16 @@ jobs:
fi
done
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6

63
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

@@ -78,6 +78,11 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -30,9 +30,9 @@ function toggle(index: number) {
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
{{ heading }}
</h2>
</div>
@@ -42,7 +42,7 @@ function toggle(index: number) {
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
class="border-b border-primary-comfy-canvas/20"
>
<button
:id="`faq-trigger-${faq.id}`"
@@ -83,7 +83,7 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>

View File

@@ -25,7 +25,7 @@ const {
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -40,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,10 +66,10 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[locale] }}
</span>

View File

@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
<template>
<footer
ref="footerRef"
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
>
<div
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"

View File

@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span

View File

@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
readonly toggleButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
this.toggleButton = page.getByRole('button', {
name: 'Toggle properties panel'
})
}
}

View File

@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
this.downloadSelectedButton = page
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.selectionFooter = page.getByTestId('assets-selection-bar')
this.selectionCountButton = page.getByText(/\d+ selected/)
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'

View File

@@ -112,6 +112,10 @@ export const TestIds = {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',
card: 'asset-card'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',

View File

@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
test('should focus keybindings search when opening manage shortcuts', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeFocused()
await expect(
comfyPage.page.getByPlaceholder('Search Settings...')
).not.toBeFocused()
})
})

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('Use button ghost-places a loader populated with the model', async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const card = comfyPage.page.locator(
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
)
await expect(card).toBeVisible()
await card.getByRole('button', { name: 'Use' }).click()
// Dialog closes and the ghost is armed; the node is not placed until the
// user clicks the canvas.
await expect(modal).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
})
})

View File

@@ -0,0 +1,99 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
interface WidgetSnapshot {
type: string
value: string
hasLayout: boolean
}
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
return await page.evaluate(
({ nodeId, widgetName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
return {
type: widget?.type ?? '',
value: String(widget?.value ?? ''),
hasLayout: widget?.last_y != null
}
},
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
)
}
test.describe(
'Promoted subgraph asset widgets',
{ tag: ['@cloud', '@canvas', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('legacy asset browser selection updates the promoted host widget value', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await expect
.poll(
() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
),
{ timeout: 10_000 }
)
.toBe(true)
await expect
.poll(() => getHostWidgetSnapshot(comfyPage.page))
.toMatchObject({
type: 'asset',
hasLayout: true
})
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
await hostNode.centerOnNode()
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
await promotedWidget.click()
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
await expect(modal).toBeVisible()
const assetCard = modal
.getByTestId(TestIds.assets.card)
.filter({ hasText: SELECTED_MODEL })
.first()
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use' }).click()
await expect(modal).toBeHidden()
await expect
.poll(() =>
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
)
.toBe(SELECTED_MODEL)
})
}
)

View File

@@ -1,6 +1,9 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
/**
* Cloud distribution E2E tests.
*
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('http://localhost:8188')
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test('preserves share auth attribution before redirecting logged-out users', async ({
page
}) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
)
)
.toBe(JSON.stringify({ share: 'abc' }))
})
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto('http://localhost:8188')
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()

View File

@@ -254,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}) => {
const dialog = await maskEditor.openDialog()
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
@@ -288,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
// through the unified /upload/image endpoint.
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
imageUploadCount,
'save should upload all four layers via /upload/image'
).toBe(4)
})
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)

View File

@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
let observedContentType = ''
let observedBodyLength = 0
await comfyPage.page.route('**/upload/mask', async (route) => {
await comfyPage.page.route('**/upload/image', async (route) => {
const request = route.request()
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
if (!observedContentType) {
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
}
await route.fulfill(
fulfillJson(successResponse('clipspace-mask-123.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
expect(observedContentType).toContain('multipart/form-data')
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeVisible()
})
test('Save failure on partial upload keeps dialog open', async ({
comfyPage,
maskEditor
}) => {
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
// The saver uploads sequentially: mask layer first, then image layers.
// Let the mask upload succeed and the image upload fail to exercise both
// endpoints and verify the dialog stays open after a partial failure.
let maskUploadHit = false
let imageUploadHit = false
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadHit = true
return route.fulfill(
fulfillJson(successResponse('clipspace-mask-999.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadHit = true
return route.fulfill({ status: 500 })
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
await expect.poll(() => maskUploadHit).toBe(true)
await expect.poll(() => imageUploadHit).toBe(true)
await expect(dialog).toBeVisible()
await expect(saveButton).toBeVisible()

View File

@@ -13,10 +13,6 @@ import type {
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-alpha',
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
await tab.open()
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible()
// Imported tab should show the mocked files
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible()
})
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible()
})
})
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
})
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible()
})
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
})
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
comfyPage
}) => {
// job-gamma is the first card; its detail carries a valid workflow so
// extraction succeeds and the filename prompt opens.
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
comfyPage
}) => {
// Strip the workflow field so extraction yields null and the export
// action returns { success: false, error: 'No workflow…' }.
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
// Filename prompt should be skipped: extraction fails before the prompt.
await expect(comfyPage.toast.toastWarnings).toBeVisible()
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
})
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
// the modifier — click({ modifiers }) only sets the mouse event flag and
// does not fire a keydown event that VueUse tracks.
// useKeyModifier('Control') needs keyboard events, not click modifiers.
await cards.first().click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
// dispatchEvent avoids the selection footer intercepting a right click.
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
})
await expect(contextMenu).toBeVisible()
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible()
})
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible()
})
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
// outputs_count: 2 which would inflate the total.
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
await cards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
})
test('Selection count sums the outputs of a stacked asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click()
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
})
test('Selection bar stays capped, not stretched, on a wide panel', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
const tab = comfyPage.menu.assetsTab
await tab.open()
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
await expect(gutter).toBeVisible()
const gutterBox = await gutter.boundingBox()
if (!gutterBox) {
throw new Error('sidebar splitter gutter has no bounding box')
}
await comfyPage.page.mouse.move(
gutterBox.x + gutterBox.width / 2,
gutterBox.y + gutterBox.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
steps: 12
})
await comfyPage.page.mouse.up()
await tab.assetCards.first().click()
await expect(tab.selectionFooter).toBeVisible()
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
.toBeGreaterThan(520)
await expect
.poll(async () => {
const bar = await tab.selectionFooter.boundingBox()
const side = await sidebar.boundingBox()
return bar && side ? side.width - bar.width : 0
})
.toBeGreaterThan(100)
})
})
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
// Capture the first history fetch (terminal statuses only).
// Queue polling also hits /jobs but with status=in_progress,pending.
// Queue polling also calls /jobs, so wait for completed history only.
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})

View File

@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})

View File

@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
test.describe('Model library sidebar - add node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Clicking a model defers creation until placed on the canvas', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
})
test('Ghost preview shows the model in the loader widget before placing', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
})
})
})

View File

@@ -1,6 +1,9 @@
import type { ConsoleMessage } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
// widening the race window so a guard regression deterministically surfaces.
async function deferLegacyHandlers(comfyPage: ComfyPage) {
return await comfyPage.page.evaluateHandle(() => {
const graph = window.app!.graph!
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const originalNodeRemoved = graph.onNodeRemoved
const originalSelectionChange = canvas.onSelectionChange
graph.onNodeRemoved = function (node) {
queue.push(() => originalNodeRemoved?.call(this, node))
}
canvas.onSelectionChange = function (selected) {
queue.push(() => originalSelectionChange?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
graph.onNodeRemoved = originalNodeRemoved
canvas.onSelectionChange = originalSelectionChange
}
}
})
}
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
// Defers only the legacy selection-change callback, so the detached host
// node lingers in the reactive selection while onNodeRemoved still runs
// normally and clears it from the canvas. This isolates the panel render
// path: a panel mounted during this window reads the stale selection.
async function deferSelectionChange(
comfyPage: ComfyPage
): Promise<DeferredHandlers> {
return await comfyPage.page.evaluateHandle(() => {
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const original = canvas.onSelectionChange
canvas.onSelectionChange = function (selected) {
queue.push(() => original?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
canvas.onSelectionChange = original
}
}
})
}
function isNullGraphErrorText(text: string): boolean {
return text.includes('NullGraphError') || text.endsWith('has no graph')
}
// Vue's default errorHandler routes render throws to console.error,
// not pageerror - listen to both.
function captureNullGraphErrors(comfyPage: ComfyPage) {
const captured: string[] = []
const onPageError = (err: Error) => {
if (
err.name === 'NullGraphError' ||
isNullGraphErrorText(err.message ?? '')
) {
captured.push(`pageerror ${err.name}: ${err.message}`)
}
}
const onConsoleMessage = (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (isNullGraphErrorText(text)) {
captured.push(`console.error: ${text}`)
}
}
comfyPage.page.on('pageerror', onPageError)
comfyPage.page.on('console', onConsoleMessage)
return {
getErrors: () => [...captured],
stop: () => {
comfyPage.page.off('pageerror', onPageError)
comfyPage.page.off('console', onConsoleMessage)
}
}
}
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await comfyPage.contextMenu.openForVueNode(fixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
}
async function reopenRightSidePanel(comfyPage: ComfyPage) {
const { propertiesPanel } = comfyPage.menu
await propertiesPanel.toggleButton.click()
await expect(propertiesPanel.root).toBeHidden()
await propertiesPanel.toggleButton.click()
await comfyPage.nextFrame()
}
// Unpacks the subgraph behind deferred teardown, runs an optional
// interaction while the node is detached but not yet cleaned up, then
// drains the deferred handlers and reports any NullGraphErrors seen.
async function unpackAndCaptureNullGraphErrors(
comfyPage: ComfyPage,
options: {
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
}
): Promise<string[]> {
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
const errors = captureNullGraphErrors(comfyPage)
const deferred = await options.defer(comfyPage)
try {
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toHaveCount(0)
await options.duringWindow?.(comfyPage)
await deferred.evaluate((handlers) => handlers.drain())
// Let drained-handler reactive flushes settle before stop().
await comfyPage.nextFrame()
return errors.getErrors()
} finally {
await deferred.evaluate((handlers) => handlers.restore())
await deferred.dispose()
errors.stop()
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toBeVisible()
const fixture =
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
await fixture.header.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await comfyPage.nextFrame()
})
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'LGraphNode render path: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'SubgraphEditor panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'SubgraphEditor remount: stale selection must not surface NullGraphError'
).toEqual([])
})
})
})

View File

@@ -280,3 +280,36 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})
test.describe(
'Vue Node Group Context Menu',
{ tag: ['@vue-nodes', '@canvas'] },
() => {
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
comfyPage
}) => {
// Deselect so the right-click selects the group itself.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
.toBe(1)
await comfyPage.page.mouse.click(100, 100)
await comfyPage.nextFrame()
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
button: 'right'
})
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
// Group-only action confirms it is the group menu.
await expect(
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
).toBeVisible()
})
}
)

View File

@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.2",
"version": "1.47.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50)
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -0,0 +1,181 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
<div
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { nodeId } = defineProps<{ nodeId: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
})
it('does not call startDrag for folder items on dragstart', async () => {

View File

@@ -427,7 +427,6 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -453,16 +452,14 @@ onMounted(() => {
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
originalOnClose()
}

View File

@@ -8,6 +8,7 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type * as DistributionTypes from '@/platform/distribution/types'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
@@ -38,6 +39,23 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<typeof DistributionTypes>()),
isCloud: true
}))
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: mockWorkspaceApi
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -118,6 +136,8 @@ describe('UsageLogsTable', () => {
vi.clearAllMocks()
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -320,6 +340,20 @@ describe('UsageLogsTable', () => {
})
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
await renderLoaded()
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
})
describe('EventType integration', () => {
it('renders credit_added event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(

View File

@@ -99,7 +99,10 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
@@ -112,6 +115,9 @@ const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const pagination = ref({
page: 1,
limit: 7,
@@ -138,10 +144,13 @@ const loadEvents = async () => {
error.value = null
try {
const response = await customerEventService.getMyEvents({
const params = {
page: pagination.value.page,
limit: pagination.value.limit
})
}
const response = useBillingApi.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
if (response) {
if (response.events) {

View File

@@ -93,6 +93,7 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<NodeDragPreview />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
@@ -145,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
@@ -464,6 +467,7 @@ useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useGroupContextMenu()
useCopy()
usePaste()
useWorkflowAutoSave()

View File

@@ -0,0 +1,97 @@
import { render } from '@testing-library/vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { fromPartial } from '@total-typescript/shoehorn'
vi.mock(
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
() => ({
default: { template: '<div data-testid="node-preview" />' }
})
)
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
function moveMouse(clientX: number, clientY: number) {
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
}
function ghostElement() {
return document.querySelector('[data-testid="node-preview"]')?.parentElement
?.parentElement
}
describe('NodeDragPreview', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
useNodeDragToCanvas().cancelDrag()
vi.useRealTimers()
})
it('shows no ghost when nothing is being dragged', async () => {
render(NodeDragPreview)
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('keeps the ghost hidden until the mouse position is known', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('follows the mouse with an offset while dragging', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
moveMouse(300, 400)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
})
it('removes the ghost when the drag is cancelled', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeTruthy()
useNodeDragToCanvas().cancelDrag()
await nextTick()
expect(ghostElement()).toBeFalsy()
})
})

View File

@@ -0,0 +1,57 @@
<template>
<Teleport to="body">
<div
v-if="showGhost && rafPosition"
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
:style="{
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview
:node-def="draggedNode!"
:widget-values="pendingWidgetValues"
position="relative"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useMouse, useRafFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
const { x, y, sourceType } = useMouse({ type: 'client' })
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
const rafPosition = shallowRef<{ x: number; y: number }>()
const { pause, resume } = useRafFn(
() => {
if (sourceType.value === null) return
const pos = rafPosition.value
if (pos && pos.x === x.value && pos.y === y.value) return
rafPosition.value = { x: x.value, y: y.value }
},
{ immediate: false }
)
watch(
showGhost,
(show) => {
if (show) {
resume()
} else {
pause()
rafPosition.value = undefined
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,126 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,258 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -0,0 +1,70 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
})
it('starts a drag on pointer down without emitting', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -0,0 +1,48 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
<button
v-if="modelValue.length < max"
type="button"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -0,0 +1,54 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</script>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -23,7 +24,7 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
@@ -55,7 +56,7 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
@@ -75,7 +76,7 @@ const runtimeErrorCard: ErrorCardData = {
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [

View File

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{

View File

@@ -1,4 +1,5 @@
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
@@ -12,7 +13,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean

View File

@@ -671,6 +671,30 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -39,8 +39,8 @@ import {
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
isNodeExecutionId,
compareExecutionId
compareExecutionId,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/** Resolve display info for a node by its execution ID. */
function resolveNodeInfo(nodeId: string) {
function resolveNodeInfo(nodeId: NodeExecutionId) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
@@ -119,7 +119,7 @@ function getOrCreateGroup(
}
function createErrorCard(
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string
): ErrorCardData {
@@ -130,7 +130,7 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
@@ -371,9 +371,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
) {
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
@@ -404,9 +406,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
if (!nodeId) return
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
nodeId,
e.node_type,
'exec',
{
@@ -417,8 +422,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
})
},
filterBySelection
@@ -669,7 +673,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function isAssetErrorInSelection(executionNodeId: string): boolean {
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -691,12 +695,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return false
}
function isAssetCandidateInSelection(nodeId: string | number): boolean {
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
@@ -707,7 +716,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
errors: [],
...overrides
}
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -3,17 +3,12 @@ import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
const i18n = createI18n({
legacy: false,
@@ -84,4 +79,20 @@ describe('SidebarIcon', () => {
tooltipText
)
})
it('falls back to label for tooltip when no tooltip is provided', async () => {
const labelText = 'WASNodeSuitePreprocessors'
const { user } = renderSidebarIcon({ label: labelText })
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
await user.hover(screen.getByRole('button'))
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
},
{ timeout: 1000 }
)
})
})

View File

@@ -40,9 +40,11 @@
</span>
</div>
</slot>
<!-- w-max sizes the label to the rail instead of the padding-inset
button content box, which is too narrow for one-line labels -->
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-2xs"
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
>{{ st(label, label) }}</span
>
</div>
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
/**
* Falls back to the label when no tooltip is provided, so labels clamped
* to two lines can always be recovered in full on hover.
*/
const computedTooltip = computed(() => {
const text = tooltip || label
return st(text, text) + tooltipSuffix
})
</script>
<style>

View File

@@ -115,69 +115,14 @@
</div>
</template>
<template #footer>
<div
<MediaAssetSelectionBar
v-if="hasSelection"
ref="footerRef"
class="flex h-18 w-full items-center justify-between gap-1"
>
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
}}
</Button>
</div>
</div>
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
<template v-if="isCompact">
<!-- Compact mode: Icon only -->
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<template v-else>
<!-- Normal mode: Icon + Text -->
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
</div>
</div>
:count="totalOutputCount"
:show-delete="shouldShowDeleteButton"
@deselect="handleDeselectAll"
@download="handleDownloadSelected"
@delete="handleDeleteSelected"
/>
</template>
</SidebarTabTemplate>
<MediaLightbox
@@ -208,8 +153,6 @@
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
} from '@vueuse/core'
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -257,7 +201,6 @@ import {
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
@@ -335,33 +278,6 @@ const {
exportMultipleWorkflows
} = useMediaAssetActions()
// Footer responsive behavior
const footerRef = ref<HTMLElement | null>(null)
const footerWidth = ref(0)
// Track footer width changes
useResizeObserver(footerRef, (entries) => {
const entry = entries[0]
footerWidth.value = entry.contentRect.width
})
// Determine if we should show compact mode (icon only)
// Threshold matches when grid switches from 2 columns to 1 column
// 2 columns need about ~430px
const COMPACT_MODE_THRESHOLD_PX = 430
const isCompact = computed(
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
)
// Hover state for selection count button
const selectionCountButtonRef = ref<HTMLElement | null>(null)
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
return getTotalOutputCount(selectedAssets.value)
})
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const totalOutputCount = computed(() =>
getTotalOutputCount(selectedAssets.value)
)
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)

View File

@@ -14,7 +14,7 @@ const {
captureRoot,
getRoot,
resetRoot,
mockAddNodeOnGraph,
mockStartDrag,
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
@@ -29,7 +29,7 @@ const {
resetRoot: () => {
capturedRoot = null
},
mockAddNodeOnGraph: vi.fn(),
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
@@ -37,8 +37,8 @@ const {
}
})
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('handles model click and adds node to graph', async () => {
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
const mockWidget = { name: 'ckpt_name', value: '' }
const mockGraphNode = { widgets: [mockWidget] }
mockGetNodeProvider.mockReturnValue({
nodeDef: mockNodeDef,
key: 'ckpt_name'
})
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
renderComponent()
await nextTick()
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
await modelLeaf?.handleClick?.(mockEvent)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
expect(mockWidget.value).toBe('model.safetensors')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' },
source: 'sidebar_drag'
})
})
it('toggles folder expansion on click', async () => {

View File

@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
import { ResourceState, useModelStore } from '@/stores/modelStore'
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.file_name
}
startModelLoaderDrag(provider, model.file_name)
}
} else {
toggleNodeOnEvent(e, node)

View File

@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
cancelDrag: vi.fn()
})
}))

View File

@@ -115,7 +115,6 @@
</div>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div
v-if="hasNoMatches"
@@ -215,7 +214,6 @@ import type {
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()

View File

@@ -1,69 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="isDragging && draggedNode && showPreview"
class="pointer-events-none fixed z-10000"
:style="{
left: `${previewPosition.x + 12}px`,
top: `${previewPosition.y + 12}px`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview :node-def="draggedNode" position="relative" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const {
isDragging,
draggedNode,
cursorPosition,
dragMode,
setupGlobalListeners,
cleanupGlobalListeners
} = useNodeDragToCanvas()
const nativeDragPosition = ref({ x: 0, y: 0 })
const previewPosition = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value
}
return cursorPosition.value
})
const showPreview = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
}
return true
})
function handleDrag(e: DragEvent) {
if (e.clientX === 0 && e.clientY === 0) return
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function handleDragEnd() {
nativeDragPosition.value = { x: 0, y: 0 }
}
onMounted(() => {
setupGlobalListeners()
document.addEventListener('drag', handleDrag)
document.addEventListener('dragend', handleDragEnd)
})
onUnmounted(() => {
cleanupGlobalListeners()
document.removeEventListener('drag', handleDrag)
document.removeEventListener('dragend', handleDragEnd)
})
</script>

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import CreditSlider from './CreditSlider.vue'
const meta: Meta<typeof CreditSlider> = {
title: 'Components/CreditSlider',
component: CreditSlider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
disabled: { control: 'boolean' }
},
args: {
disabled: false
},
decorators: [
(story) => ({
components: { story },
// Previews at the real layout width: the Figma "Team Plan" card column is
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
// width the slider actually renders into inside PricingTableWorkspace.
template: '<div class="w-[512px] px-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
// In production this comes from the API; here it shows the stops being driven
// entirely through props rather than the hardcoded default constant.
const apiTeamCreditStops = {
default_stop_index: 2,
stops: [
{
id: 'team_200',
credits: 42_200,
yearly: { price_cents: 20_000, discount_percent: 0 }
},
{
id: 'team_400',
credits: 84_400,
yearly: { price_cents: 38_000, discount_percent: 5 }
},
{
id: 'team_700',
credits: 147_700,
yearly: { price_cents: 63_000, discount_percent: 10 }
},
{
id: 'team_1400',
credits: 295_400,
yearly: { price_cents: 119_000, discount_percent: 15 }
},
{
id: 'team_2500',
credits: 527_500,
yearly: { price_cents: 200_000, discount_percent: 20 }
}
]
}
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
// The pre-discount list price is recovered as discounted / (1 - discount).
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
credits: s.credits,
discountPercentYearly: s.yearly.discount_percent,
usd: Math.round(
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
)
}))
export const BackendDrivenStops: Story = {
name: 'Backend-driven stops (props)',
render: (args) => ({
components: { CreditSlider },
setup() {
const defaultStopIndex = apiTeamCreditStops.default_stop_index
const value = ref(mappedStops[defaultStopIndex].usd)
return { args, value, mappedStops, defaultStopIndex }
},
template:
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
})
}

View File

@@ -0,0 +1,208 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { usdToCredits } from '@/base/credits/comfyCredits'
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import CreditSlider from './CreditSlider.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
usdPerMonth: 'USD / mo',
billedYearly: '{total} Billed yearly',
billedMonthly: 'Billed monthly',
creditSliderSave: 'Save {percent}% ({amount})'
}
}
}
})
function renderSlider(props: Record<string, unknown> = {}) {
return render(CreditSlider, { props, global: { plugins: [i18n] } })
}
async function flush() {
await nextTick()
await nextTick()
}
describe('CreditSlider', () => {
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
renderSlider()
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '4')
expect(thumb).toHaveAttribute('aria-valuenow', '2')
})
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith(1400)
})
it('snaps to the previous fixed stop on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalledWith(400)
})
it('emits change with the full {index, usd, credits} payload', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSlider({ modelValue: 700, onChange })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith({
index: 3,
usd: 1400,
credits: 295_400
})
})
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
const onChange = vi.fn()
renderSlider({
modelValue: 700,
disabled: true,
'onUpdate:modelValue': onUpdate,
onChange
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
})
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
renderSlider() // default $700 stop → 10% yearly discount
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 10% ($70)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'$7,560'
)
})
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 5% ($35)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'Billed monthly'
)
})
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
renderSlider({ modelValue: 400, cycle: 'monthly' })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 2.5% ($10)'
)
})
it('hides the discount UI at the 0% stop ($200)', async () => {
renderSlider({ modelValue: 200 })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
expect(
screen.queryByTestId('credit-slider-original-price')
).not.toBeInTheDocument()
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
})
it('renders all five fixed credit stop labels', async () => {
renderSlider({ modelValue: 700 })
await flush()
const stops = within(screen.getByTestId('credit-slider-stops'))
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
expect(stops.getByText(label)).toBeInTheDocument()
}
})
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
const stops = [
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
]
// No modelValue → the model default ($700) matches no stop, so selectedIndex
// falls back to defaultStopIndex (here index 1 → $100).
renderSlider({ stops, defaultStopIndex: 1 })
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$100')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 25% ($25)'
)
// Only the prop's labels render — none of the DES-197 defaults.
const labels = within(screen.getByTestId('credit-slider-stops'))
expect(labels.getByText('10.6K')).toBeInTheDocument()
expect(labels.getByText('21.1K')).toBeInTheDocument()
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
})
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
expect(stop.credits).toBe(usdToCredits(stop.usd))
}
})
})

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import {
TransitionPresets,
usePreferredReducedMotion,
useTransition
} from '@vueuse/core'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
const {
disabled = false,
class: rootClass,
stops = TEAM_PLAN_CREDIT_STOPS,
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
cycle = 'yearly'
} = defineProps<{
disabled?: boolean
class?: HTMLAttributes['class']
/**
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
*/
stops?: readonly CreditStop[]
/**
* Stop selected when the bound value matches none (e.g. first render).
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
*/
defaultStopIndex?: number
/**
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
*/
cycle?: 'monthly' | 'yearly'
}>()
const emit = defineEmits<{
/** Fired when the selected stop changes, with the full derived payload. */
change: [stop: { index: number; usd: number; credits: number }]
}>()
/**
* v-model carries the selected USD value (one of the `stops`). The literal
* default keeps `defineModel` statically analyzable; when custom `stops` are
* passed without a matching v-model, `selectedIndex` falls back to
* `defaultStopIndex`, so the displayed stop is still correct.
*/
const usd = defineModel<number>({
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
})
const selectedIndex = computed(() => {
const i = stops.findIndex((stop) => stop.usd === usd.value)
if (i !== -1) return i
// Fall back to the default stop, clamped into range: a backend-driven `stops`
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
// clamping keeps `current` defined and the price computeds below from reading
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
})
const current = computed<CreditStop>(() => stops[selectedIndex.value])
// The discount applies to the monthly figure. Yearly uses the full
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
// shows the discounted monthly price, the struck pre-discount price, the
// saving, and — for yearly — the annual total.
const effectiveDiscountPercent = computed(() =>
cycle === 'monthly'
? current.value.discountPercentYearly / 2
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
/**
* Smoothly count the price figures up/down as the slider moves between stops
* instead of snapping. Honors the user's reduced-motion preference. The save
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
* tier, so animating the bracketed amount alone would read inconsistently.
*/
const prefersReducedMotion = usePreferredReducedMotion()
const priceTween = {
duration: 350,
easing: TransitionPresets.easeOutCubic,
disabled: computed(() => prefersReducedMotion.value === 'reduce')
}
const animatedMonthly = useTransition(discountedMonthly, priceTween)
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
// Derive the yearly total from the displayed monthly so it always reads as
// exactly 12× the price shown — even mid-count — rather than drifting as a
// second, independently-phased tween would.
const displayBilledYearly = computed(() => displayMonthly.value * 12)
/**
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
* model. Driving the slider in index space with `step = 1` guarantees the
* thumb can only land on the fixed stops — never a value in between.
*/
const sliderModel = computed<number[]>({
get: () => [selectedIndex.value],
set: ([index]) => {
const stop = stops[index]
if (!stop) return
usd.value = stop.usd
emit('change', { index, usd: stop.usd, credits: stop.credits })
}
})
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
const formatCreditsCompact = (value: number) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
const { t } = useI18n()
</script>
<template>
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
<!-- Price: discounted monthly + struck pre-discount + save badge -->
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}
</span>
<span
v-if="hasDiscount"
class="text-base text-muted-foreground tabular-nums line-through"
data-testid="credit-slider-original-price"
>
{{ formatUsd(displayOriginal) }}
</span>
<span class="text-base text-muted-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</span>
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
the right of the price; when the column narrows (mobile) it wraps
and aligns left under the price instead (DES QA). -->
<span
v-if="hasDiscount"
data-testid="credit-slider-save"
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
>
{{
t('subscription.creditSliderSave', {
percent: effectiveDiscountPercent,
amount: formatUsd(saveAmount)
})
}}
</span>
</div>
<p
class="m-0 text-sm text-muted-foreground tabular-nums"
data-testid="credit-slider-billed-yearly"
>
{{
cycle === 'monthly'
? t('subscription.billedMonthly')
: t('subscription.billedYearly', {
total: formatUsd(displayBilledYearly)
})
}}
</p>
</div>
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
<Slider
v-model="sliderModel"
:min="0"
:max="lastIndex"
:step="1"
:disabled="disabled"
range-class="bg-base-foreground"
thumb-class="bg-base-foreground"
/>
<!-- Credit stop labels; the selected stop is emphasized -->
<ol
data-testid="credit-slider-stops"
class="m-0 flex list-none justify-between p-0"
>
<li
v-for="(stop, i) in stops"
:key="stop.usd"
:data-selected="i === selectedIndex ? '' : undefined"
:class="
cn(
'flex items-center gap-1 text-xs tabular-nums',
i === selectedIndex
? 'font-semibold text-base-foreground'
: 'text-muted-foreground'
)
"
>
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"
aria-hidden="true"
/>
{{ formatCreditsCompact(stop.credits) }}
</li>
</ol>
</div>
</template>

View File

@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
// eslint-disable-next-line vue/no-unused-properties
SliderRootProps & { class?: HTMLAttributes['class'] }
SliderRootProps & {
class?: HTMLAttributes['class']
rangeClass?: HTMLAttributes['class']
thumbClass?: HTMLAttributes['class']
}
>()
const pressed = ref(false)
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
<SliderRange
data-slot="slider-range"
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
:class="
cn(
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
props.rangeClass
)
"
/>
</SliderTrack>
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
'cursor-grab',
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
{ 'cursor-grabbing': pressed },
props.thumbClass
)
"
/>

View File

@@ -225,6 +225,40 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
const { reportError } = useAuthActions()
// The backend wraps the rejection in a generic code; we match the token in
// the message, so it must win over the auth.errors.${code} fallback.
reportError(
new FirebaseError(
'auth/internal-error',
'Account creation is temporarily unavailable. (ref: signup_blocked)'
)
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('matches the signup_blocked token case-insensitively', () => {
const { reportError } = useAuthActions()
reportError(
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()

View File

@@ -47,6 +47,19 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (
error instanceof FirebaseError &&
error.message.toLowerCase().includes('signup_blocked')
) {
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
// rejections collapse the thrown code into a generic `auth/internal-error`,
// so the message is the only reliable channel. `signup_blocked` is a
// cross-repo contract token; matched case-insensitively.
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('auth.errors.signupBlocked')
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',

View File

@@ -5,11 +5,13 @@ import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
SubscriptionTier,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
@@ -71,6 +73,10 @@ export interface BillingState {
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
/** Team per-credit pricing ladder; null for personal/legacy. */
teamCreditStops: ComputedRef<TeamCreditStops | null>
/** The team's currently-subscribed credit stop; null for personal/legacy. */
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
@@ -83,5 +89,10 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,20 +1,39 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
} from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
mockPurchaseCredits,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
mockPurchaseCredits: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as BillingStatusResponse
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
@@ -103,12 +122,7 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
@@ -125,6 +139,7 @@ describe('useBillingContext', () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
@@ -252,4 +267,158 @@ describe('useBillingContext', () => {
expect(getMaxSeats('creator')).toBe(5)
})
})
describe('isLegacyTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isLegacyTeamPlan } = useBillingContext()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is true for an active team plan: team- slug and no credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is true for any legacy team tier, not just standard', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'ANNUAL',
plan_slug: 'team-pro-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a new credit-slider team subscriber', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
// omitted here — the predicate does not depend on it.)
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual',
team_credit_stop: {
id: 'team_700',
credits_monthly: 147700,
stop_usd: 700
}
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a new team sub even before its credit stop is populated', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Provisioning lag: credit stop not yet attached. The underscore slug
// (team_per_credit, not team-) must still exclude it from the legacy table.
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'canceled',
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual',
cancel_at: '2099-01-01T00:00:00Z'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'FREE',
plan_slug: 'team-free'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('matches the legacy slug case-insensitively', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'Team-Standard-Annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
})
})

View File

@@ -20,6 +20,12 @@ import type {
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
// new sub is never misrouted even before its credit stop is populated.
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
@@ -116,12 +122,32 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.currentPlanSlug)
)
const teamCreditStops = computed(() =>
toValue(activeContext.value.teamCreditStops)
)
const currentTeamCreditStop = computed(() =>
toValue(activeContext.value.currentTeamCreditStop)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
isActiveSubscription.value &&
!isFreeTier.value &&
currentTeamCreditStop.value === null &&
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false)
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -254,10 +280,13 @@ function useBillingContextInternal(): BillingContext {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
isFreeTier,
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -93,6 +93,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
const teamCreditStops = computed(() => null)
const currentTeamCreditStop = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
@@ -200,6 +202,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
})
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -0,0 +1,246 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -0,0 +1,249 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const pe = (
clientX: number,
clientY: number,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -0,0 +1,614 @@
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
interface InlineEditorState {
value: string
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const { width: containerWidth } = useElementSize(canvasContainer)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
return Math.max(0, Math.min(1, n))
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
ctx.fillRect(0, 0, W, H)
}
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
}
}

View File

@@ -21,6 +21,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -50,7 +51,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -62,7 +67,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -81,7 +90,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -103,7 +116,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'model'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -229,7 +246,11 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'image'
)
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
@@ -279,7 +300,11 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([lateNode.id]),
'value'
)
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -34,6 +34,7 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -83,7 +84,7 @@ function installNodeHooks(node: LGraphNode): void {
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name

View File

@@ -703,3 +703,55 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('Pre-remove vueNodeData drain', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('drops vueNodeData entry before node.onRemoved fires', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.has(String(node.id))).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
}
graph.remove(node)
expect(
dataPresentInOnRemoved,
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
).toBe(false)
})
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
const graph = new LGraph()
const nodeA = new LGraphNode('a')
const nodeB = new LGraphNode('b')
graph.add(nodeA)
graph.add(nodeB)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
const beforeRemovedSpy = vi.fn()
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
graph.clear()
expect(
beforeRemovedSpy,
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
).toHaveBeenCalledTimes(2)
expect(
vueNodeData.size,
'node:before-removed listener must drain vueNodeData when clear() removes every node'
).toBe(0)
})
})

View File

@@ -30,6 +30,8 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -82,6 +84,7 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -94,7 +97,7 @@ export interface SafeWidgetData {
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
@@ -137,7 +140,7 @@ export interface GraphNodeManager {
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
getNode(id: WorkflowNodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -211,7 +214,8 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
read_only: widget.options.read_only,
removable: widget.options.removable
}
}
@@ -225,7 +229,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
@@ -516,8 +520,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
}
const syncWithGraph = () => {
@@ -608,27 +612,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
/**
* Handles node removal from the graph - cleans up all references
*/
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
originalCallback?.(node)
}
/**
@@ -637,7 +634,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
@@ -645,15 +643,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
graph.events.removeEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
@@ -669,6 +669,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
}
graph.events.addEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
@@ -817,11 +827,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined
originalOnTrigger || undefined,
beforeNodeRemovedListener
)
}

View File

@@ -0,0 +1,184 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
import type {
CanvasPointerEvent,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
const {
mockShowNodeOptions,
mockUpdateSelectedItems,
mockGetCanvasContextMenuTarget
} = vi.hoisted(() => ({
mockShowNodeOptions: vi.fn(),
mockUpdateSelectedItems: vi.fn(),
mockGetCanvasContextMenuTarget: vi.fn<
() => { reroute?: unknown; group?: unknown }
>(() => ({}))
}))
vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({
showNodeOptions: mockShowNodeOptions
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ updateSelectedItems: mockUpdateSelectedItems })
}))
vi.mock('@/lib/litegraph/src/canvas/getCanvasContextMenuTarget', () => ({
getCanvasContextMenuTarget: mockGetCanvasContextMenuTarget
}))
interface StubCanvas {
graph: object
deselectAll: ReturnType<typeof vi.fn>
selectedItems: Set<unknown>
state: { selectionChanged: boolean }
}
describe('useGroupContextMenu', () => {
const event = fromPartial<CanvasPointerEvent>({ canvasX: 10, canvasY: 20 })
let group: {
id: number
selected?: boolean
recomputeInsideNodes: ReturnType<typeof vi.fn>
}
let legacyMenuMock: ReturnType<typeof vi.fn>
let stubCanvas: StubCanvas
beforeEach(() => {
vi.clearAllMocks()
LiteGraph.vueNodesMode = true
group = { id: 1, recomputeInsideNodes: vi.fn() }
mockGetCanvasContextMenuTarget.mockReturnValue({ group })
legacyMenuMock = vi.fn()
LGraphCanvas.prototype.processContextMenu = fromAny(legacyMenuMock)
useGroupContextMenu()
stubCanvas = {
graph: {},
deselectAll: vi.fn(),
selectedItems: new Set(),
state: { selectionChanged: false }
}
stubCanvas.deselectAll.mockImplementation(() => {
stubCanvas.selectedItems.clear()
})
})
function invoke(node: LGraphNode | undefined) {
LGraphCanvas.prototype.processContextMenu.call(
fromAny(stubCanvas),
node,
event
)
}
it('opens the Vue menu and selects only the group in Nodes 2.0 mode', () => {
invoke(undefined)
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
expect(group.selected).toBe(true)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(true)
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(mockUpdateSelectedItems.mock.invocationCallOrder[0]).toBeLessThan(
mockShowNodeOptions.mock.invocationCallOrder[0]
)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when a node is under the cursor', () => {
invoke(fromPartial<LGraphNode>({}))
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
it('falls through to the legacy menu in legacy (non-Nodes 2.0) mode', () => {
LiteGraph.vueNodesMode = false
invoke(undefined)
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when no group is under the cursor', () => {
mockGetCanvasContextMenuTarget.mockReturnValue({})
invoke(undefined)
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(0)
})
it('falls through to the legacy menu when the cursor is on a reroute', () => {
mockGetCanvasContextMenuTarget.mockReturnValue({
reroute: { id: 5 },
group
})
invoke(undefined)
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(0)
})
it('keeps the menu open without re-selecting when only the group is selected', () => {
group.selected = true
stubCanvas.selectedItems.add(group)
invoke(undefined)
expect(stubCanvas.deselectAll).not.toHaveBeenCalled()
expect(stubCanvas.selectedItems.size).toBe(1)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(false)
expect(group.recomputeInsideNodes).not.toHaveBeenCalled()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('reselects the group when selected child nodes would hide group actions', () => {
const childNode = { selected: true }
group.selected = true
stubCanvas.selectedItems.add(group)
stubCanvas.selectedItems.add(childNode)
invoke(undefined)
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
expect(stubCanvas.selectedItems.size).toBe(1)
expect(stubCanvas.selectedItems.has(group)).toBe(true)
expect(stubCanvas.state.selectionChanged).toBe(true)
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
expect(legacyMenuMock).not.toHaveBeenCalled()
})
it('falls through to the legacy menu when the canvas has no graph', () => {
LGraphCanvas.prototype.processContextMenu.call(
fromAny({ deselectAll: vi.fn() }),
undefined,
event
)
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
expect(legacyMenuMock).toHaveBeenCalledOnce()
expect(mockShowNodeOptions).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,49 @@
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { getCanvasContextMenuTarget } from '@/lib/litegraph/src/canvas/getCanvasContextMenuTarget'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Routes Nodes 2.0 group right-clicks to Vue while nodes, reroutes,
* background, and legacy mode stay on litegraph.
*/
export function useGroupContextMenu() {
const original = LGraphCanvas.prototype.processContextMenu
function processContextMenuWithVueGroupMenu(
this: LGraphCanvas,
...args: Parameters<typeof original>
): void {
const [node, event] = args
if (node || !LiteGraph.vueNodesMode || !this.graph) {
original.apply(this, args)
return
}
const { reroute, group } = getCanvasContextMenuTarget(
this,
event.canvasX,
event.canvasY
)
if (reroute || !group) {
original.apply(this, args)
return
}
const groupIsOnlySelection =
this.selectedItems.size === 1 && this.selectedItems.has(group)
if (!groupIsOnlySelection) {
this.deselectAll()
group.selected = true
group.recomputeInsideNodes()
this.selectedItems.add(group)
this.state.selectionChanged = true
}
useCanvasStore().updateSelectedItems()
showNodeOptions(event)
}
LGraphCanvas.prototype.processContextMenu = processContextMenuWithVueGroupMenu
}

View File

@@ -181,4 +181,24 @@ describe('useMaskEditorSaver', () => {
expect(store.nodeOutputs[locatorId]).toBeDefined()
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
})
it('omits subfolder from the upload FormData under the unified contract', async () => {
const fetchApiMock = vi.mocked(api.fetchApi)
const { save } = useMaskEditorSaver()
await save()
// The unified contract uploads to /upload/image with only image + type;
// subfolder is intentionally omitted (the server assigns it). Assert it
// here so the next reader knows the omission is deliberate, not accidental.
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
const [, init] = fetchApiMock.mock.calls[0]
const body = init?.body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
})

View File

@@ -1,3 +1,5 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -6,7 +8,6 @@ import type {
EditorOutputLayer,
ImageRef
} from '@/stores/maskEditorDataStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef
)
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
const actualPaintRef = await uploadLayer(outputData.paintLayer)
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
const actualPaintedMaskedRef = await uploadLayer(
outputData.paintedMaskedImage
)
outputData.maskedImage.ref = actualMaskedRef
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -280,23 +234,35 @@ export function useMaskEditorSaver() {
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
const body = await response.text().catch(() => '')
throw new Error(
`Failed to upload ${layer.ref.filename} (${response.status}${body ? `: ${body}` : ''})`
)
}
let data: UploadImageResponse
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
data = await response.json()
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
throw new Error(
`Invalid upload response for ${layer.ref.filename}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
)
}
return layer.ref
if (!data?.name) {
throw new Error(
`Upload response missing 'name' for ${layer.ref.filename}`
)
}
return {
filename: data.name,
subfolder: data.subfolder || '',
type: data.type || 'input'
}
}
async function updateNodePreview(
@@ -322,19 +288,8 @@ export function useMaskEditorSaver() {
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
// Widget value format differs between Cloud and OSS:
// - Cloud: JUST the filename (subfolder handled by backend)
// - OSS: subfolder/filename (traditional format)
let widgetValue: string
if (isCloud) {
widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
} else {
widgetValue =
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
mainRef.filename +
(mainRef.type ? ` [${mainRef.type}]` : '')
}
const widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
imageWidget.value = widgetValue

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn()
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'sd_xl_base_1.0.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
...overrides
}
}
describe('startModelNodeDragFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('starts a ghost drag for the resolved node carrying the widget value', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
const error = startModelNodeDragFromAsset(createAsset())
expect(error).toBeUndefined()
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'sidebar_drag'
})
})
it('threads the node-add source through to the drag', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'asset_browser'
})
})
it('carries no widget value when the provider has no key', () => {
const nodeDef = { name: 'FL_ChatterboxVC' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
startModelNodeDragFromAsset(
createAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: undefined,
source: 'sidebar_drag'
})
})
it('returns the resolution error and does not start a drag for an invalid asset', () => {
mockGetNodeProvider.mockReturnValue(null)
const error = startModelNodeDragFromAsset(createAsset())
expect(error?.code).toBe('NO_PROVIDER')
expect(mockStartDrag).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,38 @@
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { NodeAddSource } from '@/platform/telemetry/types'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
/**
* Arms a ghost drag for a model loader node. Providers with no widget key
* (auto-load nodes) start the drag without widget values.
*/
export function startModelLoaderDrag(
provider: ModelNodeProvider,
filename: string,
source: NodeAddSource = 'sidebar_drag'
) {
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
}
/**
* Starts a ghost drag for the model loader node described by an asset. The
* node is created where the user next clicks the canvas, with the asset's
* filename written into the loader widget.
*
* @returns the resolution error when the asset cannot be mapped to a node,
* otherwise `undefined`.
*/
export function startModelNodeDragFromAsset(
asset: AssetItem,
source: NodeAddSource = 'sidebar_drag'
): ResolveModelNodeError | undefined {
const resolved = resolveModelNodeFromAsset(asset)
if (!resolved.success) return resolved.error
const { provider, filename } = resolved.value
startModelLoaderDrag(provider, filename, source)
}

View File

@@ -7,7 +7,8 @@ const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas
mockCanvas,
mockToastAdd
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
@@ -15,6 +16,7 @@ const {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockToastAdd: vi.fn(),
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
})
afterEach(() => {
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
vi.restoreAllMocks()
})
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
cancelDrag()
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
describe('drag listener lifecycle', () => {
it('should attach document listeners on startDrag', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
const { startDrag } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
)
})
it('should only setup listeners once', () => {
it('should not attach drag listeners until a drag starts', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should detach document listeners on cancelDrag', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
})
it('should only attach listeners once across re-arms', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const callCount = addEventListenerSpy.mock.calls.length
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
bottom: 500
})
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should cancel drag on Escape key', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not cancel drag on other keys', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -277,6 +260,102 @@ describe('useNodeDragToCanvas', () => {
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should apply the requested widget values to the placed node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const widget = { name: 'ckpt_name', value: '' }
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(widget.value).toBe('model.safetensors')
})
it('should warn but still place the node when a requested widget is missing', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1, widgets: [] }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
detail: 'assetBrowser.failedToSetModelValue'
})
)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ckpt_name')
)
})
it('should show an error toast when the graph fails to add the node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'assetBrowser.failedToCreateNode'
})
)
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -286,9 +365,9 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -311,11 +390,8 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
@@ -341,7 +417,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
@@ -359,7 +435,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -377,7 +453,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'click')
startDrag(mockNodeDef)
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -392,14 +468,12 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
@@ -426,31 +500,29 @@ describe('useNodeDragToCanvas', () => {
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
it('should not stop propagation once the drag is cancelled', () => {
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
@@ -477,10 +549,8 @@ describe('useNodeDragToCanvas', () => {
}
it('should prefer tracked drag position over dragend coordinates', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
// dragend supplies a bad position (the Firefox bug); the tracked one
@@ -494,10 +564,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should ignore drag events with (0, 0)', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
fireDrag(0, 0)
@@ -510,10 +578,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should fall back to dragend coordinates when no drag fired', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
@@ -523,32 +589,14 @@ describe('useNodeDragToCanvas', () => {
})
})
it('should ignore dragover events fired before startDrag', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
fireDrag(250, 250)
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 300,
clientY: 300
})
})
it('should clear tracked position between drags', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
handleNativeDrop(1505, 102)
// Second drag - no drag events, so we should fall back to args.
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({

View File

@@ -1,23 +1,32 @@
import { ref, shallowRef } from 'vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import type { NodeAddSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
type WidgetValues = Record<string, string>
type Position = { x: number; y: number }
interface StartDragOptions {
mode?: DragMode
widgetValues?: WidgetValues
source?: NodeAddSource
}
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
const lastNativeDragPosition = shallowRef<Position>()
const pendingWidgetValues = shallowRef<WidgetValues>()
const pendingSource = ref<NodeAddSource>('sidebar_drag')
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
// Firefox dragend can report stale clientX/Y and `drag` can fire with
// (0, 0). dragover on the target reliably reports real client coords.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
@@ -27,11 +36,20 @@ function trackNativeDragPosition(e: DragEvent) {
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
for (const [name, value] of Object.entries(values)) {
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
console.error(`Widget ${name} not found on node ${node.type}`)
useToastStore().add({
severity: 'warn',
summary: t('g.warning'),
detail: t('assetBrowser.failedToSetModelValue')
})
continue
}
widget.value = value
}
}
function isOverCanvas(clientX: number, clientY: number): boolean {
@@ -59,10 +77,22 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
const node = withNodeAddSource(pendingSource.value, () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
if (!node) {
console.error(`Failed to add node to graph: ${nodeDef.name}`)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
return true
}
if (pendingWidgetValues.value)
applyWidgetValues(node, pendingWidgetValues.value)
canvas.selectItems([node])
return true
}
@@ -92,7 +122,6 @@ function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
@@ -103,22 +132,37 @@ function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('dragover', trackNativeDragPosition)
}
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
pendingWidgetValues.value = undefined
pendingSource.value = 'sidebar_drag'
cleanupGlobalListeners()
}
export function useNodeDragToCanvas() {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
function startDrag(
nodeDef: ComfyNodeDefImpl,
{
mode = 'click',
widgetValues,
source = 'sidebar_drag'
}: StartDragOptions = {}
) {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
pendingWidgetValues.value = widgetValues
pendingSource.value = source
setupGlobalListeners()
}
function handleNativeDrop(clientX: number, clientY: number) {
@@ -134,12 +178,9 @@ export function useNodeDragToCanvas() {
return {
isDragging,
draggedNode,
cursorPosition,
dragMode,
pendingWidgetValues,
startDrag,
cancelDrag,
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
handleNativeDrop
}
}

View File

@@ -124,7 +124,9 @@ describe('useNodePreviewAndDrag', () => {
expect(result.isDragging.value).toBe(true)
expect(result.isHovered.value).toBe(false)
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
mode: 'native'
})
expect(mockDataTransfer.effectAllowed).toBe('copy')
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
'application/x-comfy-node',

View File

@@ -125,7 +125,7 @@ export function useNodePreviewAndDrag(
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, 'native')
startDrag(nodeDef.value, { mode: 'native' })
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'

View File

@@ -137,6 +137,18 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
const setup = createSetup()
const parentGraph = setup.subgraphNode.graph!
parentGraph.add(setup.subgraphNode)
parentGraph.remove(setup.subgraphNode)
expect(setup.subgraphNode.graph).toBeNull()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(() => promotedPreviews.value).not.toThrow()
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -6,7 +6,11 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import {
appendNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
@@ -38,7 +42,7 @@ export function usePromotedPreviews(
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
leafExecutionId: NodeExecutionId,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
@@ -68,6 +72,7 @@ export function usePromotedPreviews(
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
if (node.isDetached) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
@@ -121,7 +126,7 @@ export function usePromotedPreviews(
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
interiorNode
)
if (!urls?.length) return []

View File

@@ -384,7 +384,63 @@ describe('usePainter', () => {
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
expect(result).toBe('painter/uploaded.png [temp]')
expect(result).toBe('uploaded.png [input]')
const [, init] = fetchApiMock.mock.calls[0]
const body = init?.body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
it('throws when the upload response is missing a name', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => ({})
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/missing 'name'/)
})
it('throws when the upload response body is not valid JSON', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token')
}
} as unknown as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {

View File

@@ -1,3 +1,4 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
@@ -12,7 +13,6 @@ import { hexToRgb } from '@/utils/colorUtil'
import type { Point } from '@/extensions/core/maskeditor/types'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -631,8 +631,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const name = `painter-${nodeId}-${Date.now()}.png`
const body = new FormData()
body.append('image', blob, name)
if (!isCloud) body.append('subfolder', 'painter')
body.append('type', isCloud ? 'input' : 'temp')
body.append('type', 'input')
let resp: Response
try {
@@ -650,15 +649,16 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
}
if (resp.status !== 200) {
const bodyText = await resp.text().catch(() => '')
const err = t('painter.uploadError', {
status: resp.status,
statusText: resp.statusText
statusText: bodyText || resp.statusText || 'unknown error'
})
toastStore.addAlert(err)
throw new Error(err)
}
let data: { name: string }
let data: UploadImageResponse
try {
data = await resp.json()
} catch (e) {
@@ -670,9 +670,13 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
throw new Error(err, { cause: e })
}
const result = isCloud
? `${data.name} [input]`
: `painter/${data.name} [temp]`
if (!data?.name) {
const detail = `Painter upload succeeded (${resp.status}) but response is missing 'name'`
toastStore.addAlert(detail)
throw new Error(detail)
}
const result = `${data.name} [input]`
modelValue.value = result
isDirty.value = false
return result

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
it('reorders via drag when the pointer crosses another swatch', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -0,0 +1,114 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
openPicker,
onPickerInput,
remove,
addColor,
onPointerDown
}
}

View File

@@ -5,11 +5,13 @@ import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useExternalLink } from '@/composables/useExternalLink'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type * as ModelStoreModule from '@/stores/modelStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { fromPartial } from '@total-typescript/shoehorn'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
@@ -135,6 +137,23 @@ vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
const mockAssetBrowse = vi.hoisted(() =>
vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>()
)
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse }))
}))
const mockStartModelNodeDrag = vi.hoisted(() => vi.fn())
vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({
startModelNodeDragFromAsset: mockStartModelNodeDrag
}))
const mockChangeTracker = vi.hoisted(() => ({
captureCanvasState: vi.fn()
}))
@@ -618,4 +637,47 @@ describe('useCoreCommands', () => {
expect(mockShowAbout).toHaveBeenCalled()
})
})
describe('BrowseModelAssets command', () => {
const asset = fromPartial<AssetItem>({ id: 'asset-1' })
async function selectAssetFromBrowser() {
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
const command = useCoreCommands().find(
(cmd) => cmd.id === 'Comfy.BrowseModelAssets'
)!
await command.function()
const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0]
onAssetSelected?.(asset)
}
it('starts a model node drag for the selected asset', async () => {
mockStartModelNodeDrag.mockReturnValue(undefined)
await selectAssetFromBrowser()
expect(mockStartModelNodeDrag).toHaveBeenCalledWith(
asset,
'asset_browser'
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows an error toast when the asset cannot start a drag', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockStartModelNodeDrag.mockReturnValue({
code: 'NO_PROVIDER',
message: 'No node provider registered',
assetId: 'asset-1'
})
await selectAssetFromBrowser()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
})

View File

@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
@@ -21,7 +22,6 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -1307,14 +1307,14 @@ export function useCoreCommands(): ComfyCommand[] {
assetType: 'models',
title: t('sideToolbar.modelLibrary'),
onAssetSelected: (asset) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
const error = startModelNodeDragFromAsset(asset, 'asset_browser')
if (error) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
console.error('Node creation failed:', result.error)
console.error('Node creation failed:', error)
}
}
})

View File

@@ -0,0 +1,443 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
export interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

View File

@@ -522,6 +522,22 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('returns false (does not throw) when SubgraphNode is detached', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {

View File

@@ -633,6 +633,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
if (subgraphNode.isDetached) return false
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>

View File

@@ -1,5 +1,9 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,7 +1,9 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -47,6 +49,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
widgetNamed(node, 'g.__row__1').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min are not removable', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
// Attempting to remove a protected row is a no-op.
widgetNamed(node, 'g.__row__0').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('canvas click removes a row only on the remove hit target', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const header = widgetNamed(node, 'g.__row__1')
const up = { type: 'pointerup' } as CanvasPointerEvent
const down = { type: 'pointerdown' } as CanvasPointerEvent
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
// Releasing away from the remove target does nothing.
header.mouse?.(up, [0, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// A pointerdown on the target does nothing (only release acts).
header.mouse?.(down, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Releasing on the target removes the row.
header.mouse?.(up, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
})
})

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