Compare commits

..

10 Commits

Author SHA1 Message Date
christian-byrne
cdde64a0a2 docs: weekly documentation accuracy update 2026-04-13 09:37:17 +00:00
Kelly Yang
bd82c855e0 test: add minimap E2E tests for graph content and click-to-navigate (#10738)
## Summary

Adds Playwright E2E tests verifying that 
1. the minimap canvas renders node content
2. clears when the graph is empty
3. correctly navigates the main canvas on click

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10738-test-add-minimap-E2E-tests-for-graph-content-and-click-to-navigate-3336d73d365081eb955ce711b3efc57f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding `data-testid` attributes to
the minimap UI and expanding Playwright E2E assertions, with no
production behavior changes expected.
> 
> **Overview**
> Strengthens minimap E2E coverage by switching existing assertions from
CSS selectors to new `data-testid`-based selectors and adding helper
utilities for canvas/overlay interactions.
> 
> Adds new Playwright tests that verify the minimap canvas renders
content when nodes exist, clears when the graph is emptied, and that
clicking the minimap pans the main canvas (including a
post-`fitViewToSelectionAnimated` tolerance check).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
06e7542af1. 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: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 04:28:04 +00:00
Kelly Yang
5b7ef3fe21 test: Painter Widget E2E Test Plan (#10846)
### Summary of Improvements

* **Custom Test Coverage Extension**: Enhanced the Painter widget E2E
test suite by refactoring logic for better maintainability and
robustness.
* **Stable Component Targeting**: Introduced
`data-testid="painter-dimension-text"` to `WidgetPainter.vue`, providing
a reliable, non-CSS-dependent locator for canvas size verification.
* **Improved Test Organization**: Reorganized existing test scenarios
into logical categories using `test.describe` blocks (Drawing, Brush
Settings, Canvas Size Controls, etc.).
* **Asynchronous Helper Integration**: Converted `hasCanvasContent` to
an asynchronous helper and unified its usage across multiple test cases
to eliminate redundant pixel-checking logic.
* **Locator Resilience**: Updated Reka UI slider interaction logic to
use more precise targeting (`:not([data-slot])`), preventing ambiguity
and improving test stability.
* **Scenario Refinement**: Updated the `pointerup` test logic to
accurately reflect pointer capture behavior when interactions occur
outside the canvas boundaries.
* **Enhanced Verification Feedback**: Added descriptive error messages
to `expect.poll` assertions to provide clearer context on potential
failure points.
* **Standardized Tagging**: Restored the original tagging strategy
(including `@smoke` and `@screenshot` tags) to ensure tests are
categorized correctly for CI environments.

### Red-Green Verification

| Commit | CI Status | Purpose |
| :--- | :--- | :--- |
| `test: refactor painter widget e2e tests and address review findings`
| 🟢 Green | Addresses all E2E test quality and stability issues from
review findings. |

### Test Plan

- [x] **Quality Checks**: `pnpm format`, `pnpm lint`, and `pnpm
typecheck` verified as passing.
- [x] **Component Integration**: `WidgetPainter.vue` `data-testid`
correctly applied and used in tests.
- [x] **Helper Reliability**: `hasCanvasContent` correctly identifies
colored pixels and returns a promise for `expect.poll`.
- [x] **Locator Robustness**: Verified Reka slider locators correctly
exclude internal thumb spans.
- [x] **Boundary Interaction**: Verified `pointerup` correctly ends
strokes when triggered outside the viewport.
- [x] **Tagging Consistency**: Verified `@smoke` and `@screenshot` tags
are present in the final test suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10846-test-Painter-Widget-E2E-Test-Plan-3386d73d365081deb70fe4afbd417efb)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds/refactors Playwright E2E tests and stable `data-testid`
hooks, with no changes to Painter drawing logic. Risk is limited to
potential test brittleness or minor UI attribute changes.
> 
> **Overview**
> Expands the Painter widget Playwright suite with new grouped scenarios
covering drawing/erasing behavior, tool switching, brush inputs, canvas
resizing (including preserving drawings), clear behavior, and
serialization/upload flows (including failure toast).
> 
> Refactors the tests to use a shared `@e2e/helpers/painter` module
(`drawStroke`, `hasCanvasContent`, `triggerSerialization`), improves
stability via role/testid-based locators and clearer `expect.poll`
messaging, and adds `data-testid` attributes (e.g.,
`painter-clear-button`, `painter-*-row`, `painter-dimension-text`) to
`WidgetPainter.vue` to avoid CSS-dependent selectors.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
053a8e9ed2. 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: GitHub Action <action@github.com>
2026-04-13 00:13:04 -04:00
Kelly Yang
85de833776 test: add E2E tests for ImageCompare widget (#10767)
## Summary
Add E2E tests for ImageCompare widget
Covers slider interaction, batch navigation, single-image modes, visual
regression screenshots, and edge cases for the ImageCompare Vue node
widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10767-test-add-E2E-tests-for-ImageCompare-widget-3346d73d365081c6bfc6fbd97fa04e4d)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Adds Playwright E2E coverage and screenshot assertions only; main risk
is increased CI runtime/flakiness due to additional image-loading and
hover/position polling.
> 
> **Overview**
> Adds a new Playwright E2E suite for the ImageCompare Vue widget
(tagged `@widget`) that programmatically sets widget values and asserts
rendering for empty, single-image, and dual-image states.
> 
> Expands coverage to **slider behavior** (default 50%, hover movement,
clamping, persistence) using polling on inline `clip-path`/handle
position, and adds **batch navigation** tests for multi-image
before/after sets.
> 
> Introduces **visual regression screenshots** at default and specific
slider positions, plus edge-case tests for broken URLs, rapid updates
resetting batch index, legacy string values, and custom alt text.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2c65440384. 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: GitHub Action <action@github.com>
2026-04-13 00:09:50 -04:00
Kelly Yang
cab46567c0 test: add E2E tests for ImageCropV2 widget (#10737)
## Summary
Adds Playwright E2E tests for the ImageCropV2 widget covering 
1. the empty state (no source image)
2. default control rendering
3. source image display with crop overlay
4. drag-to-reposition behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10737-test-add-E2E-tests-for-ImageCropV2-widget-3336d73d365081b28ed9db63e5df383e)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: primarily adds Playwright E2E coverage and introduces
`data-testid` attributes for more stable selectors, with no changes to
core crop behavior.
> 
> **Overview**
> Adds new Playwright E2E coverage for the `ImageCropV2` Vue-node
widget, including workflows/fixtures for a disconnected input and a
`LoadImage -> ImageCropV2 -> PreviewImage` pipeline.
> 
> Tests validate the empty state and default controls, verify the crop
overlay renders after execution with screenshot assertions, and exercise
drag-to-reposition by dispatching pointer events and asserting the
widget’s crop value updates.
> 
> Updates `WidgetImageCrop.vue` to add `data-testid` hooks (empty
state/icon and crop overlay) to make the E2E selectors stable.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9f29272742. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-12 23:58:01 -04:00
Comfy Org PR Bot
63435bdb34 1.44.3 (#11170)
Patch version increment to 1.44.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11170-1-44-3-3406d73d365081799aa4e189009d123b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-12 23:11:20 +00:00
Kelly Yang
20255da61f feat(load3d): add optional HDRI environment lighting to 3D preview nodes (#10818)
## Summary

Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel

## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model

## Screenshots



https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
> 
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
> 
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
> 
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. 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: Terry Jia <terryjia88@gmail.com>
2026-04-12 05:55:48 -04:00
Christian Byrne
c2dba8f4ee chore(#11080): consolidate duplicate rgbToHSL — use shared colorUtil (#11134)
## Summary

Consolidate duplicate `rgbToHSL` implementation — mask editor now uses
the shared `colorUtil.ts` version instead of its own copy.

## Changes

- Export `rgbToHsl` from `src/utils/colorUtil.ts` (was private)
- Replace 30-line local `rgbToHSL` in `useCanvasTools.ts` with a 2-line
wrapper that imports from `colorUtil.ts` and scales the return values
from 0-1 to degree/percentage

## Testing

### Automated

- All 176 existing tests pass (`colorUtil.test.ts` + `maskeditor/`
suite)
- No new tests needed — behavior is identical

### E2E Verification Steps

1. Open any image in the mask editor
2. Select the magic wand / color picker tool
3. Use HSL-based color matching — results should be identical to before

## Review Focus

The canonical `rgbToHsl` returns normalized 0-1 values while the mask
editor needs degree/percentage scale (h: 0-360, s: 0-100, l: 0-100). The
local wrapper handles this conversion.

Fixes #11080

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11134-chore-11080-consolidate-duplicate-rgbToHSL-use-shared-colorUtil-33e6d73d36508120bbd8f444f5cc94b6)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 01:40:55 +00:00
Alexander Brown
6f579c5992 fix: enable playwright/no-force-option lint rule (#11164)
## Summary

Enable the previously disabled `playwright/no-force-option` lint rule at
error level and resolve all 29 violations across 10 files.

## Changes

### Lint rule
- `.oxlintrc.json`: `playwright/no-force-option` changed from `off` to
`error`

### Shared utility
- `CanvasHelper.ts`: Add `mouseClickAt()` and `mouseDblclickAt()`
methods that convert canvas-element-relative positions to absolute page
coordinates and use `page.mouse` APIs, avoiding Playwright's locator
actionability checks that fail when Vue DOM overlays sit above the
`<canvas>` element

### Force removal (20 violations)
- `selectionToolboxActions.spec.ts`: Remove `force: true` from 8 toolbox
button clicks (the `pointer-events: none` splitter overlay does not
intercept `elementFromPoint()`)
- `selectionToolboxSubmenus.spec.ts`: Remove `force: true` from 2
popover menu item clicks
- `BuilderSelectHelper.ts`: Remove `force: true` from 2 widget/node
clicks (builder mode does not disable pointer events)
- `linkInteraction.spec.ts`: Remove `force: true` from 3 slot `dragTo()`
calls (`::after` pseudo-elements do not intercept `elementFromPoint()`)
- `SidebarTab.ts`: Remove `force: true` from toast dismissal (`.catch()`
already handles failures)
- `nodeHelp.spec.ts`: Remove `force: true` from info button click
(preceding `toBeVisible()` assertion is sufficient)

### Rewrites (3 violations)
- `integerWidget.spec.ts`: Replace force-clicking disabled buttons with
`toBeDisabled()` assertions
- `Topbar.ts`: Replace force-click with `waitFor({ state: 'visible' })`
after hover

### Canvas coordinate clicks (9 violations)
- `litegraphUtils.ts`: Convert `NodeReference.click()` and
`navigateIntoSubgraph()` to use
`canvasOps.mouseClickAt()`/`mouseDblclickAt()`
- `subgraphPromotion.spec.ts`: Convert 3 right-click canvas calls to
`canvasOps.mouseClickAt()`
- `selectionToolboxSubmenus.spec.ts`: Convert 1 canvas dismiss-click to
`canvasOps.mouseClickAt()`

## Rationale

The original `force: true` usages were added defensively based on
incorrect assumptions about the `z-999 pointer-events: none` splitter
overlay intercepting Playwright's actionability checks. In reality,
`elementFromPoint()` skips elements with `pointer-events: none`, so the
overlay is transparent to Playwright's hit-test.

For canvas coordinate clicks, `force: true` on a locator does not tunnel
through DOM overlays — it only skips Playwright's preflight checks.
`page.mouse.click()` is the correct API for coordinate-based canvas
interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11164-fix-enable-playwright-no-force-option-lint-rule-33f6d73d365081e78601c6114121d272)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 19:59:34 +00:00
Dante
e729e5edb8 fix: place cloned node above original in Vue renderer (#10361)
## Summary

Cloned/pasted nodes in Node 2.0 (Vue renderer) mode now appear above the
original node instead of behind it.

## Root Cause

The legacy LiteGraph canvas renderer uses array ordering for z-ordering:
nodes are stored in `graph._nodes` and drawn sequentially, so newly
added nodes (appended to the end) are automatically drawn on top. There
is no explicit z-index.

The Vue renderer (Node 2.0) uses explicit CSS `z-index` for node
ordering. New nodes default to `zIndex: 0` in `layoutMutations.ts`. When
a node has been interacted with, `bringNodeToFront` raises its z-index.
A cloned node at z-index 0 therefore appears behind any previously
interacted node.

The alt-click clone path in `LGraphNode.vue` already handles this
correctly by calling `bringNodeToFront()` after cloning. However, the
menu clone and keyboard paste paths go through `_deserializeItems` in
`LGraphCanvas.ts`, which does not set z-index for new nodes.

| Clone method | Legacy renderer | Vue renderer (before fix) | Vue
renderer (after fix) |
|---|---|---|---|
| Alt-click drag | On top (array order) | On top (`bringNodeToFront`
called) | On top |
| Right-click menu Clone | On top (array order) | Behind original
(z-index 0) | On top |
| Ctrl+C / Ctrl+V | On top (array order) | Behind original (z-index 0) |
On top |

## Steps to Reproduce

1. Enable Node 2.0 mode (Vue renderer) in settings
2. Add any node to the canvas
3. Click or drag the node (raises its z-index via `bringNodeToFront`)
4. Right-click the node and select "Clone"
5. **Expected**: Cloned node appears above the original, immediately
draggable
6. **Actual**: Cloned node appears behind the original; user must move
the original to access the clone

## Changes

After `batchUpdateNodeBounds` in `_deserializeItems`, calls
`bringNodeToFront` for each newly created node so they receive a z-index
above all existing nodes.

## Side Effect Analysis

Checked all call sites of `_deserializeItems`:

1. **Initial graph load / workflow open**: `loadGraphData` in `app.ts`
does NOT call `_deserializeItems`. Workflow loading goes through
`LGraph.configure()` which directly adds nodes and links. The layout
store is initialized separately via `initializeFromLiteGraph`. No side
effect.

2. **Paste from clipboard (Ctrl+V)**: Both `usePaste.ts` (line 52) and
`pasteFromClipboard` (line 4080) call `_deserializeItems`. Pasted nodes
appearing on top is the correct and desired behavior. No issue.

3. **Undo/Redo**: `ChangeTracker.updateState()` calls
`app.loadGraphData()`, which does a full graph reconfigure -- it does
NOT go through `_deserializeItems`. No side effect.

4. **Subgraph blueprint addition**: `litegraphService.ts` (line 906)
calls `_deserializeItems` when adding subgraph blueprints from the node
library. These are freshly placed nodes that should appear on top.
Desired behavior.

5. **Alt-click clone in LGraphNode.vue**: This path calls
`LGraphCanvas.cloneNodes()` -> `_deserializeItems()`, then separately
calls `bringNodeToFront()` again on line 433 of `LGraphNode.vue`. The
second call is now redundant (the node is already at max z-index), but
harmless -- `bringNodeToFront` finds the current max, adds 1, and sets.
The z-index will increment from N to N+1 on the second call. This is a
minor redundancy, not a bug.

6. **Performance**: `bringNodeToFront` iterates all nodes in the layout
store once per call (O(m)) to find max z-index. For n new nodes, the
total cost is O(n*m). In practice, clone/paste operations involve a
small number of nodes (typically 1-10), so this is negligible. For
extremely large pastes (100+ nodes), each call also increments the max
by 1, so z-indices will be sequential (which is actually a reasonable
stacking order).

7. **layoutStore availability**: `layoutStore` is a module-level
singleton (`new LayoutStoreImpl()`) -- not a Pinia store -- so it is
always available. The `useLayoutMutations()` composable is a plain
function returning an object of closures over `layoutStore`. It does not
require Vue component context. No risk of runtime errors.

8. **Legacy renderer (non-Vue mode)**: When Node 2.0 mode is disabled,
the layout store still exists but is not used for rendering. Calling
`bringNodeToFront` will update z-index values in the Yjs document that
are never read. This is harmless.

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `6894b99` `test:` | RED | Test asserts cloned node z-index > original.
Fails with `expected 0 to be greater than 5`. |
| `3567469` `fix:` | GREEN | Calls `bringNodeToFront` for each new node
in `_deserializeItems`. Test passes. |

Fixes #10307

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 12:12:37 +00:00
71 changed files with 3103 additions and 1304 deletions

View File

@@ -150,7 +150,7 @@
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "off",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",

View File

@@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally

View File

@@ -1,116 +0,0 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,50 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,100 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "ImageCropV2",
"pos": [450, 50],
"size": [400, 500],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 10,
"y": 10,
"width": 100,
"height": 100
}
]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 50],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
await btn.click().catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))

