Compare commits

...

17 Commits

Author SHA1 Message Date
bymyself
c8e1f494fa Fix Rectangle/Rect type compatibility and unify type system
This commit addresses PR review comments by fixing the fundamental
type incompatibility between Rectangle (Array<number>) and strict
tuple types (Rect = [x, y, width, height]).

Key changes:
- Updated Rectangle class methods to accept both Rect and Rectangle unions
- Fixed all measure functions (containsRect, overlapBounding, etc.) to accept Rectangle
- Updated boundingRect interfaces to consistently use Rectangle instead of Rect
- Fixed all tests and Vue components to use Rectangle instead of array literals
- Resolved Point type conflicts between litegraph and pathRenderer modules
- Removed ReadOnlyPoint types and unified with Point arrays

The type system is now consistent: boundingRect properties return Rectangle
objects with full functionality, while Rect remains as a simple tuple type
for data interchange.
2025-09-19 22:25:01 -07:00
bymyself
c82c3c24f7 [refactor] convert Rectangle from Float64Array to Array inheritance - addresses review feedback
Replace Float64Array inheritance with Array<number> to remove legacy typed array usage.
Maintains compatibility with array indexing (rect[0], rect[1], etc.) and property access
(rect.x, rect.y, etc.) while adding .set() and .subarray() methods for compatibility.

Co-authored-by: webfiltered <webfiltered@users.noreply.github.com>
2025-09-19 20:33:54 -07:00
Christian Byrne
fffa81c9b5 Update src/lib/litegraph/src/LGraphCanvas.ts
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-19 20:33:54 -07:00
bymyself
db35e0b7d2 Update test snapshots to reflect Float32Array removal
- Updated snapshots to show regular arrays instead of Float32Array
- Regenerated snapshots for LGraph, ConfigureGraph tests
- All actively running tests now have correct array expectations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 20:33:53 -07:00
bymyself
0c6eeb0632 [perf] Fix Float32Array test assertions and link adapter
Fix the remaining Float32Array usage that was causing test failures:
- Update test assertions to expect regular arrays instead of Float32Array
- Convert link adapter Float32Array creation to regular arrays

Resolves: AssertionError: expected [ 50, 60 ] to deeply equal Float32Array[ 50, 60 ]
2025-09-19 16:38:09 -07:00
bymyself
fca95ad07e [perf] Remove legacy Float32Array usage from LiteGraph
Removes Float32Array usage throughout LiteGraph codebase, eliminating
type conversion overhead for Canvas 2D rendering operations.

## Changes Made

### Core Data Structures
- **LGraphNode**: Convert position/size arrays from Float32Array to regular arrays
- **LLink**: Convert coordinate storage from Float32Array to regular arrays
- **Reroute**: Replace malloc pattern with standard properties
- **LGraphGroup**: Convert boundary arrays from Float32Array to regular arrays

### Rendering Optimizations
- **LGraphCanvas**: Convert static rendering buffers to regular arrays
- **Drawing Functions**: Replace .set() method calls with direct array assignment
- **Measurement**: Update bounds calculation to use regular arrays

### Type System Updates
- **Interfaces**: Update Point/Size/Rect types to support both regular arrays and typed arrays
- **API Compatibility**: Maintain all existing property names and method signatures

## Performance Benefits

- Eliminates Float32Array conversion overhead in Canvas 2D operations
- Reduces memory allocation complexity (removed malloc patterns)
- Improves TypeScript integration with native array support
- Maintains full API compatibility with zero breaking changes

## Background

Float32Array usage was originally designed for WebGL integration, but the
current Canvas 2D rendering pipeline doesn't use WebGL, making the typed
arrays an unnecessary performance overhead. Every Canvas 2D operation that
reads coordinates from Float32Array incurs type conversion costs.
2025-09-19 16:38:09 -07:00
Christian Byrne
0801778f60 feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572)
## Summary
- Adds subgraph title button to Vue node headers (matching LiteGraph
behavior)
- Fixes Vue node lifecycle issues during subgraph navigation and tab
switching
- Extracts reusable `useSubgraphNavigation` composable with
callback-based API
- Adds comprehensive tests for subgraph functionality
- Ensures proper graph context restoration during tab switches



