Compare commits

..

7 Commits

Author SHA1 Message Date
CodeRabbit Fixer
d35bd7e5b9 fix: Add extensive unit test coverage for curveUtils math/geometry functions (#9112)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:51:25 +01:00
AustinMroz
55b8236c8d Fix localization on share and hide entry (#9395)
A placeholder share entry was added in #9368, but the localization for
this share label was then removed in #9361.

This localization is re-added in a location that is less likely to be
overwritten and the menu item is set to hidden. I'll manually connect it
to the workflow sharing feature flag in a followup PR after that has
been merged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9395-Fix-localization-on-share-and-hide-entry-3196d73d36508146a343f625a5327bdd)
by [Unito](https://www.unito.io)
2026-03-06 09:35:18 -08:00
Johnpaul Chiwetelu
5e17bbbf85 feat: expose litegraph internal keybindings (#9459)
## Summary

Migrate hardcoded litegraph canvas keybindings (Ctrl+A/C/V, Delete,
Backspace) into the customizable keybinding system so users can remap
them via Settings > Keybindings.

## Changes

- **What**: Register Ctrl+A (SelectAll), Ctrl+C (CopySelected), Ctrl+V
(PasteFromClipboard), Ctrl+Shift+V (PasteFromClipboardWithConnect),
Delete/Backspace (DeleteSelectedItems) as core keybindings in
`defaults.ts`. Add new `PasteFromClipboardWithConnect` command. Remove
hardcoded handling from litegraph `processKey()`, the `app.ts` Ctrl+C/V
monkey-patch, and the `keybindingService` canvas forwarding logic.

Fixes #1082
Fixes #2015

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9459-feat-expose-litegraph-internal-keybindings-31b6d73d3650819a8499fd96c8a6678f)
by [Unito](https://www.unito.io)
2026-03-06 18:30:35 +01:00
Johnpaul Chiwetelu
7cb07f9b2d fix: standardize i18n pluralization to two-part English format (#9384)
## Summary

Standardize 5 English pluralization strings from incorrect 3-part format
to proper 2-part `"singular | plural"` format.

## Changes

- **What**: Convert `nodesCount`, `asset`, `errorCount`,
`downloadsFailed`, and `exportFailed` i18n keys from redundant 3-part
pluralization (zero/one/many) to standard 2-part English format
(singular/plural)

## Review Focus

The 3-part format (`a | b | a`) was redundant for English since the
first and third parts were identical. vue-i18n only needs 2 parts for
English pluralization.

Fixes #9277

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9384-fix-standardize-i18n-pluralization-to-two-part-English-format-3196d73d365081cf97c4e7cfa310ce8e)
by [Unito](https://www.unito.io)
2026-03-06 14:53:13 +01:00
Christian Byrne
a0abe3e36f chore: add deprecation warning for legacy queue/history menu (#9460)
## Summary

Add console.warn deprecation notice when the legacy ComfyList
queue/history menu is instantiated.

## Changes

- **What**: Log a deprecation warning in the `ComfyList` constructor
telling users the legacy menu is deprecated, may break, and won't
receive support. Includes instructions to switch via Settings → "Use new
menu" → "Top".

## Review Focus

Wording of the user-facing console warning.

Fixes #8100 (Phase 1)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9460-chore-add-deprecation-warning-for-legacy-queue-history-menu-31b6d73d365081ffa041cad33e8cd9a7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-06 00:32:47 -08:00
Jin Yi
96fd25de5c feat: add Logo C fill and Comfy wave loading indicator components (#9433)
## Summary

Add SVG-based brand loading indicators (LogoCFillLoader,
LogoComfyWaveLoader) and use the wave loader as the app loading screen.

## Changes

- **What**: New `LogoCFillLoader` (bottom-to-top fill, plays once) and
`LogoComfyWaveLoader` (wave water-fill animation) components with
`size`, `color`, `bordered`, and `disableAnimation` props. Move all
loaders from `components/common/` to `components/loader/`. Use
`LogoComfyWaveLoader` in `App.vue` and `WorkspaceAuthGate.vue`. Render
loader above BlockUI overlay (z-1200) to prevent dim wash-out.
- **Dependencies**: None

## Review Focus

- SVG mask-based animation approach using `currentColor` for flexible
theming
- z-index layering: loader at z-1200 renders above PrimeVue BlockUI's
z-1100 modal overlay
- `disableAnimation` prop used in WorkspaceAuthGate to show static logo
outline during auth loading

## Screenshots (if applicable)

[loading_record.webm](https://github.com/user-attachments/assets/b34f7296-9904-4a42-9273-a7d5fda49d15)

Storybook stories added for both components under `Components/Loader/`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9433-feat-add-Logo-C-fill-and-Comfy-wave-loading-indicator-components-31a6d73d3650811cacfdcf867b1f835f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-06 00:32:20 -08:00
Christian Byrne
e2cb3560cc test: fix flaky no_workflow.webp screenshot test (#9458)
## Summary

Fix flaky `no_workflow.webp` screenshot test by waiting for async upload
and `/view` response before asserting.

## Changes

- **What**: In `loadWorkflowInMedia.spec.ts`, added `waitForUpload:
true` for `no_workflow.webp` and a `waitForResponse` call for the
`/view` endpoint. This ensures the error toast (from the 500 response)
is consistently visible before the screenshot assertion.

## Review Focus

The fix is scoped to `no_workflow.webp` only (via a `filesWithUpload`
Set) since it's the only test file that triggers an upload + `/view`
call. Other media files embed workflows and don't hit this path.

Fixes #9450

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9458-test-fix-flaky-no_workflow-webp-screenshot-test-31b6d73d365081b88deaee91769baec1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-05 18:27:43 -08:00
27 changed files with 1299 additions and 265 deletions

View File

@@ -29,11 +29,23 @@ test.describe(
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
// 'workflow.avif'
]
const filesWithUpload = new Set(['no_workflow.webp'])
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
}
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

View File

@@ -1,13 +1,13 @@
<template>
<router-view />
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center"
>
<Loader size="lg" class="text-white" />
</div>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
</template>
<script setup lang="ts">
@@ -15,7 +15,7 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted } from 'vue'
import Loader from '@/components/common/Loader.vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -9,133 +9,686 @@ import {
} from './curveUtils'
describe('createMonotoneInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createMonotoneInterpolator([])
expect(interpolate(0.5)).toBe(0)
describe('degenerate inputs', () => {
it('returns 0 for empty points', () => {
const interpolate = createMonotoneInterpolator([])
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBe(0)
expect(interpolate(1)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(0.5)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('handles two points as linear segment', () => {
const interpolate = createMonotoneInterpolator([
[0, 0],
[1, 1]
])
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.25)).toBeCloseTo(0.25, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(0.75)).toBeCloseTo(0.75, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('handles duplicate x values', () => {
const interpolate = createMonotoneInterpolator([
[0.5, 0.2],
[0.5, 0.8]
])
// Should not throw or produce NaN
const y = interpolate(0.5)
expect(Number.isFinite(y)).toBe(true)
})
})
it('returns constant for single point', () => {
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
describe('pass-through and interpolation', () => {
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('passes through many control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.1, 0.05],
[0.3, 0.2],
[0.5, 0.5],
[0.7, 0.8],
[0.9, 0.95],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
for (const [x, y] of points) {
expect(interpolate(x)).toBeCloseTo(y, 5)
}
})
it('interpolated values are between neighboring control points for monotone input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.1],
[0.5, 0.5],
[0.75, 0.9],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
// Test midpoints between each pair
for (let i = 0; i < points.length - 1; i++) {
const midX = (points[i][0] + points[i + 1][0]) / 2
const y = interpolate(midX)
const minY = Math.min(points[i][1], points[i + 1][1])
const maxY = Math.max(points[i][1], points[i + 1][1])
expect(y).toBeGreaterThanOrEqual(minY - 1e-10)
expect(y).toBeLessThanOrEqual(maxY + 1e-10)
}
})
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
describe('clamping and boundary behavior', () => {
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(0.1)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
expect(interpolate(1.5)).toBe(0.9)
})
it('clamps for negative x values', () => {
const interpolate = createMonotoneInterpolator([
[0, 0.5],
[1, 1]
])
expect(interpolate(-0.5)).toBe(0.5)
expect(interpolate(-100)).toBe(0.5)
})
it('clamps for x values far beyond range', () => {
const interpolate = createMonotoneInterpolator([
[0, 0],
[1, 1]
])
expect(interpolate(1000)).toBe(1)
})
it('returns correct value at exact boundary x values', () => {
const interpolate = createMonotoneInterpolator([
[0, 0.2],
[1, 0.8]
])
expect(interpolate(0)).toBeCloseTo(0.2, 5)
expect(interpolate(1)).toBeCloseTo(0.8, 5)
})
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
describe('monotonicity', () => {
it('produces monotone increasing output for monotone increasing input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.001) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev - 1e-10)
prev = y
}
})
it('produces monotone decreasing output for monotone decreasing input', () => {
const points: CurvePoint[] = [
[0, 1],
[0.25, 0.8],
[0.5, 0.5],
[0.75, 0.2],
[1, 0]
]
const interpolate = createMonotoneInterpolator(points)
let prev = Infinity
for (let x = 0; x <= 1; x += 0.001) {
const y = interpolate(x)
expect(y).toBeLessThanOrEqual(prev + 1e-10)
prev = y
}
})
it('preserves monotonicity with steep transitions', () => {
const points: CurvePoint[] = [
[0, 0],
[0.1, 0.01],
[0.5, 0.5],
[0.9, 0.99],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.001) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev - 1e-10)
prev = y
}
})
})
it('produces monotone output for monotone input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
describe('sorting and ordering', () => {
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev)
prev = y
}
it('produces same result regardless of input order', () => {
const sorted: CurvePoint[] = [
[0, 0],
[0.3, 0.4],
[0.6, 0.7],
[1, 1]
]
const reversed: CurvePoint[] = [...sorted].reverse() as CurvePoint[]
const shuffled: CurvePoint[] = [
sorted[2],
sorted[0],
sorted[3],
sorted[1]
]
const f1 = createMonotoneInterpolator(sorted)
const f2 = createMonotoneInterpolator(reversed)
const f3 = createMonotoneInterpolator(shuffled)
for (let x = 0; x <= 1; x += 0.05) {
expect(f1(x)).toBeCloseTo(f2(x), 10)
expect(f1(x)).toBeCloseTo(f3(x), 10)
}
})
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
})
describe('non-monotone and special curves', () => {
it('handles flat segment (constant y)', () => {
const points: CurvePoint[] = [
[0, 0.5],
[0.5, 0.5],
[1, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
for (let x = 0; x <= 1; x += 0.1) {
expect(interpolate(x)).toBeCloseTo(0.5, 5)
}
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
expect(lut).toBeInstanceOf(Uint8Array)
expect(lut.length).toBe(256)
it('handles step-like curve with flat regions', () => {
const points: CurvePoint[] = [
[0, 0],
[0.4, 0],
[0.6, 1],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
// Flat regions should stay flat
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.2)).toBeCloseTo(0, 5)
expect(interpolate(0.8)).toBeCloseTo(1, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('handles non-monotone (wave-like) input without NaN', () => {
const points: CurvePoint[] = [
[0, 0.5],
[0.25, 1],
[0.5, 0.5],
[0.75, 0],
[1, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(Number.isFinite(y)).toBe(true)
}
// Should pass through control points
expect(interpolate(0)).toBeCloseTo(0.5, 5)
expect(interpolate(0.25)).toBeCloseTo(1, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(0.75)).toBeCloseTo(0, 5)
expect(interpolate(1)).toBeCloseTo(0.5, 5)
})
it('handles y values outside [0, 1]', () => {
const points: CurvePoint[] = [
[0, -0.5],
[0.5, 2],
[1, -1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(-0.5, 5)
expect(interpolate(0.5)).toBeCloseTo(2, 5)
expect(interpolate(1)).toBeCloseTo(-1, 5)
})
})
it('produces identity LUT for diagonal curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeCloseTo(i, 0)
}
describe('numerical precision', () => {
it('handles very closely spaced x values', () => {
const points: CurvePoint[] = [
[0, 0],
[0.0001, 0.5],
[0.0002, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.0001)).toBeCloseTo(0.5, 5)
expect(interpolate(0.0002)).toBeCloseTo(1, 5)
expect(Number.isFinite(interpolate(0.00015))).toBe(true)
})
it('handles very large y values', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 1e6],
[1, 0]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0.5)).toBeCloseTo(1e6, -1)
expect(Number.isFinite(interpolate(0.25))).toBe(true)
})
it('returns finite values for all inputs across dense sampling', () => {
const points: CurvePoint[] = [
[0, 0],
[0.2, 0.3],
[0.4, 0.7],
[0.6, 0.4],
[0.8, 0.9],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
for (let x = -0.1; x <= 1.1; x += 0.001) {
expect(Number.isFinite(interpolate(x))).toBe(true)
}
})
})
it('clamps output to [0, 255]', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 1.5],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
expect(lut[i]).toBeLessThanOrEqual(255)
}
describe('slope limiting (Fritsch-Carlson condition)', () => {
it('limits slopes when alpha^2 + beta^2 > 9', () => {
// Create points where slopes would be excessively steep
// without the Fritsch-Carlson limiting
const points: CurvePoint[] = [
[0, 0],
[0.1, 0.9],
[0.2, 0.1],
[0.3, 0.95],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
// Should still produce finite, reasonable values
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(Number.isFinite(y)).toBe(true)
}
// Should pass through control points
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.1)).toBeCloseTo(0.9, 5)
expect(interpolate(0.2)).toBeCloseTo(0.1, 5)
})
})
describe('binary search correctness', () => {
it('produces continuous output across segment boundaries', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.3],
[0.5, 0.6],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
// Test continuity at segment boundaries
const eps = 1e-8
for (const [x] of points.slice(1, -1)) {
const left = interpolate(x - eps)
const at = interpolate(x)
const right = interpolate(x + eps)
expect(Math.abs(left - at)).toBeLessThan(1e-4)
expect(Math.abs(right - at)).toBeLessThan(1e-4)
}
})
})
})
describe('histogramToPath', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
describe('empty and zero inputs', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
})
it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
})
})
it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
describe('path structure', () => {
it('returns a closed SVG path starting at M0,1 and ending at L1,1 Z', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
})
it('generates exactly 258 segments (M + 256 L + L1,1 Z)', () => {
const histogram = new Uint32Array(256)
histogram.fill(50)
const path = histogramToPath(histogram)
const parts = path.split(' ')
// M0,1 + 256 L segments + "L1,1" + "Z"
expect(parts.length).toBe(259) // M0,1, L0,y, L..., L1,y, L1,1, Z
})
it('x values span from 0 to 1 in 256 steps', () => {
const histogram = new Uint32Array(256)
histogram.fill(100)
const path = histogramToPath(histogram)
const segments = path.split(' ').filter((s) => s.startsWith('L'))
// Remove the closing L1,1
const dataSegments = segments.slice(0, -1)
expect(dataSegments.length).toBe(256)
const firstX = parseFloat(dataSegments[0].substring(1).split(',')[0])
const lastX = parseFloat(
dataSegments[dataSegments.length - 1].substring(1).split(',')[0]
)
expect(firstX).toBeCloseTo(0, 5)
expect(lastX).toBeCloseTo(1, 5)
})
})
it('returns a closed SVG path for valid histogram', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
describe('normalization', () => {
it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
})
it('clamps values exceeding the percentile max to y=0', () => {
const histogram = new Uint32Array(256)
histogram.fill(50)
// Set last two bins to extreme values
histogram[254] = 500
histogram[255] = 10000
const path = histogramToPath(histogram)
// The outlier bins should have their y values clamped to 0
// (1 - min(1, val/max) where val > max -> y = 0)
const segments = path.split(' ').filter((s) => s.startsWith('L'))
const lastDataSegment = segments[segments.length - 2] // second to last L (before L1,1)
const y = parseFloat(lastDataSegment.split(',')[1])
expect(y).toBe(0)
})
it('uniform histogram produces uniform y values', () => {
const histogram = new Uint32Array(256)
histogram.fill(200)
const path = histogramToPath(histogram)
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
// Skip first (M0,1) and last (L1,1 Z) entries
const dataYValues = yValues.slice(1, -1)
// All should be near 0 (1 - 200/200 = 0)
for (const y of dataYValues) {
expect(y).toBeCloseTo(0, 5)
}
})
})
it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
describe('single-bin histograms', () => {
it('handles histogram with only one non-zero bin', () => {
const histogram = new Uint32Array(256)
histogram[128] = 1000
const path = histogramToPath(histogram)
// The 99.5th percentile of a mostly-zero array may be 0,
// but since max is sorted[floor(255*0.995)] = sorted[253],
// and we have 255 zeros and 1 non-zero, sorted[253] = 0
// So max=0, function returns ''
expect(path).toBe('')
})
it('handles histogram with a few non-zero bins', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 10; i++) histogram[i] = 100
const path = histogramToPath(histogram)
// sorted[253] = 100 (non-zero bins sort to end), so path is generated
// The non-zero bins should produce y < 1, zero bins produce y = 1
expect(path).not.toBe('')
expect(path).toMatch(/^M0,1/)
})
it('returns path when enough bins are non-zero', () => {
const histogram = new Uint32Array(256)
// Fill all bins to ensure percentile > 0
histogram.fill(1)
histogram[0] = 100
const path = histogramToPath(histogram)
expect(path).not.toBe('')
expect(path).toMatch(/^M0,1/)
})
})
describe('y value range', () => {
it('all y values are in [0, 1]', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(Math.random() * 1000) + 1
const path = histogramToPath(histogram)
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
for (const y of yValues) {
expect(y).toBeGreaterThanOrEqual(0)
expect(y).toBeLessThanOrEqual(1)
}
})
})
})
describe('curvesToLUT', () => {
describe('basic properties', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
expect(lut).toBeInstanceOf(Uint8Array)
expect(lut.length).toBe(256)
})
it('all values are in [0, 255]', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 0.8],
[1, 0.2]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
expect(lut[i]).toBeLessThanOrEqual(255)
}
})
})
describe('identity and constant curves', () => {
it('produces identity LUT for diagonal curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeCloseTo(i, 0)
}
})
it('produces constant LUT for flat curve', () => {
const lut = curvesToLUT([
[0, 0.5],
[1, 0.5]
])
const expected = Math.round(0.5 * 255) // 128
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBe(expected)
}
})
it('produces all-zero LUT for y=0 curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 0]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBe(0)
}
})
it('produces all-255 LUT for y=1 curve', () => {
const lut = curvesToLUT([
[0, 1],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBe(255)
}
})
})
describe('clamping', () => {
it('clamps output when interpolation exceeds 1', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 1.5],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeLessThanOrEqual(255)
}
})
it('clamps output when interpolation goes below 0', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, -0.5],
[1, 0]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
}
})
})
describe('endpoint values', () => {
it('first LUT entry matches curve y at x=0', () => {
const lut = curvesToLUT([
[0, 0.3],
[1, 0.8]
])
expect(lut[0]).toBe(Math.round(0.3 * 255))
})
it('last LUT entry matches curve y at x=1', () => {
const lut = curvesToLUT([
[0, 0.3],
[1, 0.8]
])
expect(lut[255]).toBe(Math.round(0.8 * 255))
})
})
describe('monotonicity in LUT', () => {
it('produces monotone increasing LUT for monotone increasing curve', () => {
const lut = curvesToLUT([
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
])
for (let i = 1; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1])
}
})
it('produces monotone decreasing LUT for inverted curve', () => {
const lut = curvesToLUT([
[0, 1],
[0.5, 0.5],
[1, 0]
])
for (let i = 1; i < 256; i++) {
expect(lut[i]).toBeLessThanOrEqual(lut[i - 1])
}
})
})
describe('edge cases', () => {
it('handles empty points (all zeros)', () => {
const lut = curvesToLUT([])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBe(0)
}
})
it('handles single point', () => {
const lut = curvesToLUT([[0.5, 0.7]])
const expected = Math.round(0.7 * 255) // 179
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBe(expected)
}
})
it('values are rounded to nearest integer', () => {
const lut = curvesToLUT([
[0, 0.5],
[1, 0.5]
])
// 0.5 * 255 = 127.5 -> rounds to 128
expect(lut[0]).toBe(128)
})
})
})

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Loader from './Loader.vue'
const meta: Meta<typeof Loader> = {
title: 'Components/Common/Loader',
title: 'Components/Loader/Loader',
component: Loader,
tags: ['autodocs'],
parameters: {

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -0,0 +1,100 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoComfyWaveLoader from './LogoComfyWaveLoader.vue'
const meta: Meta<typeof LogoComfyWaveLoader> = {
title: 'Components/Loader/LogoComfyWaveLoader',
component: LogoComfyWaveLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#F0FF41 (Yellow)</span>
<LogoComfyWaveLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#172DD7 (Blue)</span>
<LogoComfyWaveLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoComfyWaveLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoComfyWaveLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-8">
<LogoComfyWaveLoader size="sm" color="yellow" />
<LogoComfyWaveLoader size="md" color="yellow" />
<LogoComfyWaveLoader size="lg" color="yellow" />
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
`
})
}

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -905,6 +905,14 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
@@ -919,6 +927,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}

View File

@@ -189,11 +189,10 @@ export function useWorkflowActionsMenu(
addItem({
id: 'share',
label: t('menuLabels.Share'),
label: t('breadcrumbsMenu.share'),
icon: 'icon-[comfy--send]',
command: async () => {},
disabled: true,
visible: isRoot
visible: false
})
addItem({

View File

@@ -3791,13 +3791,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
this.canvas.dispatchEvent(event)
}
/**
* process a key event
*/
@@ -3842,31 +3835,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_panel?.close()
this.options_panel?.close()
if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
block_default = true
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
// copy
if (this.selected_nodes) {
this.copyToClipboard()
block_default = true
}
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
// paste
this.pasteFromClipboard({ connectInputs: e.shiftKey })
} else if (e.key === 'Delete' || e.key === 'Backspace') {
// delete or backspace
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
return
}
this.deleteSelected()
block_default = true
}
}
// TODO

View File

@@ -178,7 +178,7 @@
"uploadAlreadyInProgress": "Upload already in progress",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"nodesCount": "{count} node | {count} nodes",
"addNode": "Add a node...",
"filterBy": "Filter by:",
"filterByType": "Filter by {type}...",
@@ -222,7 +222,7 @@
"failed": "Failed",
"cancelled": "Cancelled",
"job": "Job",
"asset": "{count} assets | {count} asset | {count} assets",
"asset": "{count} asset | {count} assets",
"untitled": "Untitled",
"emDash": "—",
"enabling": "Enabling {id}",
@@ -1262,6 +1262,7 @@
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Paste": "Paste",
"Paste with Connect": "Paste with Connect",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Select All": "Select All",
@@ -2603,7 +2604,8 @@
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
"enterNewName": "Enter new name",
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
"share": "Share"
},
"shortcuts": {
"shortcuts": "Shortcuts",
@@ -3347,7 +3349,7 @@
}
},
"errorOverlay": {
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
"errorCount": "{count} ERROR | {count} ERRORS",
"seeErrors": "See Errors"
},
"help": {
@@ -3357,7 +3359,7 @@
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"downloadsFailed": "{count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
@@ -3374,7 +3376,7 @@
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"exportFailed": "{count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",

View File

@@ -74,7 +74,7 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'

View File

@@ -2,7 +2,7 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetExport } from '@/stores/assetExportStore'

View File

@@ -4,7 +4,7 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -106,7 +106,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import Button from '@/components/ui/button/Button.vue'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'

View File

@@ -208,5 +208,52 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
},
{
combo: {
ctrl: true,
key: 'a'
},
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
shift: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Delete'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
}
]

View File

@@ -1,22 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
@@ -36,13 +25,15 @@ function createTestKeyboardEvent(
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
shiftKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false
metaKey = false,
shiftKey = false
} = options
const event = new KeyboardEvent('keydown', {
@@ -50,6 +41,7 @@ function createTestKeyboardEvent(
ctrlKey,
altKey,
metaKey,
shiftKey,
bubbles: true,
cancelable: true
})
@@ -60,8 +52,10 @@ function createTestKeyboardEvent(
return event
}
describe('keybindingService - Event Forwarding', () => {
describe('keybindingService - Canvas Keybindings', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let canvasContainer: HTMLDivElement
let canvasChild: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
@@ -76,94 +70,156 @@ describe('keybindingService - Event Forwarding', () => {
typeof useDialogStore
>)
canvasContainer = document.createElement('div')
canvasContainer.id = 'graph-canvas-container'
canvasChild = document.createElement('canvas')
canvasContainer.appendChild(canvasChild)
document.body.appendChild(canvasContainer)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
afterEach(() => {
canvasContainer.remove()
})
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
const event = createTestKeyboardEvent('Delete', {
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
const event = createTestKeyboardEvent('Backspace', {
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
})
it('should not forward Delete key when typing in input field', async () => {
it('should not execute DeleteSelectedItems when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward Delete key when typing in textarea', async () => {
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
const event = createTestKeyboardEvent('Delete', {
target: textareaElement
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined - testing edge case
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined!
const event = createTestKeyboardEvent('Delete')
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should not forward Delete key when canvas is not available', async () => {
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
const event = createTestKeyboardEvent('Delete')
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
it('should execute SelectAll for Ctrl+A on canvas', async () => {
const event = createTestKeyboardEvent('a', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.SelectAll'
)
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
it('should execute CopySelected for Ctrl+C on canvas', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
shiftKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboardWithConnect'
)
})
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
const event = createTestKeyboardEvent('=', {
altKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.ZoomIn'
)
})
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('=', {
altKey: true,
target: outsideDiv
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
it('should not execute canvas commands when target is outside canvas container', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('Delete', {
target: outsideDiv
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
})

View File

@@ -1,6 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,16 +14,6 @@ export function useKeybindingService() {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -44,7 +33,17 @@ export function useKeybindingService() {
}
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
if (keybinding) {
const targetElementId =
keybinding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: keybinding.targetElementId
if (targetElementId) {
const container = document.getElementById(targetElementId)
if (!container?.contains(target)) {
return
}
}
if (
event.key === 'Escape' &&
!event.ctrlKey &&
@@ -74,18 +73,6 @@ export function useKeybindingService() {
return
}
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
canvas.processKey(event)
return
}
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}

View File

@@ -4,7 +4,7 @@
v-else
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
>
<Loader size="lg" class="text-white" />
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
</div>
</template>
@@ -22,7 +22,6 @@
* instead of workspace tokens when the workspace feature is enabled.
*/
import { promiseTimeout, until } from '@vueuse/core'
import Loader from '@/components/common/Loader.vue'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
@@ -31,6 +30,7 @@ import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000

View File

@@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -30,7 +30,7 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import Loader from '@/components/common/Loader.vue'
import Loader from '@/components/loader/Loader.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {

View File

@@ -676,20 +676,6 @@ export class ComfyApp {
e.stopImmediatePropagation()
return
}
// Ctrl+C Copy
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
return
}
// Ctrl+V Paste
if (
(e.key === 'v' || e.key == 'V') &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
return
}
}
// Fall through to Litegraph defaults

View File

@@ -245,6 +245,13 @@ class ComfyList {
this._reverse = reverse || false
this.element = $el('div.comfy-list') as HTMLDivElement
this.element.style.display = 'none'
console.warn(
'[ComfyUI] The legacy queue/history menu is deprecated. ' +
'Core functionality in this menu may break at any time. ' +
'Issues and feature requests related to the legacy menu will not be addressed. ' +
'To switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".'
)
}
get visible() {