View File

@@ -71,7 +71,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.hover()
await tab.locator('.close-button').click({ force: true })
await tab.locator('.close-button').click()
}
getSaveDialog(): Locator {

View File

@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}

View File

@@ -74,6 +74,51 @@ export class CanvasHelper {
await this.nextFrame()
}
/**
* Convert a canvas-element-relative position to absolute page coordinates.
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
}
/**
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
* Bypasses Playwright's actionability checks on the canvas locator, which
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
*/
async mouseClickAt(
position: Position,
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const abs = await this.toAbsolute(position)
const modifiers = options?.modifiers ?? []
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.page.mouse.click(abs.x, abs.y, {
button: options?.button
})
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
}
/**
* Double-click at canvas-element-relative coordinates using `page.mouse`.
*/
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()

View File

@@ -19,15 +19,6 @@ export class KeyboardHelper {
await this.nextFrame()
}
async altSend(
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Alt+${keyToPress}`)
await this.nextFrame()
}
async selectAll(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyA', locator)
}
@@ -36,10 +27,6 @@ export class KeyboardHelper {
await this.ctrlSend('KeyB', locator)
}
async collapse(locator?: Locator | null): Promise<void> {
await this.altSend('KeyC', locator)
}
async undo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyZ', locator)
}

View File

@@ -1,10 +1,7 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -123,27 +120,6 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -214,13 +190,3 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -1,112 +0,0 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
const SELECTION_PADDING = 10
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
// For collapsed nodes, use DOM element size (matches selectionBorder.ts
// which reads layoutStore.collapsedSize in Vue mode)
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const el =
id && isCollapsed
? document.querySelector(`[data-node-id="${id}"]`)
: null
const w = el instanceof HTMLElement ? el.offsetWidth : rect[2]
const h = el instanceof HTMLElement ? el.offsetHeight : rect[3]
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + w)
maxY = Math.max(maxY, rect[1] + h)
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
// Legacy mode: no Vue DOM element, use boundingRect directly
if (!nodeEl) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (node) {
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -21,6 +21,10 @@ export const TestIds = {
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
minimapContainer: 'minimap-container',
minimapCanvas: 'minimap-canvas',
minimapViewport: 'minimap-viewport',
minimapInteractionOverlay: 'minimap-interaction-overlay',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
@@ -356,7 +355,11 @@ export class NodeReference {
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
moveMouseToEmptyArea?: boolean
}
) {
let clickPos: Position
switch (position) {
@@ -377,12 +380,7 @@ export class NodeReference {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
}
@@ -499,31 +497,18 @@ export class NodeReference {
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseDblclickAt(position)
if (await checkIsInSubgraph()) return
}

View File

@@ -0,0 +1,66 @@
import type { Locator, Page } from '@playwright/test'
import type { TestGraphAccess } from '@e2e/types/globals'
export async function drawStroke(
page: Page,
canvas: Locator,
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
): Promise<void> {
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
await page.mouse.move(
box.x + box.width * startXPct,
box.y + box.height * yPct
)
await page.mouse.down()
await page.mouse.move(
box.x + box.width * endXPct,
box.y + box.height * yPct,
{ steps: 10 }
)
await page.mouse.up()
}
export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
return canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const { data } = ctx.getImageData(0, 0, el.width, el.height)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
})
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
throw new Error(
'Global window.graph is absent. Ensure workflow fixture is loaded.'
)
}
const node = graph._nodes_by_id?.['1']
if (!node) {
throw new Error(
'Target node with ID "1" not found in graph._nodes_by_id.'
)
}
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
throw new Error('Widget "mask" not found on target node 1.')
}
if (typeof widget.serializeValue !== 'function') {
throw new Error(
'mask widget on node 1 does not have a serializeValue function.'
)
}
await widget.serializeValue(node, 0)
})
}

View File

@@ -1,9 +1,10 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Compare', () => {
test.describe('Image Compare', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
@@ -21,7 +22,12 @@ test.describe('Image Compare', () => {
async function setImageCompareValue(
comfyPage: ComfyPage,
value: { beforeImages: string[]; afterImages: string[] }
value: {
beforeImages: string[]
afterImages: string[]
beforeAlt?: string
afterAlt?: string
}
) {
await comfyPage.page.evaluate(
({ value }) => {
@@ -37,6 +43,48 @@ test.describe('Image Compare', () => {
await comfyPage.nextFrame()
}
async function moveToPercentage(
page: Page,
containerLocator: Locator,
percentage: number
) {
const box = await containerLocator.boundingBox()
if (!box) throw new Error('Container not found')
await page.mouse.move(
box.x + box.width * (percentage / 100),
box.y + box.height / 2
)
}
async function waitForImagesLoaded(node: Locator) {
await expect
.poll(() =>
node.evaluate((el) => {
const imgs = el.querySelectorAll('img')
return (
imgs.length > 0 &&
Array.from(imgs).every(
(img) => img.complete && img.naturalWidth > 0
)
)
})
)
.toBe(true)
}
async function getClipPathInsetRightPercent(imgLocator: Locator) {
return imgLocator.evaluate((el) => {
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
const parts = (el as HTMLElement).style.clipPath.split(' ')
return parts.length > 1 ? parseFloat(parts[1]) : -1
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -50,6 +98,10 @@ test.describe('Image Compare', () => {
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -71,11 +123,440 @@ test.describe('Image Compare', () => {
await expect(handle).toBeVisible()
expect(
await handle.evaluate((el) => (el as HTMLElement).style.left)
await handle.evaluate((el) => (el as HTMLElement).style.left),
'Slider should default to 50% before screenshot'
).toBe('50%')
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeCloseTo(50, 0)
await waitForImagesLoaded(node)
await comfyPage.page.mouse.move(0, 0)
await expect(node).toHaveScreenshot('image-compare-default-50.png')
}
)
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
await moveToPercentage(comfyPage.page, afterImg, 5)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeGreaterThan(90)
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeLessThan(10)
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
await moveToPercentage(comfyPage.page, afterImg, 95)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeLessThan(10)
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeGreaterThan(90)
}
)
test('Slider preserves last position when mouse leaves widget', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
await moveToPercentage(comfyPage.page, afterImg, 30)
// Wait for Vue to commit the slider update
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeCloseTo(30, 0)
const positionWhileInside = parseFloat(
await handle.evaluate((el) => (el as HTMLElement).style.left)
)
await comfyPage.page.mouse.move(0, 0)
// Position must not reset to default 50%
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeCloseTo(positionWhileInside, 0)
})
test('Slider clamps to 0% at left edge of container', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
const box = await afterImg.boundingBox()
if (!box) throw new Error('Container not found')
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
await expect
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
.toBe('0%')
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Before', '#c00')
await setImageCompareValue(comfyPage, {
beforeImages: [url],
afterImages: []
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
test('Only after image shows without slider when beforeImages is empty', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [],
afterImages: [url]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
const afterUrl = createTestImageDataUrl('B1', '#888')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
}
)
test('Batch navigation is hidden when both sides have single images', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Image', '#c00')
await setImageCompareValue(comfyPage, {
beforeImages: [url],
afterImages: [url]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId('batch-nav')).toBeHidden()
})
test(
'Navigate forward through before images',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [createTestImageDataUrl('B1', '#888')]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
)
await expect(prevBtn).toBeEnabled()
await nextBtn.click()
await expect(counter).toHaveText('3 / 3')
await expect(nextBtn).toBeDisabled()
}
)
test('Navigate backward through before images', async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [createTestImageDataUrl('B1', '#888')]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await nextBtn.click()
await expect(counter).toHaveText('3 / 3')
await prevBtn.click()
await expect(counter).toHaveText('2 / 3')
await expect(prevBtn).toBeEnabled()
await expect(nextBtn).toBeEnabled()
})
test('Before and after batch navigation are independent', async ({
comfyPage
}) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
const urlA = createTestImageDataUrl('B1', '#880')
const urlB = createTestImageDataUrl('B2', '#008')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [urlA, urlB]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
)
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
'src',
urlB
)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
]) {
test(
`Screenshot at ${pct}% slider position`,
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
await waitForImagesLoaded(node)
await moveToPercentage(comfyPage.page, afterImg, pct)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeGreaterThan(expectedClipMin)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeLessThan(expectedClipMax)
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
}
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget remains stable with broken image URLs', async ({
comfyPage
}) => {
await setImageCompareValue(comfyPage, {
beforeImages: ['https://example.invalid/broken.png'],
afterImages: ['https://example.invalid/broken2.png']
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(2)
await expect(node.getByRole('presentation')).toBeVisible()
await expect
.poll(() =>
node.evaluate((el) => {
const imgs = el.querySelectorAll('img')
let errors = 0
imgs.forEach((img) => {
if (img.complete && img.naturalWidth === 0 && img.src) errors++
})
return errors
})
)
.toBe(2)
})
test('Rapid value updates show latest images and reset batch index', async ({
comfyPage
}) => {
const redUrl = createTestImageDataUrl('Red', '#c00')
const green1Url = createTestImageDataUrl('G1', '#0c0')
const green2Url = createTestImageDataUrl('G2', '#090')
const blueUrl = createTestImageDataUrl('Blue', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [redUrl, green1Url],
afterImages: [blueUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
beforeImages: [green1Url, green2Url],
afterImages: [blueUrl]
})
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
green1Url
)
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('1 / 2')
})
test('Legacy string value shows single image without slider', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Legacy', '#c00')
await comfyPage.page.evaluate(
({ url }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = url
widget.callback?.(url)
}
},
{ url }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl],
beforeAlt: 'Custom before',
afterAlt: 'Custom after'
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
})
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
const images = Array.from({ length: 20 }, (_, i) =>
createTestImageDataUrl(String(i + 1), '#c00')
)
await setImageCompareValue(comfyPage, {
beforeImages: images,
afterImages: images
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('1 / 20')
await expect(
node.getByTestId('after-batch').getByTestId('batch-counter')
).toHaveText('1 / 20')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,8 +1,38 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
function hasCanvasContent(canvas: Locator): Promise<boolean> {
return canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const { data } = ctx.getImageData(0, 0, el.width, el.height)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
})
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
}
test.describe('Minimap', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -13,14 +43,20 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.locator('.minimap-viewport')
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
@@ -40,12 +76,16 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(toggleButton).toBeVisible()
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
@@ -60,7 +100,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
@@ -72,7 +114,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
await expect(minimap).toBeVisible()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
@@ -88,7 +130,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
@@ -105,14 +149,135 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
}
)
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
await expect
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const viewport = minimap.locator('.minimap-viewport')
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(viewport).toBeVisible()
await expect(async () => {

View File

@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await helpButton.click()
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')

View File

@@ -1,9 +1,17 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/helpers/painter'
test.describe('Painter', () => {
test.describe('Painter', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
await comfyPage.vueNodes.waitForNodes()
@@ -20,9 +28,15 @@ test.describe('Painter', () => {
await expect(painterWidget).toBeVisible()
await expect(painterWidget.locator('canvas')).toBeVisible()
await expect(painterWidget.getByText('Brush')).toBeVisible()
await expect(painterWidget.getByText('Eraser')).toBeVisible()
await expect(painterWidget.getByText('Clear')).toBeVisible()
await expect(
painterWidget.getByRole('button', { name: 'Brush' })
).toBeVisible()
await expect(
painterWidget.getByRole('button', { name: 'Eraser' })
).toBeVisible()
await expect(
painterWidget.getByTestId('painter-clear-button')
).toBeVisible()
await expect(
painterWidget.locator('input[type="color"]').first()
).toBeVisible()
@@ -39,22 +53,66 @@ test.describe('Painter', () => {
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
expect(await hasCanvasContent(canvas), 'canvas should start empty').toBe(
false
)
await drawStroke(comfyPage.page, canvas)
await expect
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
)
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should have content after stroke'
})
.toBe(true)
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
test.describe('Drawing', () => {
test(
'Eraser removes drawn content',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before erasing'
})
.toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
}
)
test('Stroke ends cleanly when pointer up fires outside canvas', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
@@ -68,29 +126,250 @@ test.describe('Painter', () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
)
.poll(() => hasCanvasContent(canvas), {
message:
'canvas should have content after stroke with pointer up outside'
})
.toBe(true)
})
})
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
test.describe('Tool selection', () => {
test('Tool switching toggles brush-only controls', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect(
painterWidget.getByTestId('painter-color-row'),
'color row should be hidden in eraser mode'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeHidden()
await painterWidget.getByRole('button', { name: 'Brush' }).click()
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeVisible()
})
})
test.describe('Brush settings', () => {
test('Size slider updates the displayed value', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const sizeRow = painterWidget.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
const sizeDisplay = sizeRow.getByTestId('painter-size-value')
await expect(sizeDisplay).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect(sizeDisplay).toHaveText('30')
})
test('Opacity input clamps out-of-range values', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const opacityInput = painterWidget
.getByTestId('painter-color-row')
.locator('input[type="number"]')
await opacityInput.fill('150')
await opacityInput.press('Tab')
await expect(opacityInput).toHaveValue('100')
await opacityInput.fill('-10')
await opacityInput.press('Tab')
await expect(opacityInput).toHaveValue('0')
})
})
test.describe('Canvas size controls', () => {
test('Width and height sliders visible without connected input', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(painterWidget.getByTestId('painter-width-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-height-row')
).toBeVisible()
await expect(
painterWidget.getByTestId('painter-dimension-text')
).toBeHidden()
})
test('Width slider resizes the canvas element', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const widthSlider = painterWidget
.getByTestId('painter-width-row')
.getByRole('slider')
const initialWidth = await canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)
expect(initialWidth, 'canvas should start at default width').toBe(512)
await widthSlider.focus()
await widthSlider.press('ArrowRight')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBe(576)
})
test(
'Resize preserves existing drawing',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const widthSlider = painterWidget
.getByTestId('painter-width-row')
.getByRole('slider')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before resize'
})
.toBe(true)
await widthSlider.focus()
await widthSlider.press('ArrowRight')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBe(576)
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await expect(node).toHaveScreenshot('painter-after-resize.png')
}
)
})
test.describe('Clear', () => {
test(
'Clear removes all drawn content',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before clear'
})
.toBe(true)
const clearButton = painterWidget.getByTestId('painter-clear-button')
await clearButton.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should be clear after click'
})
.toBe(false)
}
)
})
test.describe('Serialization', () => {
test('Drawing triggers upload on serialization', async ({ comfyPage }) => {
const mockUploadResponse: UploadImageResponse = {
name: 'painter-test.png'
}
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUploadResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas does not upload on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {
await comfyPage.page.route('**/upload/image', async (route) => {
await route.fulfill({ status: 500 })
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await expect(triggerSerialization(comfyPage.page)).rejects.toThrow()
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,234 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function userToggleCollapse(
comfyPage: ComfyPage,
nodeRef: NodeReference
) {
await nodeRef.click('title')
await comfyPage.keyboard.collapse()
}
async function userToggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
await comfyPage.keyboard.bypass()
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
}
}
)
test.describe(
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('collapsed node narrows bounding box when bypass is removed', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
test('collapsed node widens bounding box when bypass is added', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const state of nodeStates) {
for (const pos of positions) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
}
)

View File

@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
await expect
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await comfyPage.page
await expect(
comfyPage.selectionToolbox.getByRole('button', {
name: /Frame Nodes/i
})
).toBeVisible()
await comfyPage.selectionToolbox
.getByRole('button', { name: /Frame Nodes/i })
.click({ force: true })
.click()
await comfyPage.nextFrame()
await expect

View File

@@ -62,7 +62,7 @@ test.describe(
return
}
await moreOptionsBtn.click({ force: true })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
@@ -126,9 +126,7 @@ test.describe(
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
await comfyPage.page.getByText('Rename', { exact: true }).click()
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
@@ -153,11 +151,7 @@ test.describe(
await comfyPage.nextFrame()
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeHidden()

View File

@@ -199,12 +199,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
@@ -235,12 +230,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
@@ -266,12 +256,7 @@ test.describe(
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -94,6 +94,7 @@ async function connectSlots(
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await fromLoc.dragTo(toLoc, { force: true })
await nextFrame()
}
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -0,0 +1,162 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type CropValue = { x: number; y: number; width: number; height: number } | null
test.describe('Image Crop', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.describe('without source image', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect.soft(node.getByTestId('crop-empty-icon')).toBeVisible()
await expect.soft(node).toContainText('No input image connected')
await expect.soft(node.getByTestId('crop-overlay')).toHaveCount(0)
await expect.soft(node.locator('img')).toHaveCount(0)
}
)
test(
'Renders controls in default state',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
).toBeVisible()
await expect(node.locator('input')).toHaveCount(4)
}
)
})
test.describe(
'with source image after execution',
{ tag: ['@widget', '@slow'] },
() => {
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
return comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(2)
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
const v = w?.value
if (v && typeof v === 'object' && 'x' in v) {
const crop = v as {
x: number
y: number
width: number
height: number
}
return {
x: crop.x,
y: crop.y,
width: crop.width,
height: crop.height
}
}
return null
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeLocator('2').locator('img')
).toBeVisible({ timeout: 30_000 })
})
test(
'Displays source image with crop overlay after execution',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await expect
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
.toBeGreaterThan(0)
await expect(node.getByTestId('crop-overlay')).toBeVisible()
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Drag crop box updates crop position',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const cropBox = node.getByTestId('crop-overlay')
const box = await cropBox.boundingBox()
if (!box) throw new Error('Crop box not found')
const valueBefore = await getCropValue(comfyPage)
if (!valueBefore)
throw new Error('Widget value missing — check fixture setup')
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
await cropBox.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: startY
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + 15,
clientY: startY + 10
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + 30,
clientY: startY + 20
})
await cropBox.dispatchEvent('pointerup', {
...pointerOpts,
clientX: startX + 30,
clientY: startY + 20
})
await comfyPage.nextFrame()
await expect(async () => {
const valueAfter = await getCropValue(comfyPage)
expect(valueAfter?.x).toBeGreaterThan(valueBefore.x)
expect(valueAfter?.y).toBeGreaterThan(valueBefore.y)
expect(valueAfter?.width).toBe(valueBefore.width)
expect(valueAfter?.height).toBe(valueBefore.height)
}).toPass({ timeout: 5000 })
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
maxDiffPixelRatio: 0.05
})
}
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.incrementButton).toBeDisabled()
await expect(controls.decrementButton).toBeDisabled()
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()

View File

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

View File

@@ -17,8 +17,12 @@
<div
v-else-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
data-testid="crop-empty-state"
>
<i class="mb-2 icon-[lucide--image] size-12" />
<i
class="mb-2 icon-[lucide--image] size-12"
data-testid="crop-empty-icon"
/>
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
@@ -43,6 +47,7 @@
)
"
:style="cropBoxStyle"
data-testid="crop-overlay"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"

View File

@@ -27,6 +27,7 @@
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -139,6 +140,7 @@ const {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -6,19 +6,21 @@
@pointerup.stop
@wheel.stop
>
<div class="show-menu relative">
<div class="relative">
<Button
ref="menuTriggerRef"
variant="textonly"
size="icon"
:aria-label="$t('menu.showMenu')"
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-base-foreground" />
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
</Button>
<div
v-show="isMenuOpen"
ref="menuPanelRef"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
@@ -42,7 +44,6 @@
</div>
</div>
</div>
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
@@ -51,6 +52,9 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@update-background-image="handleBackgroundImageUpdate"
/>
@@ -70,11 +74,19 @@
v-model:fov="cameraConfig!.fov"
/>
<LightControls
v-if="showLightControls"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
/>
<div v-if="showLightControls" class="flex flex-col">
<LightControls
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
v-model:hdri-config="lightConfig!.hdri"
/>
<HDRIControls
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
/>
</div>
<ExportControls
v-if="showExportControls"
@@ -85,10 +97,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -117,6 +131,17 @@ const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isMenuOpen = ref(false)
const menuPanelRef = ref<HTMLElement | null>(null)
const menuTriggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: isMenuOpen,
getOverlayEl: () => menuPanelRef.value,
getTriggerEl: () => menuTriggerRef.value?.$el ?? null,
onDismiss: () => {
isMenuOpen.value = false
}
})
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
@@ -160,21 +185,26 @@ const selectCategory = (category: string) => {
isMenuOpen.value = false
}
const categoryIcons = {
scene: 'icon-[lucide--image]',
model: 'icon-[lucide--box]',
camera: 'icon-[lucide--camera]',
light: 'icon-[lucide--sun]',
export: 'icon-[lucide--download]'
} as const
const getCategoryIcon = (category: string) => {
const icons = {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-base-foreground text-lg`
const icon =
category in categoryIcons
? categoryIcons[category as keyof typeof categoryIcons]
: 'icon-[lucide--circle]'
return cn(icon, 'text-lg text-base-foreground')
}
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -185,19 +215,7 @@ const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div v-if="!hasBackgroundImage || hdriConfig?.hdriPath" class="flex flex-col">
<Button
v-tooltip.right="{
value: hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="
hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile')
"
@click="triggerFileInput"
>
<i class="icon-[lucide--upload] text-lg text-base-foreground" />
</Button>
<template v-if="hdriConfig?.hdriPath">
<Button
v-tooltip.right="{
value: $t('load3d.hdri.label'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn('rounded-full', hdriConfig?.enabled && 'ring-2 ring-white/50')
"
:aria-label="$t('load3d.hdri.label')"
@click="toggleEnabled"
>
<i class="icon-[lucide--globe] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.showAsBackground'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn(
'rounded-full',
hdriConfig?.showAsBackground && 'ring-2 ring-white/50'
)
"
:aria-label="$t('load3d.hdri.showAsBackground')"
@click="toggleShowAsBackground"
>
<i class="icon-[lucide--image] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.removeFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.hdri.removeFile')"
@click="onRemoveHDRI"
>
<i class="icon-[lucide--x] text-lg text-base-foreground" />
</Button>
</template>
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
@change="onFileChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
SUPPORTED_HDRI_EXTENSIONS,
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { hasBackgroundImage = false } = defineProps<{
hasBackgroundImage?: boolean
}>()
const hdriConfig = defineModel<HDRIConfig>('hdriConfig')
const emit = defineEmits<{
(e: 'updateHdriFile', file: File | null): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
if (file) {
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
return
}
}
emit('updateHdriFile', file)
}
function toggleEnabled() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
enabled: !hdriConfig.value.enabled
}
}
function toggleShowAsBackground() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
showAsBackground: !hdriConfig.value.showAsBackground
}
}
function onRemoveHDRI() {
emit('updateHdriFile', null)
}
</script>