https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 14:19:06 -07:00
Johnpaul Chiwetelu
8ffe63f54e Layoutstore Minimap calculation (#5547)
This pull request refactors the minimap rendering system to use a
unified, extensible data source abstraction for all minimap operations.
By introducing a data source interface and factory, the minimap can now
seamlessly support multiple sources of node layout (such as the
`LayoutStore` or the underlying `LiteGraph`), improving maintainability
and future extensibility. Rendering logic and change detection
throughout the minimap have been updated to use this new abstraction,
resulting in cleaner code and easier support for new data models.

**Core architecture improvements:**

* Introduced a new `IMinimapDataSource` interface and related data types
(`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to
standardize node, link, and group data for minimap rendering.
* Added an abstract base class `AbstractMinimapDataSource` that provides
shared logic for bounds and group/link extraction, and implemented two
concrete data sources: `LiteGraphDataSource` (for classic graph data)
and `LayoutStoreDataSource` (for layout store data).
[[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95)
[[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30)
[[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42)
* Created a `MinimapDataSourceFactory` that selects the appropriate data
source based on the presence of layout store data, enabling seamless
switching between data models.

**Minimap rendering and logic refactoring:**

* Updated all minimap rendering functions (`renderGroups`,
`renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas`
entry point to use the unified data source interface, significantly
simplifying the rendering code and decoupling it from the underlying
graph structure.
[[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11)
[[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75)
[[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124)
[[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161)
[[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187)
[[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188)
[[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231)
[[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248)
* Refactored minimap viewport and graph change detection logic to use
the data source abstraction for bounds, node, and link change detection,
and to respond to layout store version changes.
[[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10)
[[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35)
[[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141)
[[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160)
[[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11)
[[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64)

These changes make the minimap codebase more modular and robust, and lay
the groundwork for supporting additional node layout strategies in the
future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1)
by [Unito](https://www.unito.io)
2025-09-19 13:52:57 -07:00
Benjamin Lu
893409dfc8 Add playwright tests for links and slots in vue nodes mode (#5668)
Tests added
- Should show a link dragging out from a slot when dragging on a slot
- Should create a link when dropping on a compatible slot
- Should not create a link when dropping on an incompatible slot(s)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5668-Add-playwright-tests-for-links-and-slots-in-vue-nodes-mode-2736d73d36508188a47dceee5d1a11e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-19 13:51:47 -07:00
Christian Byrne
df2fda6077 [refactor] Replace manual semantic version utilities/functions with semver package (#5653)
## Summary
- Replace custom `compareVersions()` with `semver.compare()`
- Replace custom `isSemVer()` with `semver.valid()`  
- Remove deprecated version comparison functions from `formatUtil.ts`
- Update all version comparison logic across components and stores
- Fix tests to use semver mocking instead of formatUtil mocking

## Benefits
- **Industry standard**: Uses well-maintained, battle-tested `semver`
package
- **Better reliability**: Handles edge cases more robustly than custom
implementation
- **Consistent behavior**: All version comparisons now use the same
underlying logic
- **Type safety**: Better TypeScript support with proper semver types


Fixes #4787

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5653-refactor-Replace-manual-semantic-version-utilities-functions-with-semver-package-2736d73d365081fb8498ee11cbcc10e2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 12:27:49 -07:00
Christian Byrne
4f5bbe0605 [refactor] Remove legacy manager UI support and tag from header (#5665)
## Summary

Removed the informational "Use Legacy UI" tag from the ManagerHeader
component while preserving all underlying legacy manager functionality.

## Changes

- **What**: Removed Tag component displaying legacy UI information from
ManagerHeader
- **Breaking**: None - all legacy manager functionality remains intact
- **Dependencies**: None

## Review Focus

Visual cleanup only - the `--enable-manager-legacy-ui` CLI flag and all
related functionality continues to work normally. Only the informational
UI tag has been removed from the header.
2025-09-19 02:07:51 -07:00
Christian Byrne
a975e50f1b [feat] Add tooltip support for Vue nodes (#5577)
## Summary

Added tooltip support for Vue node components using PrimeVue's v-tooltip
directive with proper data integration and container scoping.


https://github.com/user-attachments/assets/d1af31e6-ef6a-4df8-8de4-5098aa4490a1

## Changes

- **What**: Implemented tooltip functionality for Vue node headers,
input/output slots, and widgets using [PrimeVue
v-tooltip](https://primevue.org/tooltip/) directive
- **Dependencies**: Leverages existing PrimeVue tooltip system, no new
dependencies

## Review Focus

Container scoping implementation via provide/inject pattern for tooltip
positioning, proper TypeScript interfaces eliminating `as any` casts,
and integration with existing settings store for tooltip delays and
enable/disable functionality.

```mermaid
graph TD
    A[LGraphNode Container] --> B[provide tooltipContainer]
    B --> C[NodeHeader inject]
    B --> D[InputSlot inject]
    B --> E[OutputSlot inject]
    B --> F[NodeWidgets inject]

    G[useNodeTooltips composable] --> H[NodeDefStore lookup]
    G --> I[Settings integration]
    G --> J[i18n fallback]

    C --> G
    D --> G
    E --> G
    F --> G

    style A fill:#f9f9f9,stroke:#333,color:#000
    style G fill:#e8f4fd,stroke:#0066cc,color:#000
```

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-19 01:07:50 -07:00
Christian Byrne
a17c74fa0c fix: add optional chaining to nodeDef access in NodeTooltip (#5663)
## Summary

Extension of https://github.com/Comfy-Org/ComfyUI_frontend/pull/5659:
Added optional chaining to NodeTooltip component to prevent TypeError
when `nodeDef` is undefined for unknown node types.

## Changes

- **What**: Added [optional chaining
operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
(`?.`) to safely access `nodeDef` properties in NodeTooltip component

## Review Focus

Error handling for node types not found in nodeDefStore and tooltip
display behavior for unrecognized nodes.

Fixes CLOUD-FRONTEND-STAGING-3N

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 01:04:12 -07:00
Christian Byrne
5e625a5002 [test] add Vue FormSelectButton widget component tests (#5576)
## Summary

Added comprehensive component tests for FormSelectButton widget with 497
test cases covering all interaction patterns and edge cases.

## Changes

- **What**: Created test suite for
[FormSelectButton.vue](https://vuejs.org/guide/scaling-up/testing.html)
component with full coverage of string/number/object options, PrimeVue
compatibility, disabled states, and visual styling
- **Dependencies**: No new dependencies (uses existing vitest,
@vue/test-utils)

## Review Focus

Test completeness covering edge cases like unicode characters, duplicate
values, and objects with missing properties. Verify test helper
functions correctly simulate user interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5576-Add-Vue-FormSelectButton-widget-component-tests-26f6d73d36508171ae08ee74d0605db2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 00:12:25 -07:00
Christian Byrne
002fac0232 [refactor] Migrate manager code to DDD structure (#5662)
## Summary

Reorganized custom nodes manager functionality from scattered technical
layers into a cohesive domain-focused module following [domain-driven
design](https://en.wikipedia.org/wiki/Domain-driven_design) principles.

## Changes

- **What**: Migrated all manager code from technical layers
(`src/components/`, `src/stores/`, etc.) to unified domain structure at
`src/workbench/extensions/manager/`
- **Breaking**: Import paths changed for all manager-related modules
(40+ files updated)

## Review Focus

Verify all import path updates are correct and no circular dependencies
introduced. Check that [Vue 3 composition
API](https://vuejs.org/guide/reusability/composables.html) patterns
remain consistent across relocated composables.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5662-refactor-Migrate-manager-code-to-DDD-structure-2736d73d3650812c87faf6ed0fffb196)
by [Unito](https://www.unito.io)
2025-09-19 00:03:05 -07:00
Christian Byrne
7e115543fa fix: prevent TypeError when nodeDef is undefined in NodeTooltip (#5659)
## Summary

Fix TypeError in NodeTooltip component when `nodeDef` is undefined. This
occurs when hovering over nodes whose type is not found in the
nodeDefStore.

## Changes

- Add optional chaining (`?.`) to `nodeDef.description` access on line
71
- Follows the same defensive pattern used in previous fixes for similar
issues

## Context

This addresses Sentry issue
[CLOUD-FRONTEND-STAGING-1B](https://comfy-org.sentry.io/issues/6829258525/)
which shows 19 occurrences affecting 14 users.

The fix follows the same pattern as previous commits:
-
[290bf52fc](290bf52fc5)
- Fixed similar issue on line 112
-
[e8997a765](e8997a7653)
- Fixed multiple similar issues


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5659-fix-prevent-TypeError-when-nodeDef-is-undefined-in-NodeTooltip-2736d73d3650816e8be3f44889198b58)
by [Unito](https://www.unito.io)
2025-09-18 23:53:58 -07:00
Christian Byrne
80d75bb164 fix TypeError: nodes is not iterable when loading graph (#5660)
## Summary
- Fixes Sentry issue CLOUD-FRONTEND-STAGING-29 (TypeError: nodes is not
iterable)
- Adds defensive guard to check if nodes is valid array before iteration
- Gracefully handles malformed workflow data by skipping node processing

## Root Cause
The `collectMissingNodesAndModels` function in `src/scripts/app.ts:1135`
was attempting to iterate over `nodes` without checking if it was a
valid iterable, causing crashes when workflow data was malformed or
missing the nodes property.

## Fix
Added null/undefined/array validation before the for-loop:
```typescript
if (\!nodes || \!Array.isArray(nodes)) {
  console.warn('Workflow nodes data is missing or invalid, skipping node processing', { nodes, path })
  return
}
```

Fixes CLOUD-FRONTEND-STAGING-29

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5660-fix-TypeError-nodes-is-not-iterable-when-loading-graph-2736d73d365081cfb828d27e59a4811c)
by [Unito](https://www.unito.io)
2025-09-18 23:27:42 -07:00
154 changed files with 2962 additions and 1014 deletions

2
.gitattributes vendored
View File

@@ -13,4 +13,4 @@
# Generated files
src/types/comfyRegistryTypes.ts linguist-generated=true
src/types/generatedManagerTypes.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -0,0 +1 @@
{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4}

View File

@@ -0,0 +1,104 @@
import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces'
import type { ComfyPage } from '../fixtures/ComfyPage'
interface FitToViewOptions {
selectionOnly?: boolean
zoom?: number
padding?: number
}
/**
* Instantly fits the canvas view to graph content without waiting for UI animation.
*
* Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented.
*/
export async function fitToViewInstant(
comfyPage: ComfyPage,
options: FitToViewOptions = {}
) {
const { selectionOnly = false, zoom = 0.75, padding = 10 } = options
const rectangles = await comfyPage.page.evaluate<
ReadOnlyRect[] | null,
{ selectionOnly: boolean }
>(
({ selectionOnly }) => {
const app = window['app']
if (!app?.canvas) return null
const canvas = app.canvas
const items = (() => {
if (selectionOnly && canvas.selectedItems?.size) {
return Array.from(canvas.selectedItems)
}
try {
return Array.from(canvas.positionableItems ?? [])
} catch {
return []
}
})()
if (!items.length) return null
const rects: ReadOnlyRect[] = []
for (const item of items) {
const rect = item?.boundingRect
if (!rect) continue
const x = Number(rect[0])
const y = Number(rect[1])
const width = Number(rect[2])
const height = Number(rect[3])
rects.push([x, y, width, height] as const)
}
return rects.length ? rects : null
},
{ selectionOnly }
)
if (!rectangles || rectangles.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const [x, y, width, height] of rectangles) {
minX = Math.min(minX, Number(x))
minY = Math.min(minY, Number(y))
maxX = Math.max(maxX, Number(x) + Number(width))
maxY = Math.max(maxY, Number(y) + Number(height))
}
const hasFiniteBounds =
Number.isFinite(minX) &&
Number.isFinite(minY) &&
Number.isFinite(maxX) &&
Number.isFinite(maxY)
if (!hasFiniteBounds) return
const bounds: ReadOnlyRect = [
minX - padding,
minY - padding,
maxX - minX + 2 * padding,
maxY - minY + 2 * padding
]
await comfyPage.page.evaluate(
({ bounds, zoom }) => {
const app = window['app']
if (!app?.canvas) return
const canvas = app.canvas
canvas.ds.fitToBounds(bounds, { zoom })
canvas.setDirty(true, true)
},
{ bounds, zoom }
)
await comfyPage.nextFrame()
}

View File

@@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
test.describe('NodeHeader', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)

View File

@@ -0,0 +1,221 @@
import type { Locator } from '@playwright/test'
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const outputSlot = await samplerNode.getOutput(0)
await outputSlot.removeLinks()
await comfyPage.nextFrame()
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
await expect(slotLocator).toBeVisible()
const start = await getCenter(slotLocator)
const canvasBox = await comfyPage.canvas.boundingBox()
if (!canvasBox) throw new Error('Canvas bounding box not available')
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
expect(vaeNodes.length).toBeGreaterThan(0)
const vaeNode = vaeNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const source = graph.getNodeById(sourceId)
if (!source) return null
const linkId = source.outputs[0]?.links?.[0]
if (linkId == null) return null
const link = graph.links[linkId]
if (!link) return null
return {
originId: link.origin_id,
originSlot: link.origin_slot,
targetId: link.target_id,
targetSlot: link.target_slot
}
}, samplerNode.id)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
expect(clipNodes.length).toBeGreaterThan(0)
const clipNode = clipNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -22,7 +22,7 @@ const config: KnipConfig = {
],
ignore: [
// Auto generated manager types
'src/types/generatedManagerTypes.ts',
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',

View File

@@ -59,14 +59,13 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/composables/useManagerState'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
import PackInstallButton from './manager/button/PackInstallButton.vue'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]

View File

@@ -43,11 +43,11 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { compare } from 'semver'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
@@ -68,7 +68,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
return compare(b, a) // Reversed for descending order
})
})

View File

@@ -1,82 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n],
directives: {
tooltip: Tooltip
},
components: {
Tag
}
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('displays the legacy manager UI tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
})
it('applies info severity to the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('p-tag-info')
})
it('displays info icon in the tag', () => {
const wrapper = createWrapper()
const icon = wrapper.find('.pi-info-circle')
expect(icon.exists()).toBe(true)
})
it('has cursor-help class on the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('cursor-help')
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
expect(flexContainer.exists()).toBe(true)
const tag = flexContainer.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
})
})

View File

@@ -1,25 +0,0 @@
<template>
<div class="w-full">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<div class="flex justify-end ml-auto pr-4 pl-2">
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help ml-2"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
</script>

View File

@@ -76,6 +76,7 @@
import { useEventListener, whenever } from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onUnmounted,
provide,
@@ -182,6 +183,26 @@ const viewportCulling = useViewportCulling(
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const handleVueNodeLifecycleReset = async () => {
if (isVueNodesEnabled.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
}
}
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
watch(
() => canvasStore.isInSubgraph,
async (newValue, oldValue) => {
if (oldValue && !newValue) {
useWorkflowStore().updateActiveGraph()
}
await handleVueNodeLifecycleReset()
}
)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes

View File

@@ -68,7 +68,7 @@ const onIdle = () => {
ctor.title_mode !== LiteGraph.NO_TITLE &&
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
) {
return showTooltip(nodeDef.description)
return showTooltip(nodeDef?.description)
}
if (node.flags?.collapsed) return
@@ -83,7 +83,7 @@ const onIdle = () => {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef.inputs[inputName]?.tooltip ?? ''
nodeDef?.inputs[inputName]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -97,7 +97,7 @@ const onIdle = () => {
if (outputSlot !== -1) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef.outputs[outputSlot]?.tooltip ?? ''
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -107,7 +107,7 @@ const onIdle = () => {
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip ?? ''
nodeDef?.inputs[widget.name]?.tooltip ?? ''
)
// Widget tooltip can be set dynamically, current translation collection does not support this.
return showTooltip(widget.tooltip ?? translatedTooltip)

View File

@@ -142,14 +142,14 @@ import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useManagerState } from '@/composables/useManagerState'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Types
interface MenuItem {

View File

@@ -82,7 +82,6 @@ import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useManagerState } from '@/composables/useManagerState'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useColorPaletteService } from '@/services/colorPaletteService'
@@ -90,10 +89,11 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()

View File

@@ -1,5 +1,4 @@
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
@@ -39,7 +38,12 @@ export const useCurrentUser = () => {
callback(resolvedUserInfo.value)
}
const stop = whenever(resolvedUserInfo, callback)
const stop = watch(resolvedUserInfo, (value) => {
if (value) {
callback(value)
}
})
return () => stop()
}

View File

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

View File

@@ -57,10 +57,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
const isNodeManagerReady = computed(() => nodeManager.value !== null)
const initializeNodeManager = () => {
if (!comfyApp.graph || nodeManager.value) return
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
if (!activeGraph || nodeManager.value) return
// Initialize the core node manager
const manager = useGraphNodeManager(comfyApp.graph)
const manager = useGraphNodeManager(activeGraph)
nodeManager.value = manager
cleanupNodeManager.value = manager.cleanup
@@ -71,8 +73,8 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
nodeSizes.value = manager.nodeSizes
detectChangesInRAF.value = manager.detectChangesInRAF
// Initialize layout system with existing nodes
const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
@@ -80,7 +82,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of comfyApp.graph.reroutes.values()) {
for (const reroute of activeGraph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
@@ -88,7 +90,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
}
// Seed existing links into the Layout Store (topology only)
for (const link of comfyApp.graph._links.values()) {
for (const link of activeGraph._links.values()) {
layoutMutations.createLink(
link.id,
link.origin_id,
@@ -142,7 +144,9 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Watch for Vue nodes enabled state changes
watch(
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
() =>
isVueNodesEnabled.value &&
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
(enabled) => {
if (enabled) {
initializeNodeManager()

View File

@@ -2,9 +2,9 @@ import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const comfyManagerStore = useComfyManagerStore()

View File

@@ -5,10 +5,10 @@ import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable to find missing NodePacks from workflow

View File

@@ -2,7 +2,7 @@ import { get, useAsyncState } from '@vueuse/core'
import type { Ref } from 'vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
/**
* Handles fetching node packs from the registry given a list of node pack IDs

View File

@@ -1,8 +1,8 @@
import { compare, valid } from 'semver'
import { computed } from 'vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
nodePack: components['schemas']['Node']
@@ -16,14 +16,14 @@ export const usePackUpdateStatus = (
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !isSemVer(installedVersion.value)
() => !!installedVersion.value && !valid(installedVersion.value)
)
const isUpdateAvailable = computed(() => {
if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) {
return false
}
return compareVersions(latestVersion.value, installedVersion.value) > 0
return compare(latestVersion.value, installedVersion.value) > 0
})
return {

View File

@@ -1,7 +1,7 @@
import { type Ref, computed } from 'vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']

View File

@@ -1,9 +1,9 @@
import { compare, valid } from 'semver'
import { computed, onMounted } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable to find NodePacks that have updates available
@@ -25,13 +25,13 @@ export const useUpdateAvailableNodes = () => {
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
const isNightlyPack = !!installedVersion && !valid(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compareVersions(latestVersion, installedVersion) > 0
return compare(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue

View File

@@ -7,9 +7,9 @@ import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type WorkflowPack = {
id:

View File

@@ -5,9 +5,7 @@ import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import config from '@/config'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { SystemStats } from '@/types'
@@ -28,6 +26,8 @@ import {
satisfiesVersion,
utilCheckVersionCompatibility
} from '@/utils/versionUtil'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable for conflict detection system.
@@ -641,7 +641,9 @@ export function useConflictDetection() {
async function initializeConflictDetection(): Promise<void> {
try {
// Check if manager is new Manager before proceeding
const { useManagerState } = await import('@/composables/useManagerState')
const { useManagerState } = await import(
'@/workbench/extensions/manager/composables/useManagerState'
)
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {

View File

@@ -1,6 +1,5 @@
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { ManagerUIState, useManagerState } from '@/composables/useManagerState'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
@@ -41,12 +40,16 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import {
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes
} from '@/utils/graphTraversalUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
ManagerUIState,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const moveSelectedNodesVersionAdded = '1.22.2'

View File

@@ -2,9 +2,9 @@ import { type ComputedRef, computed, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Extracting import failed conflicts from conflict list

View File

@@ -5,9 +5,9 @@ import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']

View File

@@ -3,7 +3,7 @@ import { onUnmounted, ref } from 'vue'
import type { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { components } from '@/types/generatedManagerTypes'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
const LOGS_MESSAGE_TYPE = 'logs'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,7 +109,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
_pos: Float32Array
_pos: [number, number]
/** @todo Clean up - never implemented in comfy. */
_last_time?: number
/** The last canvas 2D path that was used to render this link */
@@ -171,7 +171,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
this._data = null
// center
this._pos = new Float32Array(2)
this._pos = [0, 0]
}
/** @deprecated Use {@link LLink.create} */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
@@ -7,7 +8,6 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -132,20 +132,25 @@ export const useSettingStore = defineStore('setting', () => {
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
(a, b) => compareVersions(b, a)
(a, b) => compare(b, a)
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
if (!isSemVer(version)) {
if (!valid(version)) {
continue
}
if (compareVersions(installedVersion, version) >= 0) {
const versionedDefault = defaultsByInstallVersion[version]
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
if (compare(installedVersion, version) >= 0) {
const versionedDefault =
defaultsByInstallVersion[
version as keyof typeof defaultsByInstallVersion
]
if (versionedDefault !== undefined) {
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
}
}
}
}

View File

@@ -1,11 +1,12 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
import { stringToLocale } from '@/utils/formatUtil'
import { type ReleaseNote, useReleaseService } from './releaseService'
@@ -56,16 +57,19 @@ export const useReleaseStore = defineStore('release', () => {
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compareVersions(
compare(
recentRelease.value.version,
currentComfyUIVersion.value
currentComfyUIVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
compare(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
) === 0
)
const hasMediumOrHighAttention = computed(() =>

View File

@@ -1,6 +1,6 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { gt, valid } from 'semver'
import { computed } from 'vue'
import config from '@/config'
@@ -26,13 +26,13 @@ export const useVersionCompatibilityStore = defineStore(
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
!valid(frontendVersion.value) ||
!valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
return gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {

View File

@@ -1,8 +1,10 @@
import { useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
@@ -94,6 +96,29 @@ export const useCanvasStore = defineStore('canvas', () => {
appScalePercentage.value = Math.round(newScale * 100)
}
const currentGraph = shallowRef<LGraph | null>(null)
const isInSubgraph = ref(false)
whenever(
() => canvas.value,
(newCanvas) => {
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',
(event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
const newGraph = event.detail?.newGraph || app.canvas?.graph
currentGraph.value = newGraph
isInSubgraph.value = Boolean(app.canvas?.subgraph)
}
)
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
isInSubgraph.value = true
})
},
{ immediate: true }
)
return {
canvas,
selectedItems,
@@ -105,6 +130,8 @@ export const useCanvasStore = defineStore('canvas', () => {
getCanvas,
setAppZoomFromPercentage,
initScaleSync,
cleanupScaleSync
cleanupScaleSync,
currentGraph,
isInSubgraph
}
})

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import { useThrottleFn } from '@vueuse/core'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
import type { UpdateFlags } from '../types'
interface GraphCallbacks {
@@ -28,6 +30,9 @@ export function useMinimapGraph(
viewport: false
})
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
@@ -96,28 +101,30 @@ export function useMinimapGraph(
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
// Use unified data source for change detection
const dataSource = MinimapDataSourceFactory.create(g)
// Check for node count changes
const currentNodeCount = dataSource.getNodeCount()
if (currentNodeCount !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
lastNodeCount.value = currentNodeCount
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
// Check for node position/size changes
const nodes = dataSource.getNodes()
for (const node of nodes) {
const nodeId = node.id
const currentState = `${node.x},${node.y},${node.width},${node.height}`
if (nodeStatesCache.get(key) !== currentState) {
if (nodeStatesCache.get(nodeId) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
nodeStatesCache.set(nodeId, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
// Clean up removed nodes from cache
const currentNodeIds = new Set(nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
@@ -125,6 +132,13 @@ export function useMinimapGraph(
}
}
// TODO: update when Layoutstore tracks links
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
@@ -140,6 +154,10 @@ export function useMinimapGraph(
const init = () => {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChangedThrottled)
watch(layoutStoreVersion, () => {
void handleGraphChangedThrottled()
})
}
const destroy = () => {

View File

@@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
@@ -53,17 +53,15 @@ export function useMinimapViewport(
}
const calculateGraphBounds = (): MinimapBounds => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) {
// Use unified data source
const dataSource = MinimapDataSourceFactory.create(graph.value)
if (!dataSource.hasData()) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
const bounds = calculateNodeBounds(g._nodes)
if (!bounds) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
return enforceMinimumBounds(bounds)
const sourceBounds = dataSource.getBounds()
return enforceMinimumBounds(sourceBounds)
}
const calculateScale = () => {

View File

@@ -0,0 +1,95 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
import type {
IMinimapDataSource,
MinimapBounds,
MinimapGroupData,
MinimapLinkData,
MinimapNodeData
} from '../types'
/**
* Abstract base class for minimap data sources
* Provides common functionality and shared implementation
*/
export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
constructor(protected graph: LGraph | null) {}
// Abstract methods that must be implemented by subclasses
abstract getNodes(): MinimapNodeData[]
abstract getNodeCount(): number
abstract hasData(): boolean
// Shared implementation using calculateNodeBounds
getBounds(): MinimapBounds {
const nodes = this.getNodes()
if (nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
// Convert MinimapNodeData to the format expected by calculateNodeBounds
const compatibleNodes = nodes.map((node) => ({
pos: [node.x, node.y],
size: [node.width, node.height]
}))
const bounds = calculateNodeBounds(compatibleNodes)
if (!bounds) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
return bounds
}
// Shared implementation for groups
getGroups(): MinimapGroupData[] {
if (!this.graph?._groups) return []
return this.graph._groups.map((group) => ({
x: group.pos[0],
y: group.pos[1],
width: group.size[0],
height: group.size[1],
color: group.color
}))
}
// TODO: update when Layoutstore supports links
getLinks(): MinimapLinkData[] {
if (!this.graph) return []
return this.extractLinksFromGraph(this.graph)
}
protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] {
const links: MinimapLinkData[] = []
const nodeMap = new Map(this.getNodes().map((n) => [n.id, n]))
for (const node of graph._nodes) {
if (!node.outputs) continue
const sourceNodeData = nodeMap.get(String(node.id))
if (!sourceNodeData) continue
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) continue
const targetNodeData = nodeMap.get(String(link.target_id))
if (!targetNodeData) continue
links.push({
sourceNode: sourceNodeData,
targetNode: targetNodeData,
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
}
}
}
return links
}
}

View File

@@ -0,0 +1,42 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
/**
* Layout Store data source implementation
*/
export class LayoutStoreDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
const allNodes = layoutStore.getAllNodes().value
if (allNodes.size === 0) return []
const nodes: MinimapNodeData[] = []
for (const [nodeId, layout] of allNodes) {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
nodes.push({
id: nodeId,
x: layout.position.x,
y: layout.position.y,
width: layout.size.width,
height: layout.size.height,
bgcolor: graphNode?.bgcolor,
mode: graphNode?.mode,
hasErrors: graphNode?.has_errors
})
}
return nodes
}
getNodeCount(): number {
return layoutStore.getAllNodes().value.size
}
hasData(): boolean {
return this.getNodeCount() > 0
}
}

View File

@@ -0,0 +1,30 @@
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
/**
* LiteGraph data source implementation
*/
export class LiteGraphDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
if (!this.graph?._nodes) return []
return this.graph._nodes.map((node) => ({
id: String(node.id),
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors
}))
}
getNodeCount(): number {
return this.graph?._nodes?.length ?? 0
}
hasData(): boolean {
return this.getNodeCount() > 0
}
}

View File

@@ -0,0 +1,22 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { IMinimapDataSource } from '../types'
import { LayoutStoreDataSource } from './LayoutStoreDataSource'
import { LiteGraphDataSource } from './LiteGraphDataSource'
/**
* Factory for creating the appropriate data source
*/
export class MinimapDataSourceFactory {
static create(graph: LGraph | null): IMinimapDataSource {
// Check if LayoutStore has data
const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0
if (layoutStoreHasData) {
return new LayoutStoreDataSource(graph)
}
return new LiteGraphDataSource(graph)
}
}

View File

@@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import type { MinimapRenderContext } from './types'
import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory'
import type {
IMinimapDataSource,
MinimapNodeData,
MinimapRenderContext
} from './types'
/**
* Get theme-aware colors for the minimap
@@ -25,24 +30,49 @@ function getMinimapColors() {
}
}
/**
* Get node color based on settings and node properties (Single Responsibility)
*/
function getNodeColor(
node: MinimapNodeData,
settings: MinimapRenderContext['settings'],
colors: ReturnType<typeof getMinimapColors>
): string {
if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
return colors.bypassColor
}
if (settings.nodeColors) {
if (node.bgcolor) {
return colors.isLightTheme
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
return colors.nodeColorDefault
}
return colors.nodeColor
}
/**
* Render groups on the minimap
*/
function renderGroups(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._groups || graph._groups.length === 0) return
const groups = dataSource.getGroups()
if (groups.length === 0) return
for (const group of graph._groups) {
const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = group.size[0] * context.scale
const h = group.size[1] * context.scale
for (const group of groups) {
const x = (group.x - context.bounds.minX) * context.scale + offsetX
const y = (group.y - context.bounds.minY) * context.scale + offsetY
const w = group.width * context.scale
const h = group.height * context.scale
let color = colors.groupColor
@@ -64,45 +94,34 @@ function renderGroups(
*/
function renderNodes(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._nodes || graph._nodes.length === 0) return
const nodes = dataSource.getNodes()
if (nodes.length === 0) return
// Group nodes by color for batch rendering
// Group nodes by color for batch rendering (performance optimization)
const nodesByColor = new Map<
string,
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
>()
for (const node of graph._nodes) {
const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = node.size[0] * context.scale
const h = node.size[1] * context.scale
for (const node of nodes) {
const x = (node.x - context.bounds.minX) * context.scale + offsetX
const y = (node.y - context.bounds.minY) * context.scale + offsetY
const w = node.width * context.scale
const h = node.height * context.scale
let color = colors.nodeColor
if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
color = colors.bypassColor
} else if (context.settings.nodeColors) {
color = colors.nodeColorDefault
if (node.bgcolor) {
color = colors.isLightTheme
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
const color = getNodeColor(node, context.settings, colors)
if (!nodesByColor.has(color)) {
nodesByColor.set(color, [])
}
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
}
// Batch render nodes by color
@@ -132,13 +151,14 @@ function renderNodes(
*/
function renderConnections(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph || !graph._nodes) return
const links = dataSource.getLinks()
if (links.length === 0) return
ctx.strokeStyle = colors.linkColor
ctx.lineWidth = 0.3
@@ -151,41 +171,28 @@ function renderConnections(
y2: number
}> = []
for (const node of graph._nodes) {
if (!node.outputs) continue
for (const link of links) {
const x1 =
(link.sourceNode.x - context.bounds.minX) * context.scale + offsetX
const y1 =
(link.sourceNode.y - context.bounds.minY) * context.scale + offsetY
const x2 =
(link.targetNode.x - context.bounds.minX) * context.scale + offsetX
const y2 =
(link.targetNode.y - context.bounds.minY) * context.scale + offsetY
const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
const outputX = x1 + link.sourceNode.width * context.scale
const outputY = y1 + link.sourceNode.height * context.scale * 0.2
const inputX = x2
const inputY = y2 + link.targetNode.height * context.scale * 0.2
for (const output of node.outputs) {
if (!output.links) continue
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) continue
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
const y2 =
(targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
const outputX = x1 + node.size[0] * context.scale
const outputY = y1 + node.size[1] * context.scale * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * context.scale * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
// Render connection slots on top
@@ -217,8 +224,11 @@ export function renderMinimapToCanvas(
// Clear canvas
ctx.clearRect(0, 0, context.width, context.height)
// Create unified data source (Dependency Inversion)
const dataSource = MinimapDataSourceFactory.create(graph)
// Fast path for empty graph
if (!graph || !graph._nodes || graph._nodes.length === 0) {
if (!dataSource.hasData()) {
return
}
@@ -228,12 +238,12 @@ export function renderMinimapToCanvas(
// Render in correct order: groups -> links -> nodes
if (context.settings.showGroups) {
renderGroups(ctx, graph, offsetX, offsetY, context, colors)
renderGroups(ctx, dataSource, offsetX, offsetY, context, colors)
}
if (context.settings.showLinks) {
renderConnections(ctx, graph, offsetX, offsetY, context, colors)
renderConnections(ctx, dataSource, offsetX, offsetY, context, colors)
}
renderNodes(ctx, graph, offsetX, offsetY, context, colors)
renderNodes(ctx, dataSource, offsetX, offsetY, context, colors)
}

View File

@@ -2,6 +2,7 @@
* Minimap-specific type definitions
*/
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* Minimal interface for what the minimap needs from the canvas
@@ -66,3 +67,50 @@ export type MinimapSettingsKey =
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
/**
* Node data required for minimap rendering
*/
export interface MinimapNodeData {
id: NodeId
x: number
y: number
width: number
height: number
bgcolor?: string
mode?: number
hasErrors?: boolean
}
/**
* Link data required for minimap rendering
*/
export interface MinimapLinkData {
sourceNode: MinimapNodeData
targetNode: MinimapNodeData
sourceSlot: number
targetSlot: number
}
/**
* Group data required for minimap rendering
*/
export interface MinimapGroupData {
x: number
y: number
width: number
height: number
color?: string
}
/**
* Interface for minimap data sources (Dependency Inversion Principle)
*/
export interface IMinimapDataSource {
getNodes(): MinimapNodeData[]
getLinks(): MinimapLinkData[]
getGroups(): MinimapGroupData[]
getBounds(): MinimapBounds
getNodeCount(): number
hasData(): boolean
}

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div v-else :class="slotWrapperClass">
<div v-else v-tooltip.left="tooltipConfig" :class="slotWrapperClass">
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
@@ -22,7 +22,9 @@
<script setup lang="ts">
import {
type ComponentPublicInstance,
type Ref,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -30,7 +32,8 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -38,7 +41,7 @@ import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
node?: LGraphNode
nodeType?: string
nodeId?: string
slotData: INodeSlot
index: number
@@ -54,6 +57,20 @@ const props = defineProps<InputSlotProps>()
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || '',
tooltipContainer
)
const tooltipConfig = computed(() => {
const slotName = props.slotData.localized_name || props.slotData.name || ''
const tooltipText = getInputSlotTooltip(slotName)
const fallbackText = tooltipText || `Input: ${slotName}`
return createTooltipConfig(fallbackText)
})
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)

View File

@@ -4,6 +4,7 @@
</div>
<div
v-else
ref="nodeContainerRef"
:data-node-id="nodeData.id"
:class="
cn(
@@ -54,6 +55,7 @@
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -163,7 +165,10 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
@@ -452,14 +457,36 @@ const handleTitleUpdate = (newTitle: string) => {
emit('update:title', nodeData.id, newTitle)
}
const handleEnterSubgraph = () => {
const graph = app.graph?.rootGraph || app.graph
if (!graph) {
console.warn('LGraphNode: No graph available for subgraph navigation')
return
}
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
return
}
const canvas = app.canvas
if (!canvas || typeof canvas.openSubgraph !== 'function') {
console.warn('LGraphNode: Canvas or openSubgraph method not available')
return
}
canvas.openSubgraph(litegraphNode.subgraph)
}
const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
// Construct proper locator ID using subgraph ID from VueNodeData
const locatorId = nodeData.subgraphId
? `${nodeData.subgraphId}:${nodeData.id}`
: nodeData.id
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
@@ -493,6 +520,10 @@ watch(
{ deep: true }
)
// Provide nodeImageUrls to child components
// Template ref for tooltip positioning
const nodeContainerRef = ref<HTMLElement>()
// Provide nodeImageUrls and tooltip container to child components
provide('nodeImageUrls', nodeImageUrls)
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -1,12 +1,16 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import NodeHeader from './NodeHeader.vue'
@@ -24,19 +28,94 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
...overrides
})
const mountHeader = (
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
) => {
const setupMockStores = () => {
const pinia = createPinia()
setActivePinia(pinia)
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
// Mock tooltip delay setting
vi.spyOn(settingStore, 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'Comfy.EnableTooltips':
return true as Settings[K]
case 'LiteGraph.Node.TooltipDelay':
return 500 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
// Mock node definition store
const baseMockNodeDef: ComfyNodeDef = {
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling',
python_module: 'test_module',
description: 'Advanced sampling node for diffusion models',
input: {
required: {
model: ['MODEL', {}],
positive: ['CONDITIONING', {}],
negative: ['CONDITIONING', {}]
},
optional: {},
hidden: {}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['samples'],
output_node: false,
deprecated: false,
experimental: false
}
const mockNodeDef = new ComfyNodeDefImpl(baseMockNodeDef)
vi.spyOn(nodeDefStore, 'nodeDefsByName', 'get').mockReturnValue({
KSampler: mockNodeDef
})
return { settingStore, nodeDefStore, pinia }
}
const createMountConfig = () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeHeader, {
const { pinia } = setupMockStores()
return {
global: {
plugins: [PrimeVue, i18n, createPinia()],
components: { InputText }
},
plugins: [PrimeVue, i18n, pinia],
components: { InputText },
directives: {
tooltip: {
mounted: vi.fn(),
updated: vi.fn(),
unmounted: vi.fn()
}
},
provide: {
tooltipContainer: { value: document.createElement('div') }
}
}
}
}
const mountHeader = (
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
) => {
const config = createMountConfig()
return mount(NodeHeader, {
...config,
props: {
nodeData: makeNodeData(),
readonly: false,
@@ -126,4 +205,68 @@ describe('NodeHeader.vue', () => {
const collapsedIcon = wrapper.get('i')
expect(collapsedIcon.classes()).toContain('pi-chevron-right')
})
describe('Tooltips', () => {
it('applies tooltip directive to node title with correct configuration', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
// Check that v-tooltip directive was applied
const directive = wrapper.vm.$el.querySelector(
'[data-testid="node-title"]'
)
expect(directive).toBeTruthy()
})
it('disables tooltip when in readonly mode', () => {
const wrapper = mountHeader({
readonly: true,
nodeData: makeNodeData({ type: 'KSampler' })
})
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
it('disables tooltip when editing is active', async () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
// Enter edit mode
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
// Tooltip should be disabled during editing
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
it('creates tooltip configuration when component mounts', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
// Verify tooltip directive is applied to the title element
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
// The tooltip composable should be initialized
expect(wrapper.vm).toBeDefined()
})
it('uses tooltip container from provide/inject', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
expect(wrapper.exists()).toBe(true)
// Container should be provided through inject
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
})
})

View File

@@ -4,8 +4,8 @@
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
:data-testid="`node-header-${nodeInfo?.id || ''}`"
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<!-- Collapse/Expand Button -->
@@ -23,7 +23,11 @@
</button>
<!-- Node Title -->
<div class="text-sm font-bold truncate flex-1" data-testid="node-title">
<div
v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1"
data-testid="node-title"
>
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
@@ -32,31 +36,53 @@
@cancel="handleTitleCancel"
/>
</div>
<!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center">
<IconButton
v-if="isSubgraphNode"
size="sm"
type="transparent"
class="text-stone-200 dark-theme:text-slate-300"
data-testid="subgraph-enter-button"
title="Enter Subgraph"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i class="pi pi-external-link"></i>
</IconButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watch } from 'vue'
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { app } from '@/scripts/app'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
interface NodeHeaderProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
collapsed?: boolean
}
const props = defineProps<NodeHeaderProps>()
const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Error boundary implementation
@@ -72,9 +98,22 @@ onErrorCaptured((error) => {
// Editing state
const isEditing = ref(false)
const nodeInfo = computed(() => props.nodeData || props.node)
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
nodeData?.type || '',
tooltipContainer
)
const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
const tooltipConfig = computed(() => {
if (readonly || isEditing.value) {
return { value: '', disabled: true }
}
const description = getNodeDescription.value
return createTooltipConfig(description)
})
const resolveTitle = (info: VueNodeData | undefined) => {
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const type = (info?.type ?? '').trim()
@@ -82,26 +121,42 @@ const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
}
// Local state for title to provide immediate feedback
const displayTitle = ref(resolveTitle(nodeInfo.value))
const displayTitle = ref(resolveTitle(nodeData))
// Watch for external changes to the node title or type
watch(
() => [nodeInfo.value?.title, nodeInfo.value?.type] as const,
() => [nodeData?.title, nodeData?.type] as const,
() => {
const next = resolveTitle(nodeInfo.value)
const next = resolveTitle(nodeData)
if (next !== displayTitle.value) {
displayTitle.value = next
}
}
)
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false
// Get the underlying LiteGraph node
const graph = app.graph?.rootGraph || app.graph
if (!graph) return false
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
// Use the official type guard method
return litegraphNode?.isSubgraphNode() ?? false
})
// Event handlers
const handleCollapse = () => {
emit('collapse')
}
const handleDoubleClick = () => {
if (!props.readonly) {
if (!readonly) {
isEditing.value = true
}
}
@@ -118,4 +173,8 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
}
const handleEnterSubgraph = () => {
emit('enter-subgraph')
}
</script>

View File

@@ -8,7 +8,8 @@
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
:slot-data="input"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
/>
@@ -19,7 +20,8 @@
v-for="(output, index) in filteredOutputs"
:key="`output-${index}`"
:slot-data="output"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="index"
:readonly="readonly"
/>
@@ -32,7 +34,7 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { isSlotObject } from '@/utils/typeGuardUtil'
@@ -40,21 +42,18 @@ import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeSlotsProps>()
const nodeInfo = computed(() => props.nodeData || props.node || null)
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeInfo.value?.inputs) return []
if (!nodeData?.inputs) return []
return nodeInfo.value.inputs
return nodeData.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
@@ -76,7 +75,7 @@ const filteredInputs = computed(() => {
// Outputs don't have widgets, so we don't need to filter them
const filteredOutputs = computed(() => {
const outputs = nodeInfo.value?.outputs || []
const outputs = nodeData?.outputs || []
return outputs.map((output) =>
isSlotObject(output)
? output
@@ -94,10 +93,10 @@ const getActualInputIndex = (
input: INodeSlot,
filteredIndex: number
): number => {
if (!nodeInfo.value?.inputs) return filteredIndex
if (!nodeData?.inputs) return filteredIndex
// Find the actual index in the unfiltered inputs array
const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input)
const actualIndex = nodeData.inputs.findIndex((i) => i === input)
return actualIndex !== -1 ? actualIndex : filteredIndex
}

View File

@@ -31,7 +31,7 @@
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getWidgetInputIndex(widget)"
:readonly="readonly"
:dot-only="true"
@@ -40,6 +40,7 @@
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:readonly="readonly"
@@ -51,15 +52,15 @@
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { type Ref, computed, inject, onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
@@ -74,13 +75,12 @@ import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
node?: LGraphNode
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeWidgetsProps>()
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -101,7 +101,13 @@ onErrorCaptured((error) => {
return false
})
const nodeInfo = computed(() => props.nodeData || props.node)
const nodeType = computed(() => nodeData?.type || '')
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value,
tooltipContainer
)
interface ProcessedWidget {
name: string
@@ -110,14 +116,13 @@ interface ProcessedWidget {
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: unknown) => void
tooltipConfig: any
}
const processedWidgets = computed((): ProcessedWidget[] => {
const info = nodeInfo.value
if (!info?.widgets) return []
if (!nodeData?.widgets) return []
const widgets = info.widgets as SafeWidgetData[]
const lodLevel = props.lodLevel
const widgets = nodeData.widgets as SafeWidgetData[]
const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
@@ -148,13 +153,17 @@ const processedWidgets = computed((): ProcessedWidget[] => {
}
}
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
result.push({
name: widget.name,
type: widget.type,
vueComponent,
simplified,
value: widget.value,
updateHandler
updateHandler,
tooltipConfig
})
}
@@ -165,7 +174,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// or restructuring data model to unify widgets and inputs
// Map a widget to its corresponding input slot index
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
const inputs = nodeInfo.value?.inputs
const inputs = nodeData?.inputs
if (!inputs) return 0
const idx = inputs.findIndex((input: any) => {

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div v-else :class="slotWrapperClass">
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<!-- Slot Name -->
<span
v-if="!dotOnly"
@@ -22,7 +22,9 @@
<script setup lang="ts">
import {
type ComponentPublicInstance,
type Ref,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -30,7 +32,8 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -38,7 +41,7 @@ import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {
node?: LGraphNode
nodeType?: string
nodeId?: string
slotData: INodeSlot
index: number
@@ -55,6 +58,20 @@ const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || '',
tooltipContainer
)
const tooltipConfig = computed(() => {
const slotName = props.slotData.name || ''
const tooltipText = getOutputSlotTooltip(props.index)
const fallbackText = tooltipText || `Output: ${slotName}`
return createTooltipConfig(fallbackText)
})
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)

View File

@@ -0,0 +1,120 @@
import { type MaybeRef, type Ref, computed, unref } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
/**
* Composable for managing Vue node tooltips
* Provides tooltip text for node headers, slots, and widgets
*/
export function useNodeTooltips(
nodeType: MaybeRef<string>,
containerRef?: Ref<HTMLElement | undefined>
) {
const nodeDefStore = useNodeDefStore()
const settingsStore = useSettingStore()
// Check if tooltips are globally enabled
const tooltipsEnabled = computed(() =>
settingsStore.get('Comfy.EnableTooltips')
)
// Get node definition for tooltip data
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
/**
* Get tooltip text for node description (header hover)
*/
const getNodeDescription = computed(() => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.description`
return st(key, nodeDef.value.description || '')
})
/**
* Get tooltip text for input slots
*/
const getInputSlotTooltip = (slotName: string) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
return st(key, inputTooltip)
}
/**
* Get tooltip text for output slots
*/
const getOutputSlotTooltip = (slotIndex: number) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
return st(key, outputTooltip)
}
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
return st(key, inputTooltip)
}
/**
* Create tooltip configuration object for v-tooltip directive
*/
const createTooltipConfig = (text: string) => {
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
const tooltipText = text || ''
const config: {
value: string
showDelay: number
disabled: boolean
appendTo?: HTMLElement
pt?: any
} = {
value: tooltipText,
showDelay: tooltipDelay as number,
disabled: !tooltipsEnabled.value || !tooltipText,
pt: {
text: {
class:
'bg-charcoal-100 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
},
arrow: {
class: 'before:border-charcoal-100'
}
}
}
// If we have a container reference, append tooltips to it
if (containerRef?.value) {
config.appendTo = containerRef.value
}
return config
}
return {
tooltipsEnabled,
getNodeDescription,
getInputSlotTooltip,
getOutputSlotTooltip,
getWidgetTooltip,
createTooltipConfig
}
}

