Compare commits

...

23 Commits

Author SHA1 Message Date
Jin Yi
0136c68065 refactor: rename ResultGallery to MediaLightbox and address code review
- Rename ResultGallery to MediaLightbox across all references
  - Replace window keydown listener with @keydown on dialog element
  - Remove redundant toBeVisible check in Playwright test
  - Remove change detector tests (renders close button, prevents default)
  - Dispatch keyboard events on dialog element in unit tests
  - Wire zoom event to lightbox in MediaAssetCard story
  - Add MediaLightbox Storybook story
  - Sort button icon sizes (icon-sm, icon, icon-lg)
2026-03-18 11:49:18 +09:00
Jin Yi
316483fc56 Merge branch 'main' into refactor/result-gallery 2026-03-17 17:33:41 +09:00
Yourz
d6c1dd2e59 feat: improve essentials tab blueprint support and display names (#10113)
## Changes

### Essential nodes
- Include blueprint nodes in essentials tab via `BLUEPRINT_PREFIX_MAP`
matching, removing dependency on backend `essentials_category`
- Sort essentials folders by `ESSENTIALS_CATEGORIES` order
- Disambiguate duplicate blueprint labels with provider suffix (e.g. two
"Text to image" → "Text to image (Flux 1)")
- Resolve blueprint icons by prefix instead of full node name
- Add 15 new SVG icons for blueprint categories, remove 2 old
subgraph-blueprint-specific SVGs
- Remove unnecessary parenthetical suffixes from unique display names
("Load style (LoRA)" → "Load style", "Text generation (LLM)" → "Text
generation")

essential nodes with blueprints icon

<img width="507" height="550" alt="image"
src="https://github.com/user-attachments/assets/967cd4b6-ea4d-44a2-9d6d-e66c152370c7"
/>

### All nodes panel
- section bottom change from `pb-6` to `pb-2`

<img width="525" height="215" alt="image"
src="https://github.com/user-attachments/assets/252cf655-3138-42f9-a9ef-9d771d3281e4"
/>

### Favorite to Bookmark

- change `Favorite node` to `Bookmark node`
- change `Unfavorite node` to `Unbookmark node`
- change `No favorites yet` to `No bookmarks yet`

<img width="495" height="380" alt="image"
src="https://github.com/user-attachments/assets/7ba6f631-15ae-4406-874b-737551ca441c"
/>

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 01:11:55 -07:00
Christian Byrne
1dcd7bca43 docs: add E2E testing gotchas for canvas overlay, context menus, and subgraph navigation (#9951)
Documents 3 hard-learned gotchas from debugging subgraph context menu
E2E tests across ~8 threads:

- Canvas `z-999` overlay intercepting `click()` on DOM widgets → use
`dispatchEvent`
- Context menus requiring node selection before right-click
- Subgraph node sizing minimum for reliable `navigateIntoSubgraph()`

Added to both `browser_tests/AGENTS.md` (auto-loaded for agents working
in that dir) and the Playwright test-writing skill's Common Issues
table.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9951-docs-add-E2E-testing-gotchas-for-canvas-overlay-context-menus-and-subgraph-navigation-3246d73d3650819a928bf91d66e22c2c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 01:09:14 -07:00
Jin Yi
99cf94f5b0 Merge branch 'main' into refactor/result-gallery 2026-03-17 16:57:41 +09:00
Jin Yi
6c699924d6 fix: e2e test 2026-03-17 16:55:51 +09:00
Christian Byrne
918095f197 fix: prune orphaned SubgraphNode inputs after configure (#10020)
## Summary

Prune orphaned inputs in `_internalConfigureAfterSlots()` to fix
duplicate SubgraphNode inputs that accumulate on serialize-load cycles.

## Changes

- **What**: After `_rebindInputSubgraphSlots()`, filter out inputs with
no matching `_subgraphSlot`. This prevents `LGraphNode.configure()`
`cloneObject` expansion from persisting stale duplicates.
- Added 3 regression tests covering: corrupted serialized data,
reconfigure round-trips, and serialization output.

## Review Focus

The fix is a single `filter()` call. The existing `console.warn` guard
at line ~976 (for inputs without `_subgraphSlot`) becomes dead code
after this fix but is retained as defense-in-depth.

Fixes #9977

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10020-fix-prune-orphaned-SubgraphNode-inputs-after-configure-3256d73d3650812e8cecf4a3c86f2c33)
by [Unito](https://www.unito.io)
2026-03-17 00:54:35 -07:00
Jin Yi
c16e0cc1ed Merge branch 'main' into refactor/result-gallery 2026-03-17 16:40:11 +09:00
Jin Yi
75276dbf75 test: flasky test @smoke to @slow 2026-03-17 16:38:58 +09:00
Benjamin Lu
14274fb8fa fix: share queue details hover state (#9924)
## Summary

Extract the shared delayed queue-details hover state into a composable
so the queue overlay and grouped queue rows use the same timing and
handoff behavior.

## Changes

- **What**: Add `useJobDetailsHover`, migrate `JobAssetsList` and
`JobGroupsList` to it, convert the touched helper functions to
declarations, and add a grouped-row regression test for the A -> B ->
leave stale-popover case.

## Review Focus

Verify that details hover timing stays consistent between the queue
overlay and grouped queue rows, especially around the A -> B hover
handoff and brief-hover exit path.

Stacked on #9549.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9924-fix-share-queue-details-hover-state-3236d73d36508108aca5dcfb99bf33c6)
by [Unito](https://www.unito.io)
2026-03-17 00:38:52 -07:00
Christian Byrne
95eb51633f fix: show webcam capture button in Vue renderer (#9936)
## Summary

Fix missing capture button on WebcamCapture node in Vue renderer mode.

## Changes

- **What**: Remove `canvasOnly: true` from the capture button widget
options. This flag prevented `WidgetButton.vue` from rendering the
button. The WEBCAM DOM widget (video feed) already renders correctly via
`WidgetDOM.vue`.

## Review Focus

Single-line change: `{ canvasOnly: true }` → `{}`. The button's label,
callback, disabled state, and serializeValue behavior are all handled by
WidgetButton.vue already.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9936-fix-show-webcam-capture-button-in-Vue-renderer-3246d73d36508173a6e5ca59d9fb7af1)
by [Unito](https://www.unito.io)
2026-03-17 00:36:15 -07:00
Jin Yi
ee5f252d88 chore: outline none 2026-03-17 16:34:44 +09:00
Jin Yi
83e1288a6b test: fix test 2026-03-17 16:25:30 +09:00
Jin Yi
0a31397fde test: add e2e test for ResultGallery using existing LoadImage workflow 2026-03-17 16:04:29 +09:00
Jin Yi
d0757a551b Merge branch 'main' into refactor/result-gallery 2026-03-17 15:47:45 +09:00
Terry Jia
46d8567f10 feat: add linear interpolation type to CURVE widget (#10118)
## Summary

Change the CURVE widget value from CurvePoint[] to CurveData ({ points,
interpolation }) to support multiple interpolation types. Add a Select
dropdown in the widget UI for switching between Smooth (monotone cubic)
and Linear interpolation, with the SVG preview updating accordingly.

- Add CurveData type with CURVE_INTERPOLATIONS const enum
- Add createLinearInterpolator with piecewise linear + binary search
- Add createInterpolator factory dispatching by interpolation type
- Add isCurveData type guard in curveUtils
- Update ICurveWidget value type to CurveData
- Add interpolation prop to CurveEditor and useCurveEditor composable
- Linear mode generates direct M...L... SVG path (no sampling)
- Add i18n entries for interpolation labels
- Add unit tests for createLinearInterpolator

BE change is https://github.com/Comfy-Org/ComfyUI/pull/12757

## Screenshots (if applicable)
<img width="1437" height="670" alt="image"
src="https://github.com/user-attachments/assets/550aedec-e5da-425b-8233-86a4f28067fa"
/>

<img width="1445" height="648" alt="image"
src="https://github.com/user-attachments/assets/0a8dc654-3f92-4ca2-9fa2-c1fef3be6d66"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10118-feat-add-linear-interpolation-type-to-CURVE-widget-3256d73d36508185a86edf73bb555c51)
by [Unito](https://www.unito.io)
2026-03-16 23:47:18 -07:00
Jin Yi
0cf3b1e5e7 test: remove e2e ResultGallery test (CI lacks output images)
Amp-Thread-ID: https://ampcode.com/threads/T-019cf9e3-c466-71ee-9c45-74b32e21ecaf
Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 15:44:26 +09:00
Christian Byrne
b22c646910 test: E2E coverage for toasts, error overlay, selection toolbox, linear mode (#9555)
## Summary

Add 26 E2E tests across 5 spec files covering high-impact,
frequently-used UI flows: toast notifications, error overlay navigation,
selection toolbox actions, linear mode layout, and selection rectangle
multi-select.

## Tests

| Spec file | Tests | Coverage |
|---|---|---|
| `toastNotifications.spec.ts` | 5 | Toast lifecycle, dismiss, severity
levels |
| `errorOverlaySeeErrors.spec.ts` | 6 | Error overlay → errors panel
navigation flow |
| `selectionToolboxActions.spec.ts` | 4 | Delete, info,
convert-to-subgraph, multi-delete |
| `linearMode.spec.ts` | 5 | Linear layout toggle, widget rendering,
persistence |
| `selectionRectangle.spec.ts` | 6 | Vue node multi-selection via
rectangle drag |

## Review Focus

- Selection toolbox tests use `force: true` clicks due to CSS transform
positioning — is this acceptable or should we find a more robust
approach?
- 2 additional toolbox tests (bypass, refresh) were split to draft PR
#9768 due to flaky CI visibility issues with
`useSelectionToolboxPosition`.

## Stack

Depends on #9554 for FeatureFlagHelper/QueueHelper infrastructure.

- #9554: Test infrastructure helpers
- **→ This PR**: Toasts, error overlay, selection toolbox, linear mode,
selection rectangle
- #9556: Node search, bottom panel, focus mode, job history, side panel
- #9557: Errors tab, node headers, queue notifications, settings sidebar
- #9558: Minimap, widget copy, floating menus, node library essentials

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-16 23:37:40 -07:00
Jin Yi
5012aa42e2 test: scoping logic 2026-03-17 15:21:01 +09:00
GitHub Action
bdd020a218 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 05:38:48 +00:00
Jin Yi
0799f7973f Merge branch 'main' into refactor/result-gallery 2026-03-17 14:35:59 +09:00
Jin Yi
0c18edb026 test: add e2e test for ResultGallery 2026-03-17 14:31:42 +09:00
Jin Yi
81d0ca8781 refactor: replace PrimeVue Galleria with custom overlay in ResultGallery 2026-03-17 14:10:02 +09:00
61 changed files with 1875 additions and 624 deletions

View File

@@ -44,15 +44,18 @@ await expect(node).toHaveClass(BYPASS_CLASS)
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
| Symptom | Common Cause | Typical Fix |
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
| Symptom | Common Cause | Typical Fix |
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
## Test Tags

View File

@@ -30,6 +30,14 @@ browser_tests/
└── tests/ - Test files (*.spec.ts)
```
## Gotchas
| Symptom | Cause | Fix |
| -------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `subtree intercepts pointer events` on DOM widgets | Canvas `z-999` overlay intercepts `click()` | Use Playwright's `locator.dispatchEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })` |
| Context menu empty or wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
| `navigateIntoSubgraph` timeout | Node too small in test asset JSON | Use node size `[400, 200]` minimum |
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory

View File

@@ -0,0 +1,92 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})

View File

@@ -0,0 +1,111 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await enterGraphMode(comfyPage)
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await expect(comfyPage.canvas).not.toBeVisible()
})
})

View File

@@ -0,0 +1,70 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
async function runAndOpenGallery(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
// Wait for SaveImage node to produce output
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30_000
})
// Open Assets sidebar tab and wait for it to load
await comfyPage.page.locator('.assets-tab-button').click()
await comfyPage.page
.locator('.sidebar-content-container')
.waitFor({ state: 'visible' })
// Wait for any asset card to appear (may contain img or video)
const assetCard = comfyPage.page
.locator('[role="button"]')
.filter({ has: comfyPage.page.locator('img, video') })
.first()
await expect(assetCard).toBeVisible({ timeout: 30_000 })
// Hover to reveal zoom button, then click it
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,85 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('@canvas Selection Rectangle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Ctrl+A selects all nodes', async ({ comfyPage }) => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(totalCount).toBeGreaterThan(0)
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
await comfyPage.page
.getByText('Load Checkpoint')
.click({ modifiers: ['Control'] })
// Then deselect remaining via Escape or programmatic clear
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
test('Single click selects one node', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: ['Control']
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
})
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
await expect(checkpointNode).toHaveClass(/outline-node-component-outline/)
})
test('Drag-select rectangle selects multiple nodes', async ({
comfyPage
}) => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
expect(totalCount).toBeGreaterThan(1)
})
})

View File

@@ -0,0 +1,101 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { ComfyPage } from '../fixtures/ComfyPage'
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
canvas.setDirty(true, true)
}, nodePos)
await comfyPage.nextFrame()
await nodeRef.click('title')
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
).toBeVisible()
})
test('delete button removes multiple selected nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 2)
})
})

View File

@@ -22,13 +22,12 @@ async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
if (!canvas.graph) return
canvas.setGraph(canvas.graph.rootGraph)
})
await comfyPage.nextFrame()
}
@@ -36,6 +35,10 @@ test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Auto-promotion on Convert to Subgraph', () => {
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
comfyPage
@@ -86,10 +89,18 @@ test.describe(
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select the SaveImage node (id 9 in default workflow)
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
@@ -251,7 +262,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -285,7 +296,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -331,7 +342,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -366,7 +377,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -397,7 +408,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -478,10 +489,18 @@ test.describe(
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select SaveImage (id 9)
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()

View File

@@ -0,0 +1,73 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Toast Notifications', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function triggerErrorToast(comfyPage: {
page: { evaluate: (fn: () => void) => Promise<void> }
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: 'Test execution error',
life: 30000
})
})
await comfyPage.nextFrame()
}
test('Error toast appears when triggered', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
test('Toast shows correct error severity class', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
const errorToast = comfyPage.page.locator(
'.p-toast-message.p-toast-message-error'
)
await expect(errorToast.first()).toBeVisible()
})
test('Toast can be dismissed via close button', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
const closeButton = comfyPage.page.locator('.p-toast-close-button').first()
await closeButton.click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
})
test('All toasts cleared via closeToasts helper', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
await comfyPage.toast.closeToasts()
expect(await comfyPage.toast.getVisibleToastCount()).toBe(0)
})
test('Toast error count is accurate', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-error').first()
).toBeVisible()
const errorCount = await comfyPage.toast.getToastErrorCount()
expect(errorCount).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -18,7 +18,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@custom-variant touch (@media (hover: none));

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 15H4.33333C3.59695 15 3 14.403 3 13.6667V4.33333C3 3.59695 3.59695 3 4.33333 3H13.6667C14.403 3 15 3.59695 15 4.33333V11L12.9427 8.94263C12.6926 8.69267 12.3536 8.55225 12 8.55225C11.6464 8.55225 11.3074 8.69267 11.0573 8.94263L5 15M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M18 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M10 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V18M8.33333 7C8.33333 7.73638 7.73638 8.33333 7 8.33333C6.26362 8.33333 5.66667 7.73638 5.66667 7C5.66667 6.26362 6.26362 5.66667 7 5.66667C7.73638 5.66667 8.33333 6.26362 8.33333 7ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 17.9999V20.6666C10 21.403 10.597 21.9999 11.3333 21.9999H20.6667C21.403 21.9999 22 21.403 22 20.6666V11.3333C22 10.5969 21.403 9.99994 20.6667 9.99994H18M14 16.9999V18.6666L18 15.9999L16.6579 15.1052M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 21.9999L7 19.9999M7 19.9999H4C3.46957 19.9999 2.96086 19.7892 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 17.9999V16.9999M7 19.9999L5 17.9999M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -1,5 +0,0 @@
<svg width="49" height="24" viewBox="0 0 49 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5243 11.7208C26.7584 11.955 26.7583 12.3351 26.5243 12.5694L23.9794 15.1153C23.7452 15.3495 23.3651 15.3493 23.1308 15.1153C22.8965 14.881 22.8965 14.501 23.1308 14.2667L24.6474 12.7501L17.0995 12.7501C16.7683 12.7499 16.4999 12.4807 16.4999 12.1495C16.5001 11.8184 16.7685 11.5501 17.0995 11.5499L24.6571 11.5499L23.1308 10.0235C22.8965 9.7892 22.8965 9.40919 23.1308 9.17489C23.3651 8.94127 23.7453 8.94083 23.9794 9.17489L26.5243 11.7208Z" fill="#8A8A8A"/>
<path d="M4.50779 3.61371C4.6735 3.55748 4.85537 3.56703 5.0156 3.64203L6.14353 4.16938L6.9199 3.685L6.99509 3.64496C7.17632 3.56122 7.38691 3.56099 7.57029 3.64692L8.95798 4.29633C9.2306 4.42419 9.34839 4.74924 9.22068 5.02192C9.09287 5.29443 8.76771 5.4121 8.49509 5.28461L7.30857 4.72797L6.5449 5.20551V6.97211H9.32127C9.55299 6.97221 9.76687 7.08954 9.89158 7.27973L9.93943 7.36567L11.2588 10.1918C11.2714 10.2189 11.282 10.2474 11.291 10.2758L11.3125 10.3627L11.9902 14.2407C12.042 14.5373 11.8435 14.8206 11.5469 14.8725C11.2505 14.924 10.9681 14.7254 10.916 14.4291L10.247 10.6049L9.06052 8.06293H6.5449V11.6079C6.54489 11.6553 6.53579 11.7007 6.52439 11.7446H7.03318C7.26977 11.7446 7.48688 11.8665 7.61033 12.0629L7.6572 12.1518L8.89548 14.9711C8.9258 15.0402 8.94492 15.1138 8.95115 15.1889L9.26951 19.0629C9.29388 19.3627 9.07125 19.6259 8.77146 19.6508C8.47137 19.6755 8.20837 19.4519 8.18357 19.1518L7.86912 15.3471L6.7656 12.8344H5.52048L4.12693 15.3715L3.81541 19.1518C3.79062 19.4519 3.52762 19.6755 3.22752 19.6508C2.92759 19.626 2.70508 19.3628 2.72947 19.0629L3.04685 15.1957L3.05662 15.1245C3.06994 15.0542 3.09436 14.9862 3.12888 14.9233L4.68064 12.0981L4.73045 12.02C4.85779 11.8482 5.05991 11.7448 5.27732 11.7446H5.47361C5.46224 11.7007 5.45409 11.6553 5.45408 11.6079V8.06293H3.24412L1.74509 10.6323L1.08103 14.1127C1.02439 14.4084 0.738116 14.6018 0.44236 14.5454C0.146978 14.4885 -0.0465728 14.2032 0.00974259 13.9077L0.687477 10.3598L0.718727 10.2485C0.732186 10.2126 0.748186 10.1772 0.767555 10.144L2.42088 7.31C2.54303 7.10073 2.76744 6.97221 3.00974 6.97211H5.45408V5.05121L4.72654 4.71039L3.50388 5.28461C3.23123 5.41225 2.90614 5.2945 2.7783 5.02192C2.65052 4.74922 2.76835 4.4242 3.04099 4.29633L4.43748 3.64203L4.50779 3.61371Z" fill="#8A8A8A"/>
<path d="M44.7027 5C46.7971 5 48.5097 6.71671 48.5097 8.84956V14.3804C48.5097 16.5133 46.7971 18.23 44.7027 18.23H35.3067C33.2122 18.23 31.4997 16.5133 31.4997 14.3804V8.84956C31.4997 6.71671 33.2122 5 35.3067 5H44.7027ZM35.3067 6.37812C33.9312 6.37812 32.8496 7.48086 32.8496 8.84956V14.3804C32.8496 15.7491 33.9312 16.8519 35.3067 16.8519H44.7027C46.0781 16.8519 47.1597 15.7491 47.1597 14.3804V8.84956C47.1597 7.48086 46.0781 6.37812 44.7027 6.37812H35.3067ZM38.0595 8.12949C38.1749 8.13322 38.2881 8.16994 38.3859 8.23544L42.6927 11.0009C42.9191 11.1419 43.0022 11.403 43.0022 11.615C43.0022 11.8269 42.9187 12.0878 42.6924 12.2288L38.3862 14.9946C38.1806 15.132 37.9132 15.1341 37.706 15.002L37.7043 15.0009C37.4997 14.8683 37.3782 14.6208 37.3845 14.3727V8.84956C37.3797 8.49772 37.6466 8.14891 38.0086 8.13007L38.0595 8.12949ZM38.7038 13.1249L41.0623 11.6144L38.7038 10.0996V13.1249ZM42.675 11.8255C42.6603 11.8574 42.6421 11.887 42.6201 11.913L42.6503 11.8717C42.6595 11.8571 42.6677 11.8414 42.675 11.8255Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 pb-6"
class="m-0 min-w-0 p-0 pb-2"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -82,23 +82,25 @@
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { computed, toRef, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import type { CurveInterpolation, CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
disabled = false
disabled = false,
interpolation = 'monotone_cubic'
} = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
interpolation?: CurveInterpolation
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -109,7 +111,8 @@ const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
modelValue,
interpolation: toRef(() => interpolation)
})
function onSvgPointerDown(e: PointerEvent) {

View File

@@ -1,9 +1,30 @@
<template>
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
<div class="flex flex-col gap-1">
<Select
v-if="!isDisabled"
:model-value="modelValue.interpolation"
@update:model-value="onInterpolationChange"
>
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interp in CURVE_INTERPOLATIONS"
:key="interp"
:value="interp"
>
{{ $t(`curveWidget.${interp}`) }}
</SelectItem>
</SelectContent>
</Select>
<CurveEditor
:model-value="effectiveCurve.points"
:disabled="isDisabled"
:interpolation="effectiveCurve.interpolation"
@update:model-value="onPointsChange"
/>
</div>
</template>
<script setup lang="ts">
@@ -15,31 +36,53 @@ import {
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
import { isCurveData } from './curveUtils'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
const modelValue = defineModel<CurveData>({
default: () => ({
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
})
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
singleValueExtractor(isCurveData)
)
const effectivePoints = computed(() =>
const effectiveCurve = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
function onPointsChange(points: CurvePoint[]) {
modelValue.value = { ...modelValue.value, points }
}
function onInterpolationChange(value: unknown) {
if (typeof value !== 'string') return
modelValue.value = {
...modelValue.value,
interpolation: value as CurveInterpolation
}
}
</script>

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
@@ -73,6 +74,64 @@ describe('createMonotoneInterpolator', () => {
})
})
describe('createLinearInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createLinearInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createLinearInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.8, 10)
expect(interpolate(1)).toBe(1)
})
it('linearly interpolates between points', () => {
const points: CurvePoint[] = [
[0, 0],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0.25)).toBeCloseTo(0.25, 10)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(0.75)).toBeCloseTo(0.75, 10)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(1)).toBe(1)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([

View File

@@ -1,19 +1,70 @@
import type { CurvePoint } from './types'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
export function isCurveData(value: unknown): value is CurveData {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false
const v = value as Record<string, unknown>
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(v.points) &&
v.points.every(
(p: unknown) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
) &&
typeof v.interpolation === 'string' &&
CURVE_INTERPOLATIONS.includes(v.interpolation as CurveInterpolation)
)
}
/**
* Piecewise linear interpolation through sorted control points.
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createLinearInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
return ys[lo] + t * (ys[hi] - ys[lo])
}
}
/**
* Factory that dispatches to the correct interpolator based on type.
*/
export function createInterpolator(
points: CurvePoint[],
interpolation: CurveInterpolation
): (x: number) => number {
return interpolation === 'linear'
? createLinearInterpolator(points)
: createMonotoneInterpolator(points)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
@@ -120,9 +171,12 @@ export function histogramToPath(histogram: Uint32Array): string {
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'
): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
const interpolate = createInterpolator(points, interpolation)
for (let i = 0; i < 256; i++) {
const x = i / 255

View File

@@ -1 +1,10 @@
export type CurvePoint = [x: number, y: number]
export const CURVE_INTERPOLATIONS = ['monotone_cubic', 'linear'] as const
export type CurveInterpolation = (typeof CURVE_INTERPOLATIONS)[number]
export interface CurveData {
points: CurvePoint[]
interpolation: CurveInterpolation
}

View File

@@ -60,7 +60,7 @@ const mountComponent = (
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
MediaLightbox: true
},
directives: {
tooltip: () => {}

View File

@@ -45,7 +45,7 @@
</div>
</div>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'

View File

@@ -303,4 +303,21 @@ describe('JobAssetsList', () => {
expect(popover.exists()).toBe(true)
expect(popover.props('jobId')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
await jobRow.trigger('mouseenter')
await wrapper.setProps({ displayedJobGroups: [] })
await nextTick()
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
})
})

View File

@@ -98,11 +98,12 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -119,29 +120,26 @@ const emit = defineEmits<{
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
const {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
getActiveId: (details) => details.jobId,
getDisplayedJobGroups: () => displayedJobGroups,
onReset: clearPopoverAnchor
})
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const updatePopoverPosition = () => {
function updatePopoverPosition() {
const rowElement = activeRowElement.value
if (!rowElement) return
@@ -153,56 +151,14 @@ const updatePopoverPosition = () => {
}
}
const resetActiveDetails = () => {
clearHideTimer()
clearShowTimer()
activeDetails.value = null
activeRowElement.value = null
popoverPosition.value = null
}
const scheduleDetailsShow = (job: JobListItem, rowElement: HTMLElement) => {
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeRowElement.value = rowElement
activeDetails.value = {
jobId: job.id,
workflowId: job.taskRef?.workflowId
}
showTimer.value = null
void nextTick(updatePopoverPosition)
}, 200)
}
const scheduleDetailsHide = (jobId?: string) => {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
if (activeDetails.value?.jobId === jobId) {
activeDetails.value = null
activeRowElement.value = null
popoverPosition.value = null
}
hideTimer.value = null
hideTimerJobId.value = null
}, 150)
}
const onJobLeave = (jobId: string) => {
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
const onJobEnter = (job: JobListItem, event: MouseEvent) => {
function onJobEnter(job: JobListItem, event: MouseEvent) {
hoveredJobId.value = job.id
const rowElement = event.currentTarget
@@ -210,24 +166,36 @@ const onJobEnter = (job: JobListItem, event: MouseEvent) => {
activeRowElement.value = rowElement
if (activeDetails.value?.jobId === job.id) {
clearHideTimer()
clearShowTimer()
clearHoverTimers()
void nextTick(updatePopoverPosition)
return
}
scheduleDetailsShow(job, rowElement)
scheduleDetailsShow(
{
jobId: job.id,
workflowId: job.taskRef?.workflowId
},
() => {
activeRowElement.value = rowElement
void nextTick(updatePopoverPosition)
}
)
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
function isCancelable(job: JobListItem) {
return job.showClear !== false && isActiveJobState(job.state)
}
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
const getJobPreviewUrl = (job: JobListItem) => {
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
@@ -235,66 +203,49 @@ const getJobPreviewUrl = (job: JobListItem) => {
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
function isVideoPreviewJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
}
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
function isPreviewableCompletedJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)
}
const emitViewItem = (job: JobListItem) => {
function emitViewItem(job: JobListItem) {
if (isPreviewableCompletedJob(job)) {
resetActiveDetails()
emit('viewItem', job)
}
}
const emitCompletedViewItem = (job: JobListItem) => {
function emitCompletedViewItem(job: JobListItem) {
resetActiveDetails()
emit('viewItem', job)
}
const emitCancelItem = (job: JobListItem) => {
function emitCancelItem(job: JobListItem) {
resetActiveDetails()
emit('cancelItem', job)
}
const emitDeleteItem = (job: JobListItem) => {
function emitDeleteItem(job: JobListItem) {
resetActiveDetails()
emit('deleteItem', job)
}
const onPopoverEnter = () => {
clearHideTimer()
clearShowTimer()
function onPopoverEnter() {
clearHoverTimers()
}
const onPopoverLeave = () => {
scheduleDetailsHide(activeDetails.value?.jobId)
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
const getJobIconClass = (job: JobListItem): string | undefined => {
function getJobIconClass(job: JobListItem): string | undefined {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
watch(
() => displayedJobGroups,
(groups) => {
const activeJobId = activeDetails.value?.jobId
if (!activeJobId) return
const hasActiveJob = groups.some((group) =>
group.items.some((item) => item.id === activeJobId)
)
if (!hasActiveJob) {
resetActiveDetails()
}
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -56,11 +56,11 @@ const mountComponent = (groups: JobGroup[]) =>
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
@@ -95,4 +95,33 @@ describe('JobGroupsList hover behavior', () => {
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
jobItems[0].vm.$emit('details-enter', firstJob.id)
vi.advanceTimersByTime(200)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
jobItems[0].vm.$emit('details-leave', firstJob.id)
jobItems[1].vm.$emit('details-enter', secondJob.id)
vi.advanceTimersByTime(100)
await nextTick()
jobItems[1].vm.$emit('details-leave', secondJob.id)
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[1].props('activeDetailsId')).toBeNull()
})
})

View File

@@ -36,12 +36,11 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
@@ -50,65 +49,34 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const emitCancelItem = (item: JobListItem) => {
const {
activeDetails: activeDetailsId,
clearHoverTimers,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<string>({
getActiveId: (jobId) => jobId,
getDisplayedJobGroups: () => displayedJobGroups
})
function emitCancelItem(item: JobListItem) {
emit('cancelItem', item)
}
const emitDeleteItem = (item: JobListItem) => {
function emitDeleteItem(item: JobListItem) {
emit('deleteItem', item)
}
const activeDetailsId = ref<string | null>(null)
const hideTimer = ref<number | null>(null)
const showTimer = ref<number | null>(null)
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
function onDetailsEnter(jobId: string) {
if (activeDetailsId.value === jobId) {
clearHoverTimers()
return
}
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const onDetailsEnter = (jobId: string) => {
clearHideTimer()
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeDetailsId.value = jobId
showTimer.value = null
}, 200)
}
const onDetailsLeave = (jobId: string) => {
clearHideTimer()
clearShowTimer()
hideTimer.value = window.setTimeout(() => {
if (activeDetailsId.value === jobId) activeDetailsId.value = null
hideTimer.value = null
}, 150)
scheduleDetailsShow(jobId)
}
const resetActiveDetails = () => {
clearHideTimer()
clearShowTimer()
activeDetailsId.value = null
function onDetailsLeave(jobId: string) {
scheduleDetailsHide(jobId)
}
watch(
() => props.displayedJobGroups,
(groups) => {
const activeId = activeDetailsId.value
if (!activeId) return
const hasActiveJob = groups.some((group) =>
group.items.some((item) => item.id === activeId)
)
if (!hasActiveJob) resetActiveDetails()
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -170,7 +170,7 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -58,7 +58,7 @@
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'

View File

@@ -167,7 +167,10 @@ import {
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import {
resolveBlueprintSuffix,
resolveEssentialsDisplayName
} from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
@@ -371,11 +374,38 @@ const essentialSections = computed(() => {
)
})
function disambiguateBlueprintLabels(
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
if (!root.children) return root
return {
...root,
children: root.children.map((folder) => {
if (folder.type !== 'folder' || !folder.children) return folder
const labelCounts = new Map<string, number>()
for (const node of folder.children) {
if (node.label)
labelCounts.set(node.label, (labelCounts.get(node.label) ?? 0) + 1)
}
return {
...folder,
children: folder.children.map((node) => {
if ((labelCounts.get(node.label ?? '') ?? 0) <= 1) return node
const suffix = resolveBlueprintSuffix(node.data?.name ?? '')
if (!suffix) return node
return { ...node, label: `${node.label} (${suffix})` }
})
}
})
}
}
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
const root = section
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
: fillNodeInfo({ key: 'root', label: '', children: [] })
return disambiguateBlueprintLabels(root)
})
function flattenRenderedLeaves(

View File

@@ -39,6 +39,7 @@ import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { resolveBlueprintIcon } from '@/constants/essentialsDisplayNames'
import { ESSENTIALS_ICON_OVERRIDES } from '@/constants/essentialsNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -73,6 +74,10 @@ const nodeIcon = computed(() => {
const nodeName = node.data?.name
if (nodeName && nodeName in ESSENTIALS_ICON_OVERRIDES)
return ESSENTIALS_ICON_OVERRIDES[nodeName]
if (nodeName) {
const blueprintIcon = resolveBlueprintIcon(nodeName)
if (blueprintIcon) return blueprintIcon
}
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})

View File

@@ -0,0 +1,229 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
enableAutoUnmount(afterEach)
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import MediaLightbox from './MediaLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
gallery: 'Gallery',
previous: 'Previous',
next: 'Next'
}
}
}
})
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
isAudio?: boolean
}
describe('MediaLightbox', () => {
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
const mockResultAudio = {
name: 'ResultAudio',
template:
'<div class="mock-result-audio" data-testid="result-audio"></div>',
props: ['result']
}
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image2.jpg',
id: '2'
},
{
filename: 'image3.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '789' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image3.jpg',
id: '3'
}
]
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(MediaLightbox, {
global: {
plugins: [i18n],
components: {
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo,
ResultAudio: mockResultAudio
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders overlay with role="dialog" and aria-modal', async () => {
const wrapper = mountGallery()
await nextTick()
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.exists()).toBe(true)
expect(dialog.attributes('aria-modal')).toBe('true')
})
it('shows navigation buttons when multiple items', async () => {
const wrapper = mountGallery()
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
})
it('hides navigation buttons for single item', async () => {
const wrapper = mountGallery({
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
})
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
expect(wrapper.find('[data-mask]').exists()).toBe(false)
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
expect(wrapper.find('[data-mask]').exists()).toBe(true)
})
it('emits update:activeIndex with -1 when close button clicked', async () => {
const wrapper = mountGallery()
await nextTick()
await wrapper.find('[aria-label="Close"]').trigger('click')
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
describe('keyboard navigation', () => {
it('navigates to next item on ArrowRight', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
})
it('navigates to previous item on ArrowLeft', async () => {
const wrapper = mountGallery({ activeIndex: 1 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
})
it('wraps to last item on ArrowLeft from first', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
})
it('closes gallery on Escape', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
})
})

View File

@@ -0,0 +1,149 @@
<template>
<Teleport to="body">
<div
v-if="galleryVisible"
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-label="$t('g.gallery')"
tabindex="-1"
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/90 outline-none"
data-mask
@mousedown="onMaskMouseDown"
@mouseup="onMaskMouseUp"
@keydown="handleKeyDown"
>
<!-- Close Button -->
<Button
variant="secondary"
size="icon-lg"
class="absolute top-4 right-4 z-10 rounded-full"
:aria-label="$t('g.close')"
@click="close"
>
<i class="icon-[lucide--x] size-5" />
</Button>
<!-- Previous Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 left-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.previous')"
@click="navigateImage(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</Button>
<!-- Content -->
<div class="flex max-h-full max-w-full items-center justify-center">
<template v-if="activeItem">
<ComfyImage
v-if="activeItem.isImage"
:key="activeItem.url"
:src="activeItem.url"
:contain="false"
:alt="activeItem.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
</template>
</div>
<!-- Next Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 right-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.next')"
@click="navigateImage(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</Button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const galleryVisible = ref(false)
const dialogRef = ref<HTMLElement>()
let previouslyFocusedElement: HTMLElement | null = null
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
const activeItem = computed(() => props.allGalleryItems[props.activeIndex])
watch(
() => props.activeIndex,
(index) => {
galleryVisible.value = index !== -1
if (index !== -1) {
previouslyFocusedElement = document.activeElement as HTMLElement | null
void nextTick(() => dialogRef.value?.focus())
}
},
{ immediate: true }
)
function close() {
galleryVisible.value = false
emit('update:activeIndex', -1)
previouslyFocusedElement?.focus()
previouslyFocusedElement = null
}
function navigateImage(direction: number) {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
let maskMouseDownTarget: EventTarget | null = null
function onMaskMouseDown(event: MouseEvent) {
maskMouseDownTarget = event.target
}
function onMaskMouseUp(event: MouseEvent) {
if (
maskMouseDownTarget === event.target &&
(event.target as HTMLElement)?.hasAttribute('data-mask')
) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
const actions: Record<string, () => void> = {
ArrowLeft: () => navigateImage(-1),
ArrowRight: () => navigateImage(1),
Escape: () => close()
}
const action = actions[event.key]
if (action) {
event.preventDefault()
action()
}
}
</script>

View File

@@ -1,184 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
}
describe('ResultGallery', () => {
// Mock ComfyImage and ResultVideo components
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
// Sample gallery items - using mock instances with only required properties
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image2.jpg',
id: '2'
}
]
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
// Create mock elements for Galleria to find
document.body.innerHTML = `
<div id="app"></div>
`
})
afterEach(() => {
// Clean up any elements added to body
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: {
Galleria,
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders Galleria component with correct props', async () => {
const wrapper = mountGallery()
await nextTick() // Wait for component to mount
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
expect(galleria.props('value')).toEqual(mockGalleryItems)
expect(galleria.props('showIndicators')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(true)
expect(galleria.props('fullScreen')).toBe(true)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
// galleryVisible should become true
expect(vm.galleryVisible).toBe(true)
})
it('should render the component properly', () => {
// This is a meta-test to confirm the component mounts properly
const wrapper = mountGallery()
// We can't directly test the compiled CSS, but we can verify the component renders
expect(wrapper.exists()).toBe(true)
// Verify that the Galleria component exists and is properly mounted
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
})
it('ensures correct configuration for mobile viewport', async () => {
// Mock window.matchMedia to simulate mobile viewport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query.includes('max-width: 768px'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
const wrapper = mountGallery()
await nextTick()
// Verify mobile media query is working
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
// Check if the component renders with Galleria
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})
// Additional tests for interaction could be added once we can reliably
// test Galleria component in fullscreen mode
})

View File

@@ -1,151 +0,0 @@
<template>
<Galleria
v-model:visible="galleryVisible"
:active-index="activeIndex"
:value="allGalleryItems"
:show-indicators="false"
change-item-on-indicator-hover
:show-item-navigators="hasMultiple"
full-screen
:circular="hasMultiple"
:show-thumbnails="false"
:pt="{
mask: {
onMousedown: onMaskMouseDown,
onMouseup: onMaskMouseUp,
'data-mask': true
},
prevButton: {
style: 'position: fixed !important'
},
nextButton: {
style: 'position: fixed !important'
}
}"
@update:visible="handleVisibilityChange"
@update:active-index="handleActiveIndexChange"
>
<template #item="{ item }">
<ComfyImage
v-if="item.isImage"
:key="item.url"
:src="item.url"
:contain="false"
:alt="item.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
</template>
</Galleria>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
let maskMouseDownTarget: EventTarget | null = null
const onMaskMouseDown = (event: MouseEvent) => {
maskMouseDownTarget = event.target
}
const onMaskMouseUp = (event: MouseEvent) => {
const maskEl = document.querySelector('[data-mask]')
if (
galleryVisible.value &&
maskMouseDownTarget === event.target &&
maskMouseDownTarget === maskEl
) {
galleryVisible.value = false
handleVisibilityChange(false)
}
}
watch(
() => props.activeIndex,
(index) => {
if (index !== -1) {
galleryVisible.value = true
}
}
)
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('update:activeIndex', -1)
}
}
const handleActiveIndexChange = (index: number) => {
emit('update:activeIndex', index)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!galleryVisible.value) return
switch (event.key) {
case 'ArrowLeft':
navigateImage(-1)
break
case 'ArrowRight':
navigateImage(1)
break
case 'Escape':
galleryVisible.value = false
handleVisibilityChange(false)
break
}
}
const navigateImage = (direction: number) => {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style>
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
cannot use scoped style here. */
.p-galleria-close-button {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: 1;
}
/* Mobile/tablet specific fixes */
@media screen and (max-width: 768px) {
.p-galleria-prev-button,
.p-galleria-next-button {
z-index: 2;
}
}
</style>

View File

@@ -28,8 +28,9 @@ export const buttonVariants = cva({
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0',
icon: 'size-8',
'icon-lg': 'size-10',
unset: ''
}
},
@@ -54,8 +55,13 @@ const variants = [
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']
>
const sizes = [
'sm',
'md',
'lg',
'icon-sm',
'icon',
'icon-lg'
] as const satisfies Array<ButtonVariants['size']>
export const FOR_STORIES = { variants, sizes } as const

View File

@@ -0,0 +1,107 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import type { JobGroup } from '@/composables/queue/useJobList'
const DETAILS_SHOW_DELAY_MS = 200
const DETAILS_HIDE_DELAY_MS = 150
interface UseJobDetailsHoverOptions<TActive> {
getActiveId: (active: TActive) => string
getDisplayedJobGroups: () => JobGroup[]
onReset?: () => void
}
export function useJobDetailsHover<TActive>({
getActiveId,
getDisplayedJobGroups,
onReset
}: UseJobDetailsHoverOptions<TActive>) {
const activeDetails = ref<TActive | null>(null)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
function clearHideTimer() {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
function clearShowTimer() {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
function clearHoverTimers() {
clearHideTimer()
clearShowTimer()
}
function resetActiveDetails() {
clearHoverTimers()
activeDetails.value = null
onReset?.()
}
function hasDisplayedJob(jobId: string) {
return getDisplayedJobGroups().some((group) =>
group.items.some((item) => item.id === jobId)
)
}
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
const nextActiveId = getActiveId(nextActive)
clearShowTimer()
showTimer.value = window.setTimeout(() => {
showTimer.value = null
if (!hasDisplayedJob(nextActiveId)) return
activeDetails.value = nextActive
onShow?.()
}, DETAILS_SHOW_DELAY_MS)
}
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
const currentActive = activeDetails.value
if (currentActive && getActiveId(currentActive) === jobId) {
activeDetails.value = null
onHide?.()
}
hideTimer.value = null
hideTimerJobId.value = null
}, DETAILS_HIDE_DELAY_MS)
}
watch(getDisplayedJobGroups, () => {
const currentActive = activeDetails.value
if (!currentActive) return
if (!hasDisplayedJob(getActiveId(currentActive))) {
resetActiveDetails()
}
})
onBeforeUnmount(resetActiveDetails)
return {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
}
}

View File

@@ -1,25 +1,37 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/components/curve/types'
import { createInterpolator } from '@/components/curve/curveUtils'
import type { CurveInterpolation, CurvePoint } from '@/components/curve/types'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
modelValue: Ref<CurvePoint[]>
interpolation: Ref<CurveInterpolation>
}
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
export function useCurveEditor({
svgRef,
modelValue,
interpolation
}: UseCurveEditorOptions) {
const dragIndex = ref(-1)
let cleanupDrag: (() => void) | null = null
const curvePath = computed(() => {
const points = modelValue.value
if (points.length < 2) return ''
const sorted = [...points].sort((a, b) => a[0] - b[0])
const interpolate = createMonotoneInterpolator(points)
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
if (interpolation.value === 'linear') {
return sorted
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${1 - p[1]}`)
.join('')
}
const interpolate = createInterpolator(sorted, interpolation.value)
const xMin = sorted[0][0]
const xMax = sorted[sorted.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []

View File

@@ -1,3 +1,4 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -59,22 +60,26 @@ const EXACT_NAME_MAP: Record<string, string> = {
* (after removing the SubgraphBlueprint. prefix) starts with the key.
* Ordered longest-first so more specific prefixes match before shorter ones.
*/
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
const BLUEPRINT_PREFIX_MAP: [
prefix: string,
displayNameKey: string,
category: EssentialsCategory
][] = [
// Image Generation
['image_inpainting_', 'essentials.inpaintImage'],
['image_outpainting_', 'essentials.outpaintImage'],
['image_edit', 'essentials.imageToImage'],
['text_to_image', 'essentials.textToImage'],
['pose_to_image', 'essentials.poseToImage'],
['canny_to_image', 'essentials.cannyToImage'],
['depth_to_image', 'essentials.depthToImage'],
['image_inpainting_', 'essentials.inpaintImage', 'image generation'],
['image_outpainting_', 'essentials.outpaintImage', 'image generation'],
['image_edit', 'essentials.imageToImage', 'image generation'],
['text_to_image', 'essentials.textToImage', 'image generation'],
['pose_to_image', 'essentials.poseToImage', 'image generation'],
['canny_to_image', 'essentials.cannyToImage', 'image generation'],
['depth_to_image', 'essentials.depthToImage', 'image generation'],
// Video Generation
['text_to_video', 'essentials.textToVideo'],
['image_to_video', 'essentials.imageToVideo'],
['pose_to_video', 'essentials.poseToVideo'],
['canny_to_video', 'essentials.cannyToVideo'],
['depth_to_video', 'essentials.depthToVideo']
['text_to_video', 'essentials.textToVideo', 'video generation'],
['image_to_video', 'essentials.imageToVideo', 'video generation'],
['pose_to_video', 'essentials.poseToVideo', 'video generation'],
['canny_to_video', 'essentials.cannyToVideo', 'video generation'],
['depth_to_video', 'essentials.depthToVideo', 'video generation']
]
function resolveBlueprintDisplayName(
@@ -88,6 +93,59 @@ function resolveBlueprintDisplayName(
return undefined
}
/**
* Resolves the icon class for a blueprint node based on its prefix.
* E.g. `SubgraphBlueprint.canny_to_image_flux` → `"icon-[comfy--canny-to-image]"`
*/
export function resolveBlueprintIcon(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const iconName = prefix.replace(/_$/, '').replaceAll('_', '-')
return `icon-[comfy--${iconName}]`
}
}
return undefined
}
/**
* Extracts the provider/model suffix from a blueprint name for disambiguation.
* E.g. `SubgraphBlueprint.text_to_image_flux_1` → `"Flux 1"`
*/
export function resolveBlueprintSuffix(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const raw = blueprintName.slice(prefix.length).replace(/^_/, '')
if (!raw) return undefined
return raw
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
}
return undefined
}
/**
* Returns the essentials category for a blueprint node based on its name,
* or `undefined` if the blueprint doesn't belong in the essentials tab.
*/
export function resolveBlueprintEssentialsCategory(
nodeName: string
): EssentialsCategory | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix, , category] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
return category
}
}
return undefined
}
/**
* Resolves the Essentials tab display name for a given node definition.
* Returns `undefined` if the node has no Essentials display name mapping.

View File

@@ -108,7 +108,7 @@ app.registerExtension({
'waiting for camera...',
'capture',
capture,
{ canvasOnly: true }
{}
)
btn.disabled = true
btn.serializeValue = () => undefined

View File

@@ -9,9 +9,9 @@ import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -618,6 +618,86 @@ describe.skip('SubgraphNode Cleanup', () => {
})
})
describe('SubgraphNode duplicate input pruning (#9977)', () => {
it('should prune inputs that have no matching subgraph slot after configure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const parentGraph = new LGraph()
const instanceData = {
id: 1 as const,
type: subgraph.id,
pos: [0, 0] as [number, number],
size: [200, 100] as [number, number],
inputs: [
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null },
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null }
],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
}
const node = new SubgraphNode(
parentGraph,
subgraph,
instanceData as ExportedSubgraphInstance
)
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate duplicate inputs on reconfigure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
const serialized = node.serialize()
node.configure(serialized)
expect(node.inputs).toHaveLength(2)
const serialized2 = node.serialize()
node.configure(serialized2)
expect(node.inputs).toHaveLength(2)
})
it('should serialize with exactly the subgraph-defined inputs', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
const serialized = node.serialize()
expect(serialized.inputs).toHaveLength(2)
expect(serialized.inputs?.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -926,6 +926,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Prune inputs that don't map to any subgraph slot definition.
// This prevents stale/duplicate serialized inputs from persisting (#9977).
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []

View File

@@ -1,5 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type { CurveData } from '@/components/curve/types'
import type {
CanvasColour,
@@ -331,9 +331,9 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
export interface ICurveWidget extends IBaseWidget<CurveData, 'curve'> {
type: 'curve'
value: CurvePoint[]
value: CurveData
}
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {

View File

@@ -136,9 +136,11 @@
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
"manageExtensions": "Manage extensions",
"gallery": "Gallery",
"graphNavigation": "Graph navigation",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"previous": "Previous",
"next": "Next",
"submit": "Submit",
"install": "Install",
@@ -857,9 +859,9 @@
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites",
"favoriteNode": "Favorite Node",
"unfavoriteNode": "Unfavorite Node",
"favorites": "Bookmarks",
"favoriteNode": "Bookmark Node",
"unfavoriteNode": "Unbookmark Node",
"bookmarked": "Bookmarked",
"subgraphBlueprints": "Subgraph Blueprints",
"myBlueprints": "My Blueprints",
@@ -868,7 +870,7 @@
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"noBookmarkedNodes": "No favorites yet"
"noBookmarkedNodes": "No bookmarks yet"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -1974,6 +1976,10 @@
"width": "Width",
"height": "Height"
},
"curveWidget": {
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -3569,9 +3575,9 @@
"removeBackground": "Remove Background",
"imageCompare": "Image compare",
"extractFrame": "Extract frame",
"loadStyleLora": "Load style (LoRA)",
"loadStyleLora": "Load style",
"lipsync": "Lipsync",
"textGenerationLLM": "Text generation (LLM)",
"textGenerationLLM": "Text generation",
"textTo3DModel": "Text to 3D model",
"imageTo3DModel": "Image to 3D Model",
"musicGeneration": "Music generation",

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
@@ -10,16 +11,26 @@ const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
(_story, context) => ({
components: { MediaLightbox },
setup() {
const galleryStore = useMediaAssetGalleryStore()
;(context.args as Record<string, unknown>).onZoom = (
asset: AssetItem
) => {
const kind = getMediaTypeFromFilename(asset.name)
galleryStore.openSingle({
...asset,
kind,
src: asset.preview_url || ''
})
}
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
<MediaLightbox
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
type MockItem = Pick<
ResultItemImpl,
'filename' | 'url' | 'isImage' | 'isVideo' | 'isAudio'
>
const SAMPLE_IMAGES: MockItem[] = [
{
filename: 'landscape.jpg',
url: 'https://i.imgur.com/OB0y6MR.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'portrait.jpg',
url: 'https://i.imgur.com/CzXTtJV.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'nature.jpg',
url: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
isImage: true,
isVideo: false,
isAudio: false
}
]
const meta: Meta<typeof MediaLightbox> = {
title: 'Platform/Assets/MediaLightbox',
component: MediaLightbox
}
export default meta
type Story = StoryObj<typeof meta>
export const MultipleImages: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Use arrow keys to navigate, Escape to close. Click backdrop to close.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
Open {{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const SingleImage: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = [SAMPLE_IMAGES[0]] as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Single image — no navigation buttons shown.
</p>
<button
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = 0"
>
Open lightbox
</button>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const Closed: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(-1)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Lightbox is closed (activeIndex = -1). Click a button to open.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
{{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}

View File

@@ -1,3 +1,4 @@
import type { CurveData } from '@/components/curve/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ICurveWidget } from '@/lib/litegraph/src/types/widgets'
import type {
@@ -6,20 +7,22 @@ import type {
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
const DEFAULT_CURVE_DATA: CurveData = {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
}
export const useCurveWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ICurveWidget => {
const spec = inputSpec as CurveInputSpec
const defaultValue = spec.default ?? [
[0, 0],
[1, 1]
]
const defaultValue: CurveData = spec.default
? { ...spec.default, points: [...spec.default.points] }
: { ...DEFAULT_CURVE_DATA, points: [...DEFAULT_CURVE_DATA.points] }
const rawWidget = node.addWidget(
'curve',
spec.name,
[...defaultValue],
() => {}
)
const rawWidget = node.addWidget('curve', spec.name, defaultValue, () => {})
if (rawWidget.type !== 'curve') {
throw new Error(`Unexpected widget type: ${rawWidget.type}`)

View File

@@ -128,11 +128,16 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
const zCurvePoint = z.tuple([z.number(), z.number()])
const zCurveData = z.object({
points: z.array(zCurvePoint),
interpolation: z.enum(['monotone_cubic', 'linear'])
})
const zCurveInputSpec = zBaseInputOptions.extend({
type: z.literal('CURVE'),
name: z.string(),
isOptional: z.boolean().optional(),
default: z.array(zCurvePoint).optional()
default: zCurveData.optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({

View File

@@ -1,5 +1,9 @@
import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import { ESSENTIALS_NODES } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
@@ -144,11 +148,16 @@ class NodeOrganizationService {
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const essentialNodes = nodes.filter(
(nodeDef) => !!nodeDef.essentials_category
(nodeDef) =>
!!nodeDef.essentials_category ||
!!resolveBlueprintEssentialsCategory(nodeDef.name)
)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (nodeDef) => {
const folder = nodeDef.essentials_category || ''
const folder =
nodeDef.essentials_category ||
resolveBlueprintEssentialsCategory(nodeDef.name) ||
''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
})
@@ -158,6 +167,14 @@ class NodeOrganizationService {
private sortEssentialsFolders(tree: TreeNode): void {
if (!tree.children) return
const catLen = ESSENTIALS_CATEGORIES.length
tree.children.sort((a, b) => {
const ai = ESSENTIALS_CATEGORIES.indexOf(a.label as EssentialsCategory)
const bi = ESSENTIALS_CATEGORIES.indexOf(b.label as EssentialsCategory)
return (ai === -1 ? catLen : ai) - (bi === -1 ? catLen : bi)
})
for (const folder of tree.children) {
if (!folder.children) continue
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]