View File

@@ -1,7 +1,24 @@
<template>
<div class="flex flex-col">
<div v-if="showLightIntensityButton" class="show-light-intensity relative">
<div
v-if="embedded && showIntensityControl"
class="flex w-[200px] flex-col gap-2 rounded-lg bg-black/50 p-3 shadow-lg"
>
<span class="text-sm font-medium text-base-foreground">{{
$t('load3d.lightIntensity')
}}</span>
<Slider
:model-value="sliderValue"
class="w-full"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
<div v-else-if="showIntensityControl" class="relative">
<Button
ref="triggerRef"
v-tooltip.right="{
value: $t('load3d.lightIntensity'),
showDelay: 300
@@ -12,19 +29,20 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-base-foreground" />
<i class="icon-[lucide--sun] text-lg text-base-foreground" />
</Button>
<div
v-show="showLightIntensity"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
ref="panelRef"
class="absolute top-0 left-12 w-[200px] rounded-lg bg-black/50 p-3 shadow-lg"
>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
@@ -32,20 +50,30 @@
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import Slider from '@/components/ui/slider/Slider.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type {
HDRIConfig,
MaterialMode
} from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const materialMode = defineModel<MaterialMode>('materialMode')
const hdriConfig = defineModel<HDRIConfig | undefined>('hdriConfig')
const showLightIntensityButton = computed(
() => materialMode.value === 'original'
const { embedded = false } = defineProps<{
embedded?: boolean
}>()
const usesHdriIntensity = computed(
() => !!hdriConfig.value?.hdriPath?.length && !!hdriConfig.value?.enabled
)
const showLightIntensity = ref(false)
const showIntensityControl = computed(() => materialMode.value === 'original')
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
@@ -57,23 +85,49 @@ const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderMin = computed(() =>
usesHdriIntensity.value ? 0 : lightIntensityMinimum
)
const sliderMax = computed(() =>
usesHdriIntensity.value ? 5 : lightIntensityMaximum
)
const sliderStep = computed(() =>
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
)
const sliderValue = computed(() => {
if (usesHdriIntensity.value) {
return [hdriConfig.value?.intensity ?? 1]
}
return [lightIntensity.value ?? lightIntensityMinimum]
})
const showLightIntensity = ref(false)
const panelRef = ref<HTMLElement | null>(null)
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: showLightIntensity,
getOverlayEl: () => panelRef.value,
getTriggerEl: () => triggerRef.value?.$el ?? null,
onDismiss: () => {
showLightIntensity.value = false
}
})
function toggleLightIntensity() {
showLightIntensity.value = !showLightIntensity.value
}
function closeLightSlider(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
function onSliderUpdate(value: number[] | undefined) {
if (!value?.length) return
const next = value[0]
if (usesHdriIntensity.value) {
const h = hdriConfig.value
if (!h) return
hdriConfig.value = { ...h, intensity: next }
} else {
lightIntensity.value = next
}
}
onMounted(() => {
document.addEventListener('click', closeLightSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeLightSlider)
})
</script>

View File

@@ -11,53 +11,55 @@
<i class="pi pi-table text-lg text-base-foreground" />
</Button>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<template v-if="!hdriActive">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
</template>
<div v-if="hasBackgroundImage">
<Button
@@ -112,6 +114,10 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
}>()
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()