View File

@@ -0,0 +1,507 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import FormSelectButton from './FormSelectButton.vue'
describe('FormSelectButton Core Component', () => {
// Type-safe helper for mounting component
const mountComponent = (
modelValue: string | null | undefined = null,
options: (string | number | Record<string, any>)[] = [],
props: Record<string, unknown> = {}
) => {
return mount(FormSelectButton, {
global: {
plugins: [PrimeVue]
},
props: {
modelValue,
options: options as any,
...props
}
})
}
const clickButton = async (
wrapper: ReturnType<typeof mount>,
buttonText: string
) => {
const buttons = wrapper.findAll('button')
const targetButtonIndex = buttons.findIndex((button) =>
button.text().includes(buttonText)
)
if (targetButtonIndex === -1) {
throw new Error(`Button with text "${buttonText}" not found`)
}
// Use get() which throws if element doesn't exist, providing better error messages
const targetButton = buttons.at(targetButtonIndex)!
await targetButton.trigger('click')
return targetButton
}
describe('Basic Rendering', () => {
it('renders as a horizontal button group layout', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(null, options)
const container = wrapper.find('div')
const buttons = wrapper.findAll('button')
// Verify layout behavior: container exists and contains buttons
expect(container.exists()).toBe(true)
expect(buttons).toHaveLength(2)
// Verify buttons are arranged horizontally (not vertically stacked)
// This tests the layout logic rather than specific CSS classes
buttons.forEach((button) => {
expect(button.exists()).toBe(true)
expect(button.element.tagName).toBe('BUTTON')
})
})
it('renders buttons for each option', () => {
const options = ['first', 'second', 'third']
const wrapper = mountComponent(null, options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('first')
expect(buttons[1].text()).toBe('second')
expect(buttons[2].text()).toBe('third')
})
it('renders empty container when no options provided', () => {
const wrapper = mountComponent(null, [])
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(0)
})
it('applies proper button styling', () => {
const options = ['test']
const wrapper = mountComponent(null, options)
const button = wrapper.find('button')
expect(button.classes()).toContain('flex-1')
expect(button.classes()).toContain('h-6')
expect(button.classes()).toContain('px-5')
expect(button.classes()).toContain('py-[5px]')
expect(button.classes()).toContain('rounded')
expect(button.classes()).toContain('text-center')
expect(button.classes()).toContain('text-xs')
expect(button.classes()).toContain('font-normal')
})
})
describe('String Options', () => {
it('handles string array options', () => {
const options = ['apple', 'banana', 'cherry']
const wrapper = mountComponent('banana', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('apple')
expect(buttons[1].text()).toBe('banana')
expect(buttons[2].text()).toBe('cherry')
})
it('emits correct string value when clicked', async () => {
const options = ['first', 'second', 'third']
const wrapper = mountComponent('first', options)
await clickButton(wrapper, 'second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['second'])
})
it('highlights selected string option', () => {
const options = ['option1', 'option2', 'option3']
const wrapper = mountComponent('option2', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
})
describe('Number Options', () => {
it('handles number array options', () => {
const options = [1, 2, 3]
const wrapper = mountComponent('2', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('1')
expect(buttons[1].text()).toBe('2')
expect(buttons[2].text()).toBe('3')
})
it('emits string representation of number when clicked', async () => {
const options = [10, 20, 30]
const wrapper = mountComponent('10', options)
await clickButton(wrapper, '20')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['20'])
})
it('highlights selected number option', () => {
const options = [100, 200, 300]
const wrapper = mountComponent('200', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
})
})
describe('Object Options', () => {
it('handles object array with label and value', () => {
const options = [
{ label: 'First Option', value: 'first' },
{ label: 'Second Option', value: 'second' }
]
const wrapper = mountComponent('first', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].text()).toBe('First Option')
expect(buttons[1].text()).toBe('Second Option')
})
it('emits object value when object option clicked', async () => {
const options = [
{ label: 'Apple', value: 'apple_val' },
{ label: 'Banana', value: 'banana_val' }
]
const wrapper = mountComponent('apple_val', options)
await clickButton(wrapper, 'Banana')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['banana_val'])
})
it('highlights selected object option by value', () => {
const options = [
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' }
]
const wrapper = mountComponent('md', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white') // Medium
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
it('handles objects without value field', () => {
const options = [
{ label: 'First', name: 'first_name' },
{ label: 'Second', name: 'second_name' }
]
const wrapper = mountComponent('first_name', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First')
expect(buttons[1].text()).toBe('Second')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles objects without label field', () => {
const options = [
{ value: 'val1', name: 'Name 1' },
{ value: 'val2', name: 'Name 2' }
]
const wrapper = mountComponent('val1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('Name 1')
expect(buttons[1].text()).toBe('Name 2')
})
})
describe('PrimeVue Compatibility', () => {
it('uses custom optionLabel prop', () => {
const options = [
{ title: 'First Item', value: 'first' },
{ title: 'Second Item', value: 'second' }
]
const wrapper = mountComponent('first', options, { optionLabel: 'title' })
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First Item')
expect(buttons[1].text()).toBe('Second Item')
})
it('uses custom optionValue prop', () => {
const options = [
{ label: 'First', id: 'first_id' },
{ label: 'Second', id: 'second_id' }
]
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('emits custom optionValue when clicked', async () => {
const options = [
{ label: 'First', id: 'first_id' },
{ label: 'Second', id: 'second_id' }
]
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
await clickButton(wrapper, 'Second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['second_id'])
})
})
describe('Disabled State', () => {
it('disables all buttons when disabled prop is true', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.element.disabled).toBe(true)
expect(button.classes()).toContain('opacity-50')
expect(button.classes()).toContain('cursor-not-allowed')
})
})
it('does not emit events when disabled', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
await clickButton(wrapper, 'option2')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('does not apply hover styles when disabled', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
expect(button.classes()).not.toContain('cursor-pointer')
})
})
it('applies disabled styling to selected option', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
expect(buttons[0].classes()).toContain('opacity-50')
expect(buttons[0].classes()).toContain('text-zinc-500')
})
})
describe('Selection Logic', () => {
it('handles null modelValue', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(null, options)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
})
})
it('handles undefined modelValue', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(undefined, options)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
})
})
it('handles empty string modelValue', () => {
const options = ['', 'option1', 'option2']
const wrapper = mountComponent('', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('compares values as strings', () => {
const options = [1, '2', 3]
const wrapper = mountComponent('1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
})
})
describe('Visual States', () => {
it('applies selected styling to active option', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
const selectedButton = wrapper.findAll('button')[0]
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
})
it('applies unselected styling to inactive options', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain('bg-transparent')
expect(unselectedButton.classes()).toContain('text-zinc-500')
})
it('applies hover effects to enabled unselected buttons', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: false })
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
describe('Edge Cases', () => {
it('handles very long option text', () => {
const longText =
'This is a very long option text that might cause layout issues'
const options = ['short', longText, 'normal']
const wrapper = mountComponent('short', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].text()).toBe(longText)
expect(buttons).toHaveLength(3)
})
it('handles options with special characters', () => {
const specialOptions = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
const wrapper = mountComponent(specialOptions[0], specialOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('@#$%^&*()')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles unicode characters in options', () => {
const unicodeOptions = ['🎨 Art', '中文', 'العربية']
const wrapper = mountComponent('🎨 Art', unicodeOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('🎨 Art')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles duplicate option values', () => {
const duplicateOptions = ['duplicate', 'unique', 'duplicate']
const wrapper = mountComponent('duplicate', duplicateOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('handles mixed type options safely', () => {
const mixedOptions: any[] = [
'string',
123,
{ label: 'Object', value: 'obj' },
null
]
const wrapper = mountComponent('123', mixedOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
})
it('handles objects with missing properties gracefully', () => {
const incompleteOptions = [
{}, // Empty object
{ randomProp: 'value' }, // No standard props
{ value: 'has_value' }, // No label
{ label: 'has_label' } // No value
]
const wrapper = mountComponent('has_value', incompleteOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[2].classes()).toContain('bg-white')
})
it('handles large number of options', () => {
const manyOptions = Array.from(
{ length: 50 },
(_, i) => `Option ${i + 1}`
)
const wrapper = mountComponent('Option 25', manyOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(50)
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
})
it('fallback to index when all object properties are missing', () => {
const problematicOptions = [
{ someRandomProp: 'random1' },
{ anotherRandomProp: 'random2' }
]
const wrapper = mountComponent('0', problematicOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
})
})
describe('Event Handling', () => {
it('prevents click events when disabled', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const clickHandler = vi.fn()
wrapper.vm.$el.addEventListener('click', clickHandler)
await clickButton(wrapper, 'option2')
expect(clickHandler).not.toHaveBeenCalled()
})
it('allows repeated selection of same option', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
await clickButton(wrapper, 'option1')
await clickButton(wrapper, 'option1')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(2)
expect(emitted![0]).toEqual(['option1'])
expect(emitted![1]).toEqual(['option1'])
})
})
})

View File

@@ -46,7 +46,7 @@ import { WidgetInputBaseClass } from '../layout'
interface Props {
modelValue: string | null | undefined
options: T[] // Now using generic type instead of any[]
options: T[]
optionLabel?: string // PrimeVue compatible prop
optionValue?: string // PrimeVue compatible prop
disabled?: boolean

View File

@@ -1121,6 +1121,13 @@ export class ComfyApp {
nodes: ComfyWorkflowJSON['nodes'],
path: string = ''
) => {
if (!Array.isArray(nodes)) {
console.warn(
'Workflow nodes data is missing or invalid, skipping node processing',
{ nodes, path }
)
return
}
for (let n of nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader'

View File

@@ -5,20 +5,12 @@ import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInCon
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import NodeConflictDialogContent from '@/components/dialog/content/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/components/dialog/content/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/components/dialog/content/manager/NodeConflictHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
@@ -31,6 +23,14 @@ import {
useDialogStore
} from '@/stores/dialogStore'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import ManagerProgressDialogContent from '@/workbench/extensions/manager/components/ManagerProgressDialogContent.vue'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
export type ConfirmationDialogType =
| 'default'

View File

@@ -16,7 +16,6 @@ import type {
SearchAttribute,
SearchNodePacksParams
} from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
@@ -24,6 +23,7 @@ import type {
SortableField
} from '@/types/searchServiceTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']

View File

@@ -364,39 +364,6 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
}
}
export const isSemVer = (
version: string
): version is `${number}.${number}.${number}` => {
const regex = /^\d+\.\d+\.\d+$/
return regex.test(version)
}
const normalizeVersion = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part))
export function compareVersions(
versionA: string | undefined,
versionB: string | undefined
): number {
versionA ??= '0.0.0'
versionB ??= '0.0.0'
const aParts = normalizeVersion(versionA)
const bParts = normalizeVersion(versionB)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
/**
* Converts Metronome's integer amount back to a formatted currency string.
* For USD, converts from cents to dollars.

View File

@@ -8,6 +8,23 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
interface NodeWithId {
id: string | number
subgraphId?: string | null
}
/**
* Constructs a locator ID from node data with optional subgraph context.
*
* @param nodeData - Node data containing id and optional subgraphId
* @returns The locator ID string
*/
export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
return nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
}
/**
* Parses an execution ID into its component parts.
*

View File

@@ -1,4 +1,4 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
/**
@@ -33,9 +33,7 @@ export const lcm = (a: number, b: number): number => {
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
* @returns Bounds object with union rectangle, or null if no rectangles provided
*/
export function computeUnionBounds(
rectangles: readonly ReadOnlyRect[]
): Bounds | null {
export function computeUnionBounds(rectangles: readonly Rect[]): Bounds | null {
const n = rectangles.length
if (n === 0) {
return null

View File

@@ -1,4 +1,4 @@
import * as semver from 'semver'
import { clean, satisfies } from 'semver'
import type {
ConflictDetail,
@@ -11,7 +11,7 @@ import type {
* @returns Cleaned version string or original if cleaning fails
*/
export function cleanVersion(version: string): string {
return semver.clean(version) || version
return clean(version) || version
}
/**
@@ -23,7 +23,7 @@ export function cleanVersion(version: string): string {
export function satisfiesVersion(version: string, range: string): boolean {
try {
const cleanedVersion = cleanVersion(version)
return semver.satisfies(cleanedVersion, range)
return satisfies(cleanedVersion, range)
} catch {
return false
}

View File

@@ -29,7 +29,7 @@ const defaultMockTaskLogs = [
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/stores/comfyManagerStore', () => ({
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],

View File

@@ -88,7 +88,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()

View File

@@ -78,13 +78,13 @@ import { useConflictDetection } from '@/composables/useConflictDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const dialogStore = useDialogStore()

View File

@@ -24,7 +24,7 @@ import { useI18n } from 'vue-i18n'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -143,24 +143,24 @@ import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab

View File

@@ -0,0 +1,45 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n]
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.items-center')
expect(flexContainer.exists()).toBe(true)
const title = flexContainer.find('h2')
expect(title.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,11 @@
<template>
<div class="w-full">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -32,7 +32,7 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]

View File

@@ -35,7 +35,7 @@ const mockInstalledPacks = {
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>

View File

@@ -43,13 +43,13 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { valid as validSemver } from 'semver'
import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
import PackVersionSelectorPopover from '@/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const TRUNCATED_HASH_LENGTH = 7
@@ -81,7 +81,9 @@ const installedVersion = computed(() => {
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
return validSemver(version)
? version
: version.slice(0, TRUNCATED_HASH_LENGTH)
})
const toggleVersionSelector = (event: Event) => {

View File

@@ -64,7 +64,7 @@ vi.mock('@/services/comfyRegistryService', () => ({
}))
// Mock the manager store
vi.mock('@/stores/comfyManagerStore', () => ({
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installPack: {
call: mockInstallPack,

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