View File

@@ -38,6 +38,7 @@
<div
v-if="isImageInputConnected"
class="text-center text-xs text-muted-foreground"
data-testid="painter-dimension-text"
>
{{ canvasWidth }} x {{ canvasHeight }}
</div>
@@ -100,6 +101,7 @@
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
data-testid="painter-size-row"
>
<Slider
:model-value="[brushSize]"
@@ -109,9 +111,12 @@
class="flex-1"
@update:model-value="(v) => v?.length && (brushSize = v[0])"
/>
<span class="text-node-text-muted w-8 text-center text-xs">{{
brushSize
}}</span>
<span
class="text-node-text-muted w-8 text-center text-xs"
data-testid="painter-size-value"
>
{{ brushSize }}
</span>
</div>
<template v-if="tool === PAINTER_TOOLS.BRUSH">
@@ -123,6 +128,7 @@
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
data-testid="painter-color-row"
>
<input
type="color"
@@ -166,6 +172,7 @@
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
data-testid="painter-hardness-row"
>
<Slider
:model-value="[brushHardnessPercent]"
@@ -192,6 +199,7 @@
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
data-testid="painter-width-row"
>
<Slider
:model-value="[canvasWidth]"
@@ -214,6 +222,7 @@
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
data-testid="painter-height-row"
>
<Slider
:model-value="[canvasHeight]"
@@ -255,6 +264,7 @@
<Button
variant="secondary"
size="md"
data-testid="painter-clear-button"
:class="
cn(
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',

View File

@@ -5,7 +5,6 @@ import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -72,24 +71,6 @@ function useVueNodeLifecycleIndividual() {
nodeManager.value = null
}
// Wire up LiteGraph.getCollapsedSize callback in the renderer layer
// (kept out of useVueFeatureFlags to avoid a platform → renderer import)
watch(
shouldRenderVueNodes,
() => {
LiteGraph.getCollapsedSize = shouldRenderVueNodes.value
? (nodeId) => {
try {
return layoutStore.getNodeCollapsedSize(String(nodeId))
} catch {
return undefined
}
}
: undefined
},
{ immediate: true }
)
// Watch for Vue nodes enabled state changes
watch(
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),

View File

@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
import { rgbToHsl } from '@/utils/colorUtil'
const getPixelAlpha = (
data: Uint8ClampedArray,
@@ -47,39 +48,8 @@ const rgbToHSL = (
g: number,
b: number
): { h: number; s: number; l: number } => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
const hsl = rgbToHsl({ r, g, b })
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
}
const rgbToLab = (rgb: {

View File

@@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn()
uploadFile: vi.fn(),
mapSceneLightIntensityToHdri: vi.fn(
(scene: number, min: number, max: number) => {
const span = max - min
const t = span > 0 ? (scene - min) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
)
}
}))
@@ -72,7 +82,13 @@ describe('useLoad3d', () => {
state: null
},
'Light Config': {
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
},
'Resource Folder': ''
},
@@ -122,6 +138,11 @@ describe('useLoad3d', () => {
isPlyModel: vi.fn().mockReturnValue(false),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
loadHDRI: vi.fn().mockResolvedValue(undefined),
setHDRIEnabled: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIIntensity: vi.fn(),
clearHDRI: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
@@ -167,7 +188,13 @@ describe('useLoad3d', () => {
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
@@ -476,7 +503,7 @@ describe('useLoad3d', () => {
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toEqual({
expect(mockNode.properties['Light Config']).toMatchObject({
intensity: 10
})
})
@@ -912,6 +939,97 @@ describe('useLoad3d', () => {
})
})
describe('hdri controls', () => {
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
}
await nextTick()
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
})
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
}
await nextTick()
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
await composable.handleHDRIFileUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 1
}
}
await composable.handleHDRIFileUpdate(null)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)

View File

@@ -1,6 +1,7 @@
import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -24,6 +25,7 @@ import type {
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
})
const lightConfig = ref<LightConfig>({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
const isRecording = ref(false)
const hasRecording = ref(false)
@@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const savedLightConfig = node.properties['Light Config'] as LightConfig
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
if (savedLightConfig) {
lightConfig.value = savedLightConfig
lightConfig.value = {
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
hdri: {
...lightConfig.value.hdri!,
...savedLightConfig.hdri,
enabled: false
}
}
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
const hdri = lightConfig.value.hdri
let hdriLoaded = false
if (hdri?.hdriPath) {
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(hdri.hdriPath),
'input'
)
)
try {
await load3d.loadHDRI(hdriUrl)
hdriLoaded = true
} catch (error) {
console.warn('Failed to restore HDRI:', error)
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
}
}
}
if (hdriLoaded && savedHdriEnabled) {
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, enabled: true }
}
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
@@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
applySceneConfigToLoad3d()
applyLightConfigToLoad3d()
}
const applySceneConfigToLoad3d = () => {
if (!load3d) return
const cfg = sceneConfig.value
load3d.toggleGrid(cfg.showGrid)
if (!lightConfig.value.hdri?.enabled) {
load3d.setBackgroundColor(cfg.backgroundColor)
}
if (cfg.backgroundRenderMode) {
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
}
}
const applyLightConfigToLoad3d = () => {
if (!load3d) return
const cfg = lightConfig.value
load3d.setLightIntensity(cfg.intensity)
const hdri = cfg.hdri
if (!hdri) return
load3d.setHDRIIntensity(hdri.intensity)
load3d.setHDRIAsBackground(hdri.showAsBackground)
load3d.setHDRIEnabled(hdri.enabled)
}
const persistLightConfigToNode = () => {
const n = nodeRef.value
if (n) {
n.properties['Light Config'] = lightConfig.value
}
}
const getModelUrl = (modelPath: string): string | null => {
@@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
sceneConfig,
async (newValue) => {
if (load3d && nodeRef.value) {
(newValue) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
load3d.toggleGrid(newValue.showGrid)
load3d.setBackgroundColor(newValue.backgroundColor)
await load3d.setBackgroundImage(newValue.backgroundImage || '')
if (newValue.backgroundRenderMode) {
load3d.setBackgroundRenderMode(newValue.backgroundRenderMode)
}
}
},
{ deep: true }
)
watch(
() => sceneConfig.value.showGrid,
(showGrid) => {
load3d?.toggleGrid(showGrid)
}
)
watch(
() => sceneConfig.value.backgroundColor,
(color) => {
if (!load3d || lightConfig.value.hdri?.enabled) return
load3d.setBackgroundColor(color)
}
)
watch(
() => sceneConfig.value.backgroundImage,
async (image) => {
if (!load3d) return
await load3d.setBackgroundImage(image || '')
}
)
watch(
() => sceneConfig.value.backgroundRenderMode,
(mode) => {
if (mode) load3d?.setBackgroundRenderMode(mode)
}
)
watch(
modelConfig,
(newValue) => {
@@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
)
watch(
lightConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Light Config'] = newValue
load3d.setLightIntensity(newValue.intensity)
() => lightConfig.value.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (!lightConfig.value.hdri?.enabled) {
lastNonHdriLightIntensity.value = intensity
}
},
{ deep: true }
persistLightConfigToNode()
load3d.setLightIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (intensity === undefined) return
persistLightConfigToNode()
load3d.setHDRIIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.showAsBackground,
(show) => {
if (!load3d || !nodeRef.value) return
if (show === undefined) return
persistLightConfigToNode()
load3d.setHDRIAsBackground(show)
}
)
watch(
() => lightConfig.value.hdri?.enabled,
(enabled, prevEnabled) => {
if (!load3d || !nodeRef.value) return
if (enabled === undefined) return
if (enabled && prevEnabled === false) {
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
if (!enabled && prevEnabled === true) {
lightConfig.value = {
...lightConfig.value,
intensity: lastNonHdriLightIntensity.value
}
}
persistLightConfigToNode()
load3d.setHDRIEnabled(enabled)
}
)
watch(playing, (newValue) => {
@@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const handleHDRIFileUpdate = async (file: File | null) => {
const capturedLoad3d = load3d
if (!capturedLoad3d) return
if (!file) {
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
capturedLoad3d.clearHDRI()
return
}
const resourceFolder =
(nodeRef.value?.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
return
}
// Re-validate: node may have been removed during upload
if (load3d !== capturedLoad3d) return
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadedPath),
'input'
)
)
try {
loading.value = true
loadingMessage.value = t('load3d.loadingHDRI')
await capturedLoad3d.loadHDRI(hdriUrl)
if (load3d !== capturedLoad3d) return
let sceneMin = 1
let sceneMax = 10
if (getActivePinia() != null) {
const settingStore = useSettingStore()
sceneMin = settingStore.get(
'Comfy.Load3D.LightIntensityMinimum'
) as number
sceneMax = settingStore.get(
'Comfy.Load3D.LightIntensityMaximum'
) as number
}
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
lightConfig.value.intensity,
sceneMin,
sceneMax
)
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: uploadedPath,
enabled: true,
showAsBackground: true,
intensity: mappedHdriIntensity
}
}
} catch (error) {
console.error('Failed to load HDRI:', error)
capturedLoad3d.clearHDRI()
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -0,0 +1,223 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HDRIManager } from './HDRIManager'
import Load3dUtils from './Load3dUtils'
const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({
mockFromEquirectangular: vi.fn(),
mockDisposePMREM: vi.fn()
}))
vi.mock('./Load3dUtils', () => ({
default: {
getFilenameExtension: vi.fn()
}
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class MockPMREMGenerator {
compileEquirectangularShader = vi.fn()
fromEquirectangular = mockFromEquirectangular
dispose = mockDisposePMREM
}
return { ...actual, PMREMGenerator: MockPMREMGenerator }
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => {
class EXRLoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { EXRLoader }
})
vi.mock('three/examples/jsm/loaders/RGBELoader', () => {
class RGBELoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { RGBELoader }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
describe('HDRIManager', () => {
let scene: THREE.Scene
let eventManager: ReturnType<typeof makeMockEventManager>
let manager: HDRIManager
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
eventManager = makeMockEventManager()
mockFromEquirectangular.mockReturnValue({
texture: new THREE.Texture(),
dispose: vi.fn()
})
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts disabled with default intensity', () => {
expect(manager.isEnabled).toBe(false)
expect(manager.showAsBackground).toBe(false)
expect(manager.intensity).toBe(1)
})
})
describe('loadHDRI', () => {
it('loads .exr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr')
await expect(
manager.loadHDRI('http://example.com/env.exr')
).resolves.toBeUndefined()
})
it('loads .hdr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await expect(
manager.loadHDRI('http://example.com/env.hdr')
).resolves.toBeUndefined()
})
it('applies to scene immediately when already enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
manager.setEnabled(true)
// No texture loaded yet so scene.environment stays null
expect(scene.environment).toBeNull()
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).not.toBeNull()
})
it('does not apply to scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).toBeNull()
})
})
describe('setEnabled', () => {
it('applies environment map to scene when enabled after loading', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
expect(scene.environment).not.toBeNull()
expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', {
enabled: true,
showAsBackground: false
})
})
it('removes environment map from scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setEnabled(false)
expect(scene.environment).toBeNull()
expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', {
enabled: false,
showAsBackground: false
})
})
})
describe('setIntensity', () => {
it('updates scene intensity when enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setIntensity(2.5)
expect(scene.environmentIntensity).toBe(2.5)
expect(manager.intensity).toBe(2.5)
})
it('stores intensity without applying when disabled', () => {
manager.setIntensity(3)
expect(manager.intensity).toBe(3)
expect(scene.environmentIntensity).not.toBe(3)
})
})
describe('setShowAsBackground', () => {
it('sets scene background texture when enabled and showing as background', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
expect(scene.background).not.toBeNull()
})
it('clears scene background when showAsBackground is false', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
manager.setShowAsBackground(false)
expect(scene.background).toBeNull()
})
})
describe('clear', () => {
it('removes HDRI from scene and resets state', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.clear()
expect(manager.isEnabled).toBe(false)
expect(scene.environment).toBeNull()
})
})
describe('dispose', () => {
it('disposes PMREMGenerator', () => {
manager.dispose()
expect(mockDisposePMREM).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,142 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import Load3dUtils from './Load3dUtils'
import type { EventManagerInterface } from './interfaces'
export class HDRIManager {
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private pmremGenerator: THREE.PMREMGenerator
private eventManager: EventManagerInterface
private hdriTexture: THREE.Texture | null = null
private envMapTarget: THREE.WebGLRenderTarget | null = null
private _isEnabled: boolean = false
private _showAsBackground: boolean = false
private _intensity: number = 1
get isEnabled() {
return this._isEnabled
}
get showAsBackground() {
return this._showAsBackground
}
get intensity() {
return this._intensity
}
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.scene = scene
this.renderer = renderer
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
this.pmremGenerator.compileEquirectangularShader()
this.eventManager = eventManager
}
async loadHDRI(url: string): Promise<void> {
const ext = Load3dUtils.getFilenameExtension(url)
let newTexture: THREE.Texture
if (ext === 'exr') {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new EXRLoader().load(url, resolve, undefined, reject)
})
} else {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new RGBELoader().load(url, resolve, undefined, reject)
})
}
newTexture.mapping = THREE.EquirectangularReflectionMapping
const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture)
// Dispose old resources only after the new one is ready
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = newTexture
this.envMapTarget = newEnvMapTarget
if (this._isEnabled) {
this.applyToScene()
}
}
setEnabled(enabled: boolean): void {
this._isEnabled = enabled
if (enabled) {
if (this.envMapTarget) {
this.applyToScene()
}
} else {
this.removeFromScene()
}
}
setShowAsBackground(show: boolean): void {
this._showAsBackground = show
if (this._isEnabled && this.envMapTarget) {
this.applyToScene()
}
}
setIntensity(intensity: number): void {
this._intensity = intensity
if (this._isEnabled) {
this.scene.environmentIntensity = intensity
}
}
private applyToScene(): void {
const envMap = this.envMapTarget?.texture
if (!envMap) return
this.scene.environment = envMap
this.scene.environmentIntensity = this._intensity
this.scene.background = this._showAsBackground ? this.hdriTexture : null
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: this._isEnabled,
showAsBackground: this._showAsBackground
})
}
private removeFromScene(): void {
this.scene.environment = null
if (this.scene.background === this.hdriTexture) {
this.scene.background = null
}
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: false,
showAsBackground: this._showAsBackground
})
}
private clearResources(): void {
this.removeFromScene()
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = null
this.envMapTarget = null
}
clear(): void {
this.clearResources()
this._isEnabled = false
}
dispose(): void {
this.clearResources()
this.pmremGenerator.dispose()
}
}

View File

@@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface {
currentIntensity: number = 3
private scene: THREE.Scene
private eventManager: EventManagerInterface
private lightMultipliers = new Map<THREE.Light, number>()
constructor(scene: THREE.Scene, eventManager: EventManagerInterface) {
this.scene = scene
@@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface {
this.scene.remove(light)
})
this.lights = []
this.lightMultipliers.clear()
}
setupLights(): void {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
this.scene.add(ambientLight)
this.lights.push(ambientLight)
const addLight = (light: THREE.Light, multiplier: number) => {
this.scene.add(light)
this.lights.push(light)
this.lightMultipliers.set(light, multiplier)
}
addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(0, 10, 10)
this.scene.add(mainLight)
this.lights.push(mainLight)
addLight(mainLight, 0.8)
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -10)
this.scene.add(backLight)
this.lights.push(backLight)
addLight(backLight, 0.5)
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
leftFillLight.position.set(-10, 0, 0)
this.scene.add(leftFillLight)
this.lights.push(leftFillLight)
addLight(leftFillLight, 0.3)
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
rightFillLight.position.set(10, 0, 0)
this.scene.add(rightFillLight)
this.lights.push(rightFillLight)
addLight(rightFillLight, 0.3)
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
this.scene.add(bottomLight)
this.lights.push(bottomLight)
addLight(bottomLight, 0.2)
}
setLightIntensity(intensity: number): void {
this.currentIntensity = intensity
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {
light.intensity = intensity * 0.8
} else if (light === this.lights[2]) {
light.intensity = intensity * 0.5
} else if (light === this.lights[5]) {
light.intensity = intensity * 0.2
} else {
light.intensity = intensity * 0.3
}
} else if (light instanceof THREE.AmbientLight) {
light.intensity = intensity * 0.5
}
light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1)
})
this.eventManager.emitEvent('lightIntensityChange', intensity)
}
setHDRIMode(hdriActive: boolean): void {
this.lights.forEach((light) => {
light.visible = !hdriActive
})
}
reset(): void {}
}

View File

@@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
CameraState,
HDRIConfig,
LightConfig,
ModelConfig,
SceneConfig
@@ -113,6 +114,7 @@ class Load3DConfiguration {
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
}
private loadSceneConfig(): SceneConfig {
@@ -140,13 +142,27 @@ class Load3DConfiguration {
}
private loadLightConfig(): LightConfig {
const hdriDefaults: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
if (this.properties && 'Light Config' in this.properties) {
return this.properties['Light Config'] as LightConfig
const saved = this.properties['Light Config'] as Partial<LightConfig>
return {
intensity:
saved.intensity ??
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
}
}
return {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
} as LightConfig
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
hdri: hdriDefaults
}
}
private loadModelConfig(): ModelConfig {
@@ -190,6 +206,15 @@ class Load3DConfiguration {
this.load3d.setLightIntensity(config.intensity)
}
private applyHDRISettings(config: HDRIConfig) {
if (!config.hdriPath) return
this.load3d.setHDRIIntensity(config.intensity)
this.load3d.setHDRIAsBackground(config.showAsBackground)
if (config.enabled) {
this.load3d.setHDRIEnabled(true)
}
}
private applyModelConfig(config: ModelConfig) {
this.load3d.setUpDirection(config.upDirection)
this.load3d.setMaterialMode(config.materialMode)

View File

@@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
@@ -54,6 +55,7 @@ class Load3d {
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
@@ -126,6 +128,12 @@ class Load3d {
this.eventManager
)
this.hdriManager = new HDRIManager(
this.sceneManager.scene,
this.renderer,
this.eventManager
)
this.viewHelperManager = new ViewHelperManager(
this.renderer,
this.getActiveCamera.bind(this),
@@ -635,6 +643,33 @@ class Load3d {
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
}
setHDRIEnabled(enabled: boolean): void {
this.hdriManager.setEnabled(enabled)
this.lightingManager.setHDRIMode(enabled)
this.forceRender()
}
setHDRIAsBackground(show: boolean): void {
this.hdriManager.setShowAsBackground(show)
this.forceRender()
}
setHDRIIntensity(intensity: number): void {
this.hdriManager.setIntensity(intensity)
this.forceRender()
}
clearHDRI(): void {
this.hdriManager.clear()
this.lightingManager.setHDRIMode(false)
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
@@ -858,6 +893,7 @@ class Load3d {
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
it('maps scene slider low end to a small positive HDRI intensity', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5)
})
it('maps midpoint proportionally', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo(
2.5
)
})
it('clamps scene ratio and HDRI ceiling', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5)
})
it('uses minimum HDRI when span is zero', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
})
})

View File

@@ -89,6 +89,15 @@ class Load3dUtils {
return uploadPath
}
static getFilenameExtension(url: string): string | undefined {
const queryString = url.split('?')[1]
if (queryString) {
const filename = new URLSearchParams(queryString).get('filename')
if (filename) return filename.split('.').pop()?.toLowerCase()
}
return url.split('?')[0].split('.').pop()?.toLowerCase()
}
static splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -122,6 +131,19 @@ class Load3dUtils {
await Promise.all(uploadPromises)
}
static mapSceneLightIntensityToHdri(
sceneIntensity: number,
sceneMin: number,
sceneMax: number
): number {
const span = sceneMax - sceneMin
const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
}
export default Load3dUtils

View File

@@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
])
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')

View File

@@ -47,6 +47,14 @@ export interface CameraConfig {
export interface LightConfig {
intensity: number
hdri?: HDRIConfig
}
export interface HDRIConfig {
enabled: boolean
hdriPath: string
showAsBackground: boolean
intensity: number
}
export interface EventCallback<T = unknown> {

View File

@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {
static override type = TEST_NODE_TYPE
constructor(title?: string) {
super(title ?? TEST_NODE_TYPE)
this.type = TEST_NODE_TYPE
}
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
describe('cloned node z-index in Vue renderer', () => {
let graph: LGraph
let canvas: LGraphCanvas
let previousVueNodesMode: boolean
beforeEach(() => {
vi.clearAllMocks()
previousVueNodesMode = LiteGraph.vueNodesMode
LiteGraph.vueNodesMode = true
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
layoutStore.initializeFromLiteGraph([])
// Simulate Vue runtime: create layout entries when nodes are added
graph.onNodeAdded = (node: LGraphNode) => {
createLayoutEntry(node, 0)
}
})
afterEach(() => {
LiteGraph.vueNodesMode = previousVueNodesMode
})
it('places cloned nodes above the original node z-index', () => {
const originalNode = new TestNode()
originalNode.pos = [100, 100]
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
setZIndex(originalNodeId, 5, 0)
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
expect(originalLayout?.zIndex).toBe(5)
// Clone the node via cloneNodes (same path as right-click > clone)
const result = LGraphCanvas.cloneNodes([originalNode])
expect(result).toBeDefined()
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
expect(clonedLayout).toBeDefined()
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
})
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
const nodeA = new TestNode()
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
expect(result!.created.length).toBe(2)
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)
expect(layoutB.zIndex).toBeGreaterThan(7)
// Each cloned node should have a distinct z-index
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
})
})

View File

@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
// Bring cloned/pasted nodes to front so they render above the originals
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
}
const { setNodeZIndex } = useLayoutMutations()
for (let i = 0; i < newPositions.length; i++) {
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
}
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())

View File

@@ -7,7 +7,6 @@ import type {
Point,
ISerialisedNode
} from '@/lib/litegraph/src/litegraph'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import {
LGraphNode,
LiteGraph,
@@ -654,71 +653,4 @@ describe('LGraphNode', () => {
)
})
})
describe('measure() collapsed with getCollapsedSize', () => {
let out: Rect
beforeEach(() => {
out = [0, 0, 0, 0] as unknown as Rect
node.flags.collapsed = true
})
afterEach(() => {
LiteGraph.getCollapsedSize = undefined
})
test('uses getCollapsedSize when callback returns a value', () => {
LiteGraph.getCollapsedSize = () => ({ width: 200, height: 40 })
node.measure(out)
expect(out[2]).toBe(200)
expect(out[3]).toBe(40)
})
test('falls back to legacy when getCollapsedSize returns undefined', () => {
LiteGraph.getCollapsedSize = () => undefined
node.measure(out)
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
})
test('falls back to legacy when getCollapsedSize is not set', () => {
node.measure(out)
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
})
test('reads fresh collapsed size on every call (no stale cache)', () => {
const spy = vi.fn(() => ({ width: 180, height: 36 }))
LiteGraph.getCollapsedSize = spy
node.measure(out)
expect(spy).toHaveBeenCalledTimes(1)
expect(out[2]).toBe(180)
// Simulate size change (e.g. bypass badge added/removed on collapsed node)
spy.mockReturnValue({ width: 240, height: 36 })
node.measure(out)
expect(spy).toHaveBeenCalledTimes(2)
expect(out[2]).toBe(240)
})
test('reflects updated size after expand→collapse cycle', () => {
const spy = vi.fn(() => ({ width: 180, height: 36 }))
LiteGraph.getCollapsedSize = spy
node.measure(out)
expect(out[2]).toBe(180)
node.flags.collapsed = false
node.measure(out)
expect(out[2]).toBe(node.size[0])
node.flags.collapsed = true
spy.mockReturnValue({ width: 200, height: 40 })
node.measure(out)
expect(out[2]).toBe(200)
expect(out[3]).toBe(40)
})
})
})

View File

@@ -2084,34 +2084,24 @@ export class LGraphNode
const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE &&
titleMode != TitleMode.NO_TITLE
// NODE_TITLE_HEIGHT (30) is used in both legacy and Vue modes.
// Vue headers may be taller in CSS (e.g. 36px), but the canvas
// coordinate system positions nodes at pos.y - 30 via CSS transform
// (see LGraphNode.vue), so the bounding rect must match.
const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0
out[0] = this.pos[0]
out[1] = this.pos[1] - titleHeight
out[1] = this.pos[1] + -titleHeight
if (!this.flags?.collapsed) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {
const collapsedSize = LiteGraph.getCollapsedSize?.(this.id)
if (collapsedSize) {
out[2] = collapsedSize.width
out[3] = collapsedSize.height
} else {
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
}
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
}
}

View File

@@ -355,15 +355,6 @@ export class LiteGraphGlobal {
*/
vueNodesMode: boolean = false
/**
* Optional accessor for collapsed node dimensions in Vue mode.
* Set by the Vue layer to provide DOM-measured collapsed sizes
* that measure() can use instead of canvas text measurement.
*/
getCollapsedSize?: (
nodeId: string | number
) => { width: number; height: number } | undefined
// Special Rendering Values pulled out of app.ts patches
nodeOpacity = 1
nodeLightness: number | undefined = undefined

View File

@@ -155,7 +155,6 @@ LiteGraphGlobal {
"dialog_close_on_mouse_leave_delay": 500,
"distance": [Function],
"do_add_triggers_slots": false,
"getCollapsedSize": undefined,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",

View File

@@ -1988,7 +1988,16 @@
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
"uploadingModel": "Uploading 3D model..."
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
}
},
"imageCrop": {
"loading": "Loading...",
@@ -2083,7 +2092,9 @@
"failedToUpdateMaterialMode": "Failed to update material mode",
"failedToUpdateEdgeThreshold": "Failed to update edge threshold",
"failedToUploadBackgroundImage": "Failed to upload background image",
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
"failedToLoadHDRI": "Failed to load HDRI file",
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
},
"nodeErrors": {
"render": "Node Render Error",

View File

@@ -645,42 +645,4 @@ describe('layoutStore CRDT operations', () => {
expect(layoutStore.getSlotLayout(slotKey)).toEqual(slotLayout)
}
)
describe('collapsed size lifecycle', () => {
const nodeId = 'collapsed-node'
beforeEach(() => {
layoutStore.initializeFromLiteGraph([
{ id: nodeId, pos: [0, 0], size: [200, 100] }
])
})
it('stores and retrieves collapsed size', () => {
layoutStore.updateNodeCollapsedSize(nodeId, { width: 180, height: 36 })
expect(layoutStore.getNodeCollapsedSize(nodeId)).toEqual({
width: 180,
height: 36
})
})
it('clears collapsed size', () => {
layoutStore.updateNodeCollapsedSize(nodeId, { width: 180, height: 36 })
layoutStore.clearNodeCollapsedSize(nodeId)
expect(layoutStore.getNodeCollapsedSize(nodeId)).toBeUndefined()
})
it('returns undefined for non-existent node', () => {
expect(layoutStore.getNodeCollapsedSize('no-such-node')).toBeUndefined()
})
it('returns undefined when ynode has invalid collapsedSize data', () => {
// Write a non-Size value directly to simulate corrupted CRDT data
layoutStore.updateNodeCollapsedSize(nodeId, { width: 10, height: 20 })
// Valid data should work
expect(layoutStore.getNodeCollapsedSize(nodeId)).toEqual({
width: 10,
height: 20
})
})
})
})

View File

@@ -34,7 +34,6 @@ import type {
NodeId,
NodeLayout,
Point,
Size,
RerouteId,
RerouteLayout,
ResizeNodeOperation,
@@ -1547,33 +1546,6 @@ class LayoutStoreImpl implements LayoutStore {
this.currentSource = originalSource
}
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
const ynode = this.ynodes.get(nodeId)
if (!ynode) return
this.ydoc.transact(() => {
ynode.set('collapsedSize', size)
}, this.currentActor)
this.nodeTriggers.get(nodeId)?.()
}
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
const v = this.ynodes.get(nodeId)?.get('collapsedSize')
if (typeof v === 'object' && v !== null && 'width' in v && 'height' in v) {
return v as Size
}
return undefined
}
clearNodeCollapsedSize(nodeId: NodeId): void {
const ynode = this.ynodes.get(nodeId)
if (ynode?.has('collapsedSize')) {
this.ydoc.transact(() => {
ynode.delete('collapsedSize')
}, this.currentActor)
this.nodeTriggers.get(nodeId)?.()
}
}
}
// Create singleton instance

View File

@@ -313,11 +313,6 @@ export interface LayoutStore {
// Returns all slot layout keys currently tracked by the store
getAllSlotKeys(): string[]
// Collapsed node size (Vue mode only)
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void
getNodeCollapsedSize(nodeId: NodeId): Size | undefined
clearNodeCollapsedSize(nodeId: NodeId): void
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void

View File

@@ -24,6 +24,7 @@
<div
ref="containerRef"
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
data-testid="minimap-container"
:style="containerStyles"
>
<Button
@@ -58,12 +59,18 @@
:width="width"
:height="height"
class="minimap-canvas"
data-testid="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="minimap-viewport"
:style="viewportStyles"
data-testid="minimap-viewport"
/>
<div
class="absolute inset-0 touch-none"
data-testid="minimap-interaction-overlay"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { computed, toValue } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
@@ -176,7 +176,13 @@ describe('LGraphNode', () => {
it('should call resize tracking composable with node ID', () => {
renderLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
expect(useVueElementTracking).toHaveBeenCalledWith(
expect.any(Function),
'node'
)
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
const id = toValue(idArg)
expect(id).toEqual('test-node-123')
})
it('should render with data-node-id attribute', () => {

View File

@@ -12,9 +12,7 @@
cn(
'group/node lg-node absolute isolate text-sm',
'flex flex-col contain-layout contain-style',
isRerouteNode
? 'h-(--node-height)'
: 'min-h-(--node-height) min-w-(--min-node-width)',
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
@@ -57,7 +55,8 @@
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
/>
@@ -67,23 +66,27 @@
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0'
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
<div
data-node-body
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'w-(--node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
bypassed && bypassOverlayClass,
muted && mutedOverlayClass,
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
}
)
"
:style="{
@@ -193,6 +196,7 @@
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
@@ -218,6 +222,8 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -265,7 +271,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import {
LGraphCanvas,
LGraphEventMode,
@@ -311,6 +316,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
@@ -340,7 +346,7 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(String(nodeData.id), 'node')
useVueElementTracking(() => nodeData.id, 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
@@ -560,6 +566,30 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
})
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
@@ -628,28 +658,14 @@ const selectionShapeClass = computed(() => {
}
})
const BEFORE_OVERLAY_BASE =
'before:pointer-events-none before:absolute before:inset-0'
const bypassOverlayClass = computed(() => {
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return `${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
return ''
case RenderShape.CARD:
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
return 'before:rounded-tl-2xl before:rounded-br-2xl'
default:
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
}
})
const mutedOverlayClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return BEFORE_OVERLAY_BASE
case RenderShape.CARD:
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE}`
default:
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE}`
return 'before:rounded-2xl'
}
})

View File

@@ -1,16 +1,13 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<div
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -26,38 +23,37 @@
data-testid="subgraph-enter-button"
:class="
cn(
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
<div
<template
v-else-if="
!isSubgraph &&
hasAnyError &&
showErrorsTabEnabled &&
(showAdvancedInputsButton || showAdvancedState)
"
:class="errorWrapperStyles"
>
<Button
variant="textonly"
:class="
cn(
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -72,15 +68,15 @@
variant="textonly"
:class="
cn(
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{
showAdvancedState
? t('rightSidePanel.hideAdvancedShort')
@@ -95,20 +91,17 @@
/>
</div>
</Button>
</div>
</template>
<!-- Case 2: Error Only (Full Width) -->
<div
v-else-if="hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
footerRadiusClass
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -118,27 +111,18 @@
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 3: Subgraph only (Full Width) -->
<div
v-else-if="isSubgraph"
:class="
cn(
footerWrapperBase,
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
>
<template v-else-if="isSubgraph">
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@@ -149,47 +133,37 @@
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<div
<Button
v-else-if="showAdvancedInputsButton || showAdvancedState"
variant="textonly"
:class="
cn(
footerWrapperBase,
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<Button
variant="textonly"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</div>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</template>
<script setup lang="ts">
@@ -205,67 +179,67 @@ interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const {
isSubgraph,
hasAnyError,
showErrorsTabEnabled,
showAdvancedInputsButton,
showAdvancedState,
headerColor,
shape
} = defineProps<Props>()
const props = defineProps<Props>()
defineEmits<{
enterSubgraph: []
openErrors: []
toggleAdvanced: []
(e: 'enterSubgraph'): void
(e: 'openErrors'): void
(e: 'toggleAdvanced'): void
}>()
// Static lookup to keep class names scannable by Tailwind
const RADIUS_CLASS = {
'rounded-b-17': 'rounded-b-[17px]',
'rounded-b-20': 'rounded-b-[20px]',
'rounded-br-17': 'rounded-br-[17px]',
'rounded-br-20': 'rounded-br-[20px]'
} as const
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
function getBottomRadius(
nodeShape: RenderShape | undefined,
size: '17px' | '20px',
corners: 'both' | 'right' = 'both'
): string {
if (nodeShape === RenderShape.BOX) return ''
const prefix =
nodeShape === RenderShape.CARD || corners === 'right'
? 'rounded-br'
: 'rounded-b'
const key =
`${prefix}-${size === '17px' ? '17' : '20'}` as keyof typeof RADIUS_CLASS
return RADIUS_CLASS[key]
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
}
const footerRadiusClass = computed(() =>
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
)
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
const tabStyles = 'pointer-events-auto h-9 text-xs'
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
const errorWrapperStyles = cn(
footerWrapperBase,
'-mx-1 -mb-2 w-[calc(100%+8px)] pb-1'
)
const headerColorStyle = computed(() =>
headerColor ? { backgroundColor: headerColor } : undefined
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>

View File

@@ -43,14 +43,11 @@ const testState = vi.hoisted(() => ({
nodeLayouts: new Map<NodeId, NodeLayout>(),
batchUpdateNodeBounds: vi.fn(),
setSource: vi.fn(),
syncNodeSlotLayoutsFromDOM: vi.fn(),
updateNodeCollapsedSize: vi.fn(),
clearNodeCollapsedSize: vi.fn()
syncNodeSlotLayoutsFromDOM: vi.fn()
}))
vi.mock('@vueuse/core', () => ({
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
createSharedComposable: <T>(fn: T) => fn
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -70,9 +67,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
setSource: testState.setSource,
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null),
clearNodeCollapsedSize: testState.clearNodeCollapsedSize,
updateNodeCollapsedSize: testState.updateNodeCollapsedSize
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
}
}))
@@ -89,7 +84,6 @@ function createResizeEntry(options?: {
left?: number
top?: number
collapsed?: boolean
bodyWidth?: number
}) {
const {
nodeId = 'test-node',
@@ -97,8 +91,7 @@ function createResizeEntry(options?: {
height = 180,
left = 100,
top = 200,
collapsed = false,
bodyWidth
collapsed = false
} = options ?? {}
const element = document.createElement('div')
@@ -106,14 +99,6 @@ function createResizeEntry(options?: {
if (collapsed) {
element.dataset.collapsed = ''
}
if (bodyWidth !== undefined) {
const body = document.createElement('div')
body.setAttribute('data-node-body', '')
Object.defineProperty(body, 'offsetWidth', { value: bodyWidth })
element.appendChild(body)
}
Object.defineProperty(element, 'offsetWidth', { value: width })
Object.defineProperty(element, 'offsetHeight', { value: height })
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
element.getBoundingClientRect = rectSpy
const boxSizes = [{ inlineSize: width, blockSize: height }]
@@ -173,8 +158,6 @@ describe('useVueNodeResizeTracking', () => {
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
testState.syncNodeSlotLayoutsFromDOM.mockReset()
testState.updateNodeCollapsedSize.mockReset()
testState.clearNodeCollapsedSize.mockReset()
resizeObserverState.observe.mockReset()
resizeObserverState.unobserve.mockReset()
resizeObserverState.disconnect.mockReset()
@@ -281,96 +264,18 @@ describe('useVueNodeResizeTracking', () => {
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
})
it('stores collapsed size and resyncs slots for collapsed nodes', () => {
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
const nodeId = 'test-node'
const width = 200
const height = 40
const { entry, rectSpy } = createResizeEntry({
nodeId,
width,
height,
collapsed: true
})
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
width,
height
})
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
})
it('uses body element width for collapsed size when inner wrapper exists', () => {
const nodeId = 'test-node'
const rootWidth = 260
const bodyWidth = 200
const height = 40
const { entry } = createResizeEntry({
nodeId,
width: rootWidth,
height,
collapsed: true,
bodyWidth
})
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
resizeObserverState.callback?.([entry], createObserverMock())
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
width: bodyWidth,
height
})
})
it('clears collapsed size then updates bounds on collapse-to-expand transition', () => {
const nodeId = 'test-node'
const collapsedWidth = 200
const collapsedHeight = 40
// Seed with smaller size so expand triggers a real bounds update
seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 })
// Step 1: Collapse
const { entry: collapsedEntry } = createResizeEntry({
nodeId,
width: collapsedWidth,
height: collapsedHeight,
left: 100,
top: 200,
collapsed: true
})
resizeObserverState.callback?.([collapsedEntry], createObserverMock())
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
width: collapsedWidth,
height: collapsedHeight
})
testState.updateNodeCollapsedSize.mockReset()
testState.clearNodeCollapsedSize.mockReset()
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
// Step 2: Expand — same node, no collapsed attribute, larger size
const { entry: expandedEntry } = createResizeEntry({
nodeId,
width: 240,
height: 180,
left: 100,
top: 200
})
resizeObserverState.callback?.([expandedEntry], createObserverMock())
expect(testState.clearNodeCollapsedSize).toHaveBeenCalledWith(nodeId)
expect(testState.updateNodeCollapsedSize).not.toHaveBeenCalled()
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
})
})

View File

@@ -8,7 +8,8 @@
* Supports different element types (nodes, slots, widgets, etc.) with
* customizable data attributes and update handlers.
*/
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useDocumentVisibility } from '@vueuse/core'
@@ -138,37 +139,25 @@ const resizeObserver = new ResizeObserver((entries) => {
const nodeId: NodeId | undefined =
elementType === 'node' ? elementId : undefined
// Collapsed nodes: preserve expanded size but store collapsed
// dimensions separately in layoutStore for selection bounds.
// Skip collapsed nodes — their DOM height is just the header, and writing
// that back to the layout store would overwrite the stored expanded size.
if (elementType === 'node' && element.dataset.collapsed != null) {
if (nodeId) {
markElementForFreshMeasurement(element)
const body = element.querySelector('[data-node-body]')
const collapsedWidth =
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
const collapsedHeight = element.offsetHeight
const collapsedSize = { width: collapsedWidth, height: collapsedHeight }
if (collapsedWidth === 0 && collapsedHeight === 0) continue
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
layoutStore.updateNodeCollapsedSize(nodeId, collapsedSize)
}
nodesNeedingSlotResync.add(nodeId)
}
continue
}
// Clear stale collapsedSize on collapse→expand transition only
if (elementType === 'node' && nodeId) {
layoutStore.clearNodeCollapsedSize(nodeId)
}
// Measure the full root element (including footer in flow).
// min-height is applied to the root, so footer height in node.size
// does not accumulate on Vue/legacy mode switching.
const width = Math.max(0, element.offsetWidth)
const height = Math.max(0, element.offsetHeight)
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
// Border box is the border included FULL wxh DOM value.
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = Math.max(0, borderBox.inlineSize)
const height = Math.max(0, borderBox.blockSize)
const nodeLayout = nodeId
? layoutStore.getNodeLayoutRef(nodeId).value
: null
@@ -292,9 +281,10 @@ const resizeObserver = new ResizeObserver((entries) => {
* ```
*/
export function useVueElementTracking(
appIdentifier: string,
appIdentifierMaybe: MaybeRefOrGetter<string>,
trackingType: string
) {
const appIdentifier = toValue(appIdentifierMaybe)
onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el
if (!(element instanceof HTMLElement) || !appIdentifier) return
@@ -319,7 +309,6 @@ export function useVueElementTracking(
delete element.dataset[config.dataAttribute]
cachedNodeMeasurements.delete(element)
elementsNeedingFreshMeasurement.delete(element)
deferredElements.delete(element)
resizeObserver.unobserve(element)
})
}

View File

@@ -36,7 +36,7 @@ export function isTransparent(color: string) {
return false
}
function rgbToHsl({ r, g, b }: RGB): HSL {
export function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255
b /= 255