Compare commits

..

13 Commits

Author SHA1 Message Date
Jin Yi
5591a11c2f fix: use shared MENU_HEIGHT/MENU_WIDTH constants in FormDropdownMenu
Use the shared constants from types.ts instead of hardcoded Tailwind
classes so dimension changes are reflected in both the menu component
and the positioning logic in FormDropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:11:18 +09:00
Jin Yi
b09778e879 fix: clamp teleported dropdown position to viewport bounds
When neither upward nor downward direction has enough space for the
full menu height, clamp the position so the menu stays within the
viewport instead of overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
3e587c7758 fix: prevent teleported dropdown from overflowing viewport top
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d64-af34-7489-abd5-cde23ead7105
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0d21b3aee6 fix: extract MENU_HEIGHT/MENU_WIDTH as shared constants, drop computed for shouldTeleport
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d37-f1a3-7421-90b9-b4d8d058bedb
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:06:31 +09:00
Jin Yi
8ecbbfcd48 fix: flip teleported dropdown upward when near viewport bottom
Apply the same openUpward logic for both teleported and local cases.
When teleported, use bottom CSS property to open upward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
bcbeaa8c91 fix: teleport FormDropdown to body in app mode with bottom-right positioning
Inject OverlayAppendToKey to detect app mode vs canvas. In app mode,
use Teleport to body with position:fixed at the trigger's bottom-right
corner, clamped to viewport bounds. In canvas, keep local absolute
positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
77ee629d05 Revert "fix: restore teleport for FormDropdown in app mode"
This reverts commit 8a88e40c40.
2026-04-07 11:06:31 +09:00
Jin Yi
cc8dd9a981 fix: restore teleport for FormDropdown in app mode
Inject OverlayAppendToKey to detect app mode ('body') vs canvas
('self'). In app mode, use <Teleport to="body"> with position:fixed
to escape overflow-hidden/overflow-y-auto ancestors. In canvas, keep
local absolute positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0bb9daeb2c fix: prefer direction with more available space for dropdown
Compare space above vs below the trigger and open toward whichever
side has more room. Prevents flipping upward when the menu would
overflow even more in that direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
be20d5f563 fix: flip dropdown upward when near viewport bottom
Use getBoundingClientRect() only for direction detection (not
positioning), so it works safely even inside CSS transform chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0720125c77 fix: stabilize E2E tests for FormDropdown positioning
- Replace fragile CSS selectors with data-testid for trigger button
- Update appModeDropdownClipping to use getByTestId after Popover removal
- Change zoom test from 0.5 to 0.75 to avoid too-small click targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:31 +09:00
Jin Yi
0c3045ca5a test: add Playwright tests for FormDropdown positioning
Amp-Thread-ID: https://ampcode.com/threads/T-019d2285-317d-75db-b838-15f7d9b55b3c
Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 11:05:27 +09:00
Jin Yi
8044bf39b7 fix: formdropdown position 2026-04-07 11:05:27 +09:00
50 changed files with 346 additions and 1991 deletions

View File

@@ -484,16 +484,7 @@ export class SubgraphHelper {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.dispatchEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.press('Control+a')
await this.comfyPage.nextFrame()
await this.page.evaluate(() => {
@@ -502,16 +493,7 @@ export class SubgraphHelper {
})
await this.comfyPage.nextFrame()
await this.exitViaBreadcrumb()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.dispatchEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.click()
await this.comfyPage.nextFrame()
}

View File

@@ -58,16 +58,6 @@ export class WorkflowHelper {
await this.comfyPage.nextFrame()
}
async waitForDraftPersisted({ timeout = 5000 } = {}) {
await this.comfyPage.page.waitForFunction(
() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
),
{ timeout }
)
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)

View File

@@ -100,7 +100,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu',
formDropdownTrigger: 'form-dropdown-trigger'
},
builder: {
footerNav: 'builder-footer-nav',

View File

@@ -4,6 +4,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
@@ -143,12 +144,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 })
const menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
.first()
await expect(menu).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const isInViewport = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -159,7 +160,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -1,78 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe(
'Subgraph node positions after draft reload',
{ tag: ['@subgraph'] },
() => {
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
comfyPage
}) => {
test.setTimeout(30000)
// Enable workflow persistence explicitly
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load a workflow containing a subgraph
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// Enter the subgraph programmatically (fixture node is too small for UI click)
await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
if (sg) window.app!.canvas.setGraph(sg)
})
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const positionsBefore = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
expect(positionsBefore.length).toBeGreaterThan(0)
// Wait for the debounced draft persistence to flush to localStorage
await comfyPage.workflow.waitForDraftPersisted()
// Reload the page (draft auto-loads with hash preserved)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// Wait for subgraph auto-entry via hash navigation
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
.toBe(true)
// Verify all internal node positions are preserved
const positionsAfter = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
for (const before of positionsBefore) {
const after = positionsAfter.find((n) => n.id === before.id)
expect(
after,
`Node ${before.id} should exist after reload`
).toBeDefined()
expect(after!.x).toBeCloseTo(before.x, 0)
expect(after!.y).toBeCloseTo(before.y, 0)
}
})
}
)

View File

@@ -0,0 +1,116 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe(
'FormDropdown positioning in Vue nodes',
{ tag: ['@widget', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
test('dropdown menu appears directly below the trigger', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20
)
// Menu left should be near the trigger left (within 10px tolerance)
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
})
test('dropdown menu appears correctly at different zoom levels', async ({
comfyPage
}) => {
for (const zoom of [0.75, 1.5]) {
// Set zoom via canvas
await comfyPage.page.evaluate((scale) => {
const canvas = window.app!.canvas
canvas.ds.scale = scale
canvas.setDirty(true, true)
}, zoom)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(
TestIds.widgets.formDropdownMenu
)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should still be near trigger bottom regardless of zoom
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20 * zoom
)
// Close dropdown before next iteration
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
}
})
test('dropdown closes on outside click', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
// Click outside the node
await comfyPage.page.mouse.click(10, 10)
await expect(menu).not.toBeVisible()
})
test('dropdown closes on Escape key', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
})
}
)

View File

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

View File

@@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil'
import type { CurveInterpolation, CurvePoint } from './types'
import { histogramToPath } from '@/utils/histogramUtil'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',

View File

@@ -5,7 +5,8 @@ import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT
curvesToLUT,
histogramToPath
} from './curveUtils'
describe('createMonotoneInterpolator', () => {
@@ -163,3 +164,37 @@ describe('curvesToLUT', () => {
}
})
})
describe('histogramToPath', () => {
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 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$/)
})
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)
})
})

View File

@@ -149,6 +149,34 @@ export function createMonotoneInterpolator(
}
}
/**
* Convert a histogram (arbitrary number of bins) into an SVG path string.
* Applies square-root scaling and normalizes using the 99.5th percentile
* to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
const len = histogram.length
if (len === 0) return ''
const sqrtValues = new Float32Array(len)
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
const max = sorted[Math.floor((len - 1) * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const lastIdx = len - 1
const parts: string[] = ['M0,1']
for (let i = 0; i < len; i++) {
const x = lastIdx === 0 ? 0.5 : i / lastIdx
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')
return parts.join(' ')
}
export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'

View File

@@ -1,131 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
props,
global: { plugins: [i18n] }
})
}
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
})
it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
expect(
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
).toBeCloseTo(0.6, 6)
})
it('dims area outside the range in histogram mode', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
})
it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
})
it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
})
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
})
it('renders histogram path when display is histogram with data', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
})
it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
expect(
Number.parseFloat((minHandle.element as HTMLElement).style.left)
).toBeCloseTo(25, 0)
expect(
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
).toBeCloseTo(75, 0)
})
})

View File

@@ -1,290 +0,0 @@
<template>
<div>
<div
ref="trackRef"
class="relative select-none"
@pointerdown.stop="onTrackPointerDown"
@contextmenu.prevent.stop
>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
:class="
cn(
'block w-full rounded-sm bg-node-component-surface',
display === 'histogram' ? 'aspect-3/2' : 'h-8'
)
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<stop
v-for="(stop, i) in computedStops"
:key="i"
:offset="stop.offset"
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
/>
</linearGradient>
</defs>
<rect
v-if="display === 'gradient'"
data-testid="gradient-bg"
x="0"
y="0"
width="1"
height="1"
:fill="`url(#${gradientId})`"
/>
<path
v-if="display === 'histogram' && histogramPath"
data-testid="histogram-path"
:d="histogramPath"
fill="currentColor"
fill-opacity="0.3"
/>
<rect
v-if="display === 'plain'"
data-testid="range-highlight"
:x="minNorm"
y="0"
:width="Math.max(0, maxNorm - minNorm)"
height="1"
fill="white"
fill-opacity="0.15"
/>
<template v-if="display === 'histogram'">
<rect
v-if="minNorm > 0"
data-testid="range-dim-left"
x="0"
y="0"
:width="minNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
<rect
v-if="maxNorm < 1"
data-testid="range-dim-right"
:x="maxNorm"
y="0"
:width="1 - maxNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
</template>
</svg>
<template v-if="!disabled">
<div
data-testid="handle-min"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('min', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#333"
stroke="#aaa"
stroke-width="0.5"
/>
</svg>
</div>
<div
v-if="showMidpoint && modelValue.midpoint !== undefined"
data-testid="handle-midpoint"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('midpoint', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#888"
stroke="#ccc"
stroke-width="0.5"
/>
</svg>
</div>
<div
data-testid="handle-max"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('max', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="white"
stroke="#555"
stroke-width="0.5"
/>
</svg>
</div>
</template>
</div>
<div
v-if="!disabled"
class="mt-3 flex items-center justify-between"
@pointerdown.stop
>
<ScrubableNumberInput
v-model="minValue"
:display-value="formatValue(minValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-if="showMidpoint && modelValue.midpoint !== undefined"
v-model="midpointValue"
:display-value="midpointValue.toFixed(2)"
:min="midpointScale === 'gamma' ? 0.01 : 0"
:max="midpointScale === 'gamma' ? 9.99 : 1"
:step="0.01"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-model="maxValue"
:display-value="formatValue(maxValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef, useId, useTemplateRef } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { cn } from '@/utils/tailwindUtil'
import { histogramToPath } from '@/utils/histogramUtil'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import {
clamp,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
const {
display = 'plain',
gradientStops,
showMidpoint = false,
midpointScale = 'linear',
histogram,
disabled = false,
valueMin = 0,
valueMax = 1
} = defineProps<{
display?: 'plain' | 'gradient' | 'histogram'
gradientStops?: ColorStop[]
showMidpoint?: boolean
midpointScale?: 'linear' | 'gamma'
histogram?: Uint32Array | null
disabled?: boolean
valueMin?: number
valueMax?: number
}>()
const modelValue = defineModel<RangeValue>({ required: true })
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const gradientId = useId()
const { handleTrackPointerDown, startDrag } = useRangeEditor({
trackRef,
modelValue,
valueMin: toRef(() => valueMin),
valueMax: toRef(() => valueMax)
})
function onTrackPointerDown(e: PointerEvent) {
if (!disabled) handleTrackPointerDown(e)
}
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
function formatValue(v: number): string {
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
}
const minNorm = computed(() =>
normalize(modelValue.value.min, valueMin, valueMax)
)
const maxNorm = computed(() =>
normalize(modelValue.value.max, valueMin, valueMax)
)
const computedStops = computed(
() =>
gradientStops ?? [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
)
const midpointPercent = computed(() => {
const { min, max, midpoint } = modelValue.value
if (midpoint === undefined) return 0
const midAbs = min + midpoint * (max - min)
return normalize(midAbs, valueMin, valueMax) * 100
})
const minValue = computed({
get: () => modelValue.value.min,
set: (min) => {
modelValue.value = {
...modelValue.value,
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
}
}
})
const maxValue = computed({
get: () => modelValue.value.max,
set: (max) => {
modelValue.value = {
...modelValue.value,
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
}
}
})
const midpointValue = computed({
get: () => {
const pos = modelValue.value.midpoint ?? 0.5
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
},
set: (val) => {
const position =
midpointScale === 'gamma'
? clamp(gammaToPosition(val), 0, 1)
: clamp(val, 0, 1)
modelValue.value = { ...modelValue.value, midpoint: position }
}
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -1,74 +0,0 @@
<template>
<RangeEditor
:model-value="effectiveValue.value"
:display="widget?.options?.display"
:gradient-stops="widget?.options?.gradient_stops"
:show-midpoint="widget?.options?.show_midpoint"
:midpoint-scale="widget?.options?.midpoint_scale"
:histogram="histogram"
:disabled="isDisabled"
:value-min="widget?.options?.value_min"
:value-max="widget?.options?.value_max"
@update:model-value="onValueChange"
/>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RangeEditor from './RangeEditor.vue'
import { isRangeValue } from './rangeUtils'
const { widget } = defineProps<{
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}>()
const modelValue = defineModel<RangeValue>({
default: () => ({ min: 0, max: 1 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const nodeOutputStore = useNodeOutputStore()
const histogram = computed(() => {
const locatorId = widget.nodeLocatorId
if (!locatorId) return null
const output = nodeOutputStore.nodeOutputs[locatorId]
const key = `histogram_${widget.name}`
const data = (output as Record<string, unknown>)?.[key]
if (!Array.isArray(data) || data.length === 0) return null
return new Uint32Array(data)
})
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isRangeValue)
)
watch(upstreamValue, (upstream) => {
if (isDisabled.value && upstream) {
modelValue.value = upstream
}
})
const effectiveValue = computed(() =>
isDisabled.value && upstreamValue.value
? { value: upstreamValue.value }
: { value: modelValue.value }
)
function onValueChange(value: RangeValue) {
modelValue.value = value
}
</script>

View File

@@ -1,126 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
constrainRange,
denormalize,
formatMidpointLabel,
gammaToPosition,
isRangeValue,
normalize,
positionToGamma
} from './rangeUtils'
describe('normalize', () => {
it('normalizes value to 0-1', () => {
expect(normalize(128, 0, 256)).toBe(0.5)
expect(normalize(0, 0, 255)).toBe(0)
expect(normalize(255, 0, 255)).toBe(1)
})
it('returns 0 when min equals max', () => {
expect(normalize(5, 5, 5)).toBe(0)
})
})
describe('denormalize', () => {
it('converts normalized value back to range', () => {
expect(denormalize(0.5, 0, 256)).toBe(128)
expect(denormalize(0, 0, 255)).toBe(0)
expect(denormalize(1, 0, 255)).toBe(255)
})
it('round-trips with normalize', () => {
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
})
})
describe('positionToGamma', () => {
it('converts 0.5 to gamma 1.0', () => {
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
})
it('converts 0.25 to gamma 2.0', () => {
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
})
})
describe('gammaToPosition', () => {
it('converts gamma 1.0 to position 0.5', () => {
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
})
it('converts gamma 2.0 to position 0.25', () => {
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
})
it('round-trips with positionToGamma', () => {
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
}
})
})
describe('formatMidpointLabel', () => {
it('formats linear scale as decimal', () => {
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
})
it('formats gamma scale as gamma value', () => {
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
})
})
describe('constrainRange', () => {
it('passes through valid range unchanged', () => {
const result = constrainRange({ min: 0.2, max: 0.8 })
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
})
it('clamps values to default [0, 1]', () => {
const result = constrainRange({ min: -0.5, max: 1.5 })
expect(result.min).toBe(0)
expect(result.max).toBe(1)
})
it('clamps values to custom range', () => {
const result = constrainRange({ min: -10, max: 300 }, 0, 255)
expect(result.min).toBe(0)
expect(result.max).toBe(255)
})
it('enforces min <= max', () => {
const result = constrainRange({ min: 0.8, max: 0.3 })
expect(result.min).toBe(0.8)
expect(result.max).toBe(0.8)
})
it('preserves midpoint when present', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
expect(result.midpoint).toBe(0.5)
})
it('clamps midpoint to [0, 1]', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
expect(result.midpoint).toBe(1)
})
})
describe('isRangeValue', () => {
it('returns true for valid range', () => {
expect(isRangeValue({ min: 0, max: 1 })).toBe(true)
expect(isRangeValue({ min: 0, max: 1, midpoint: 0.5 })).toBe(true)
})
it('returns false for non-objects', () => {
expect(isRangeValue(null)).toBe(false)
expect(isRangeValue(42)).toBe(false)
expect(isRangeValue('foo')).toBe(false)
expect(isRangeValue([0, 1])).toBe(false)
})
it('returns false for objects missing min or max', () => {
expect(isRangeValue({ min: 0 })).toBe(false)
expect(isRangeValue({ max: 1 })).toBe(false)
expect(isRangeValue({ min: 'a', max: 1 })).toBe(false)
})
})

View File

@@ -1,58 +0,0 @@
import { clamp } from 'es-toolkit'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
export { clamp }
export function normalize(value: number, min: number, max: number): number {
return max === min ? 0 : (value - min) / (max - min)
}
export function denormalize(
normalized: number,
min: number,
max: number
): number {
return min + normalized * (max - min)
}
export function positionToGamma(position: number): number {
const clamped = clamp(position, 0.001, 0.999)
return -Math.log2(clamped)
}
export function gammaToPosition(gamma: number): number {
return Math.pow(2, -gamma)
}
export function formatMidpointLabel(
position: number,
scale: 'linear' | 'gamma'
): string {
if (scale === 'gamma') {
return positionToGamma(position).toFixed(2)
}
return position.toFixed(2)
}
export function constrainRange(
value: RangeValue,
valueMin: number = 0,
valueMax: number = 1
): RangeValue {
const min = clamp(value.min, valueMin, valueMax)
const max = clamp(Math.max(min, value.max), valueMin, valueMax)
const midpoint =
value.midpoint !== undefined ? clamp(value.midpoint, 0, 1) : undefined
return { min, max, midpoint }
}
export function isRangeValue(value: unknown): value is RangeValue {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false
const v = value as Record<string, unknown>
const hasFiniteBounds = Number.isFinite(v.min) && Number.isFinite(v.max)
const hasValidMidpoint =
v.midpoint === undefined || Number.isFinite(v.midpoint)
return hasFiniteBounds && hasValidMidpoint
}

View File

@@ -1,115 +0,0 @@
import { onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { clamp } from 'es-toolkit'
import { denormalize, normalize } from '@/components/range/rangeUtils'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
type HandleType = 'min' | 'max' | 'midpoint'
interface UseRangeEditorOptions {
trackRef: Ref<HTMLElement | null>
modelValue: Ref<RangeValue>
valueMin: Ref<number>
valueMax: Ref<number>
}
export function useRangeEditor({
trackRef,
modelValue,
valueMin,
valueMax
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(null)
let cleanupDrag: (() => void) | null = null
function pointerToValue(e: PointerEvent): number {
const el = trackRef.value
if (!el) return valueMin.value
const rect = el.getBoundingClientRect()
const normalized = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
)
return denormalize(normalized, valueMin.value, valueMax.value)
}
function nearestHandle(value: number): HandleType {
const { min, max, midpoint } = modelValue.value
const dMin = Math.abs(value - min)
const dMax = Math.abs(value - max)
let best: HandleType = dMin <= dMax ? 'min' : 'max'
const bestDist = Math.min(dMin, dMax)
if (midpoint !== undefined) {
const midAbs = min + midpoint * (max - min)
if (Math.abs(value - midAbs) < bestDist) {
best = 'midpoint'
}
}
return best
}
function updateValue(handle: HandleType, value: number) {
const current = modelValue.value
const clamped = clamp(value, valueMin.value, valueMax.value)
if (handle === 'min') {
modelValue.value = { ...current, min: Math.min(clamped, current.max) }
} else if (handle === 'max') {
modelValue.value = { ...current, max: Math.max(clamped, current.min) }
} else {
const range = current.max - current.min
const midNorm =
range > 0 ? normalize(clamped, current.min, current.max) : 0
const midpoint = Math.max(0, Math.min(1, midNorm))
modelValue.value = { ...current, midpoint }
}
}
function handleTrackPointerDown(e: PointerEvent) {
if (e.button !== 0) return
startDrag(nearestHandle(pointerToValue(e)), e)
}
function startDrag(handle: HandleType, e: PointerEvent) {
if (e.button !== 0) return
cleanupDrag?.()
activeHandle.value = handle
const el = trackRef.value
if (!el) return
el.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
if (!activeHandle.value) return
updateValue(activeHandle.value, pointerToValue(ev))
}
const endDrag = () => {
if (!activeHandle.value) return
activeHandle.value = null
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', endDrag)
el.removeEventListener('lostpointercapture', endDrag)
cleanupDrag = null
}
cleanupDrag = endDrag
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', endDrag)
el.addEventListener('lostpointercapture', endDrag)
}
onBeforeUnmount(() => {
cleanupDrag?.()
})
return {
activeHandle,
handleTrackPointerDown,
startDrag
}
}

View File

@@ -139,7 +139,6 @@ export type IWidget =
| IBoundingBoxWidget
| ICurveWidget
| IPainterWidget
| IRangeWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -342,31 +341,6 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
value: string
}
export interface RangeValue {
min: number
max: number
midpoint?: number
}
export interface IWidgetRangeOptions extends IWidgetOptions {
display?: 'plain' | 'gradient' | 'histogram'
gradient_stops?: ColorStop[]
show_midpoint?: boolean
midpoint_scale?: 'linear' | 'gamma'
value_min?: number
value_max?: number
histogram?: Uint32Array | null
}
export interface IRangeWidget extends IBaseWidget<
RangeValue,
'range',
IWidgetRangeOptions
> {
type: 'range'
value: RangeValue
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -1,16 +0,0 @@
import type { IRangeWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class RangeWidget
extends BaseWidget<IRangeWidget>
implements IRangeWidget
{
override type = 'range' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Range')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -22,7 +22,6 @@ import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
@@ -61,7 +60,6 @@ export type WidgetTypeMap = {
boundingbox: BoundingBoxWidget
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
[key: string]: BaseWidget
}
@@ -142,8 +140,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(CurveWidget, narrowedWidget, node)
case 'painter':
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "مخطط تكراري للصورة",
"inputs": {
"image": {
"name": "صورة"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "الإضاءة",
"tooltip": null
},
"2": {
"name": "أحمر",
"tooltip": null
},
"3": {
"name": "أخضر",
"tooltip": null
},
"4": {
"name": "أزرق",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "عكس الصورة",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Image Histogram",
"inputs": {
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"name": "rgb",
"tooltip": null
},
"1": {
"name": "luminance",
"tooltip": null
},
"2": {
"name": "red",
"tooltip": null
},
"3": {
"name": "green",
"tooltip": null
},
"4": {
"name": "blue",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Invert Image",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Histograma de imagen",
"inputs": {
"image": {
"name": "imagen"
}
},
"outputs": {
"0": {
"name": "rgb",
"tooltip": null
},
"1": {
"name": "luminancia",
"tooltip": null
},
"2": {
"name": "rojo",
"tooltip": null
},
"3": {
"name": "verde",
"tooltip": null
},
"4": {
"name": "azul",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Invertir Imagen",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "هیستوگرام تصویر",
"inputs": {
"image": {
"name": "تصویر"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "درخشندگی",
"tooltip": null
},
"2": {
"name": "قرمز",
"tooltip": null
},
"3": {
"name": "سبز",
"tooltip": null
},
"4": {
"name": "آبی",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "معکوس‌سازی تصویر",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Histogramme d'image",
"inputs": {
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"name": "rvb",
"tooltip": null
},
"1": {
"name": "luminance",
"tooltip": null
},
"2": {
"name": "rouge",
"tooltip": null
},
"3": {
"name": "vert",
"tooltip": null
},
"4": {
"name": "bleu",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Inverser l'image",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "画像ヒストグラム",
"inputs": {
"image": {
"name": "画像"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "輝度",
"tooltip": null
},
"2": {
"name": "赤",
"tooltip": null
},
"3": {
"name": "緑",
"tooltip": null
},
"4": {
"name": "青",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "画像を反転",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "이미지 히스토그램",
"inputs": {
"image": {
"name": "이미지"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "휘도",
"tooltip": null
},
"2": {
"name": "레드",
"tooltip": null
},
"3": {
"name": "그린",
"tooltip": null
},
"4": {
"name": "블루",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "이미지 반전",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Histograma de Imagem",
"inputs": {
"image": {
"name": "imagem"
}
},
"outputs": {
"0": {
"name": "rgb",
"tooltip": null
},
"1": {
"name": "luminância",
"tooltip": null
},
"2": {
"name": "vermelho",
"tooltip": null
},
"3": {
"name": "verde",
"tooltip": null
},
"4": {
"name": "azul",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Inverter Imagem",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Гистограмма изображения",
"inputs": {
"image": {
"name": "изображение"
}
},
"outputs": {
"0": {
"name": "rgb",
"tooltip": null
},
"1": {
"name": "яркость",
"tooltip": null
},
"2": {
"name": "красный",
"tooltip": null
},
"3": {
"name": "зелёный",
"tooltip": null
},
"4": {
"name": "синий",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Инвертировать изображение",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "Görüntü Histogramı",
"inputs": {
"image": {
"name": "görüntü"
}
},
"outputs": {
"0": {
"name": "rgb",
"tooltip": null
},
"1": {
"name": "parlaklık",
"tooltip": null
},
"2": {
"name": "kırmızı",
"tooltip": null
},
"3": {
"name": "yeşil",
"tooltip": null
},
"4": {
"name": "mavi",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "Görüntüyü Ters Çevir",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "影像直方圖",
"inputs": {
"image": {
"name": "影像"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "亮度",
"tooltip": null
},
"2": {
"name": "紅色",
"tooltip": null
},
"3": {
"name": "綠色",
"tooltip": null
},
"4": {
"name": "藍色",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "反轉影像",
"inputs": {

View File

@@ -5006,36 +5006,6 @@
}
}
},
"ImageHistogram": {
"display_name": "图像直方图",
"inputs": {
"image": {
"name": "图像"
}
},
"outputs": {
"0": {
"name": "RGB",
"tooltip": null
},
"1": {
"name": "亮度",
"tooltip": null
},
"2": {
"name": "红色",
"tooltip": null
},
"3": {
"name": "绿色",
"tooltip": null
},
"4": {
"name": "蓝色",
"tooltip": null
}
}
},
"ImageInvert": {
"display_name": "反转图像",
"inputs": {

View File

@@ -61,15 +61,6 @@ describe('ModelInfoPanel', () => {
expect(wrapper.text()).toContain('my-model.safetensors')
})
it('prefers user_metadata.filename over asset.name for filename field', () => {
const asset = createMockAsset({
name: 'registry-display-name',
user_metadata: { filename: 'checkpoints/real-file.safetensors' }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('checkpoints/real-file.safetensors')
})
it('displays name from user_metadata when present', () => {
const asset = createMockAsset({
user_metadata: { name: 'My Custom Model' }

View File

@@ -32,9 +32,7 @@
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all text-muted-foreground">{{
getAssetFilename(asset)
}}</span>
<span class="break-all text-muted-foreground">{{ asset.name }}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
@@ -234,7 +232,6 @@ import {
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,

View File

@@ -181,9 +181,7 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
// When layout store already has correct position, getBoundingClientRect
// is not needed — position is read from the store instead.
expect(rectSpy).not.toHaveBeenCalled()
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
@@ -194,13 +192,13 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).not.toHaveBeenCalled()
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
it('preserves layout store position when size matches but DOM position differs', () => {
it('updates bounds on first observation when size matches but position differs', () => {
const nodeId = 'test-node'
const width = 240
const height = 180
@@ -211,6 +209,7 @@ describe('useVueNodeResizeTracking', () => {
left: 100,
top: 200
})
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
seedNodeLayout({
nodeId,
@@ -222,10 +221,20 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
// Position from DOM should NOT override layout store position
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
{
nodeId,
bounds: {
x: 100,
y: 200 + titleHeight,
width,
height
}
}
])
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
})
it('updates node bounds + slot layouts when size changes', () => {

View File

@@ -186,26 +186,13 @@ const resizeObserver = new ResizeObserver((entries) => {
continue
}
// Use existing position from layout store (source of truth) rather than
// converting screen-space getBoundingClientRect() back to canvas coords.
// The DOM→canvas conversion depends on the current canvas scale/offset,
// which can be stale during graph transitions (e.g. entering a subgraph
// before fitView runs), producing corrupted positions.
const existingPos = nodeLayout?.position
let posX: number
let posY: number
if (existingPos) {
posX = existingPos.x
posY = existingPos.y
} else {
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
posX = cx
posY = cy + LiteGraph.NODE_TITLE_HEIGHT
}
// Screen-space rect
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
const bounds: Bounds = {
x: posX,
y: posY,
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width,
height
}

View File

@@ -41,11 +41,6 @@ const MockFormDropdownInput = {
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
}
const MockPopover = {
name: 'Popover',
template: '<div><slot /></div>'
}
interface MountDropdownOptions {
searcher?: (
query: string,
@@ -65,13 +60,17 @@ function mountDropdown(
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
async function openDropdown(wrapper: ReturnType<typeof mountDropdown>) {
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
@@ -87,7 +86,7 @@ describe('FormDropdown', () => {
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(2)
@@ -106,7 +105,7 @@ describe('FormDropdown', () => {
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await openDropdown(wrapper)
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
@@ -116,7 +115,7 @@ describe('FormDropdown', () => {
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
await openDropdown(wrapper)
expect(getMenuItems(wrapper)).toHaveLength(0)
@@ -154,7 +153,10 @@ describe('FormDropdown', () => {
await flushPromises()
expect(searcher).not.toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
})
it('runs filtering when dropdown opens', async () => {
@@ -169,8 +171,7 @@ describe('FormDropdown', () => {
)
await flushPromises()
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { computedAsync, onClickOutside, refDebounced } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
@@ -16,6 +17,7 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -51,7 +53,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -95,8 +96,10 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const dropdownRef = useTemplateRef('dropdownRef')
const shouldTeleport = inject(OverlayAppendToKey, undefined) === 'body'
const maxSelectable = computed(() => {
if (multiple === true) return Infinity
@@ -142,18 +145,59 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
const toggleDropdown = (event: Event) => {
const MENU_HEIGHT_WITH_GAP = MENU_HEIGHT + 8
const openUpward = ref(false)
const fixedPosition = ref({ top: 0, left: 0 })
const teleportStyle = computed<CSSProperties | undefined>(() => {
if (!shouldTeleport) return undefined
const pos = fixedPosition.value
return openUpward.value
? {
position: 'fixed',
left: `${pos.left}px`,
bottom: `${window.innerHeight - pos.top}px`,
paddingBottom: '0.5rem'
}
: {
position: 'fixed',
left: `${pos.left}px`,
top: `${pos.top}px`,
paddingTop: '0.5rem'
}
})
function toggleDropdown() {
if (disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
isOpen.value = !isOpen.value
if (!isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openUpward.value =
spaceBelow < MENU_HEIGHT_WITH_GAP && spaceAbove > spaceBelow
if (shouldTeleport) {
fixedPosition.value = {
top: openUpward.value
? Math.max(MENU_HEIGHT_WITH_GAP, rect.top)
: Math.min(rect.bottom, window.innerHeight - MENU_HEIGHT_WITH_GAP),
left: Math.min(rect.right, window.innerWidth - MENU_WIDTH)
}
}
}
isOpen.value = !isOpen.value
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
function closeDropdown() {
isOpen.value = false
}
onClickOutside(triggerRef, closeDropdown, { ignore: [dropdownRef] })
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown()
}
}
@@ -192,7 +236,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
</script>
<template>
<div ref="triggerRef">
<div ref="triggerRef" class="relative" @keydown="handleEscape">
<FormDropdownInput
:files
:is-open
@@ -207,42 +251,41 @@ function handleSelection(item: FormDropdownItem, index: number) {
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
<Teleport to="body" :disabled="!shouldTeleport">
<div
v-if="isOpen"
ref="dropdownRef"
:class="
cn(
'z-50 rounded-lg border-none bg-transparent p-0 shadow-lg',
!shouldTeleport && 'absolute left-0',
!shouldTeleport &&
(openUpward ? 'bottom-full pb-2' : 'top-full pt-2')
)
"
:style="teleportStyle"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
"
>
<button
data-testid="form-dropdown-trigger"
:class="
cn(
theButtonStyle,

View File

@@ -13,6 +13,7 @@ import type {
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -97,7 +98,9 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-testid="form-dropdown-menu"
class="flex flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
:style="{ height: `${MENU_HEIGHT}px`, width: `${MENU_WIDTH}px` }"
data-capture-wheel="true"
>
<FormDropdownMenuFilter

View File

@@ -28,5 +28,10 @@ export interface SortOption<TId extends string = string> {
export type LayoutMode = 'list' | 'grid' | 'list-small'
/** Height of FormDropdownMenu in pixels (matches h-[640px] in template). */
export const MENU_HEIGHT = 640
/** Width of FormDropdownMenu in pixels (matches w-103 = 26rem = 416px in template). */
export const MENU_WIDTH = 412
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')

View File

@@ -1,37 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IRangeWidget,
IWidgetRangeOptions
} from '@/lib/litegraph/src/types/widgets'
import type { RangeInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useRangeWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec): IRangeWidget => {
const spec = inputSpec as RangeInputSpec
const defaultValue = spec.default ?? { min: 0.0, max: 1.0 }
const options: IWidgetRangeOptions = {
display: spec.display,
gradient_stops: spec.gradient_stops,
show_midpoint: spec.show_midpoint,
midpoint_scale: spec.midpoint_scale,
value_min: spec.value_min,
value_max: spec.value_max
}
const rawWidget = node.addWidget(
'range',
spec.name,
{ ...defaultValue },
() => {},
options
)
if (rawWidget.type !== 'range') {
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
}
return rawWidget as IRangeWidget
}
}

View File

@@ -63,9 +63,6 @@ const WidgetCurve = defineAsyncComponent(
const WidgetPainter = defineAsyncComponent(
() => import('@/components/painter/WidgetPainter.vue')
)
const WidgetRange = defineAsyncComponent(
() => import('@/components/range/WidgetRange.vue')
)
export const FOR_TESTING = {
WidgetButton,
@@ -200,14 +197,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['PAINTER'],
essential: false
}
],
[
'range',
{
component: WidgetRange,
aliases: ['RANGE'],
essential: false
}
]
]
@@ -245,8 +234,7 @@ const EXPANDING_TYPES = [
'load3D',
'curve',
'painter',
'imagecompare',
'range'
'imagecompare'
] as const
export function shouldExpand(type: string): boolean {

View File

@@ -3,7 +3,6 @@ import { z } from 'zod'
import {
zBaseInputOptions,
zBooleanInputOptions,
zColorStop,
zComboInputOptions,
zFloatInputOptions,
zIntInputOptions,
@@ -141,25 +140,6 @@ const zCurveInputSpec = zBaseInputOptions.extend({
default: zCurveData.optional()
})
const zRangeValue = z.object({
min: z.number(),
max: z.number(),
midpoint: z.number().optional()
})
const zRangeInputSpec = zBaseInputOptions.extend({
type: z.literal('RANGE'),
name: z.string(),
isOptional: z.boolean().optional(),
default: zRangeValue.optional(),
display: z.enum(['plain', 'gradient', 'histogram']).optional(),
gradient_stops: z.array(zColorStop).optional(),
show_midpoint: z.boolean().optional(),
midpoint_scale: z.enum(['linear', 'gamma']).optional(),
value_min: z.number().optional(),
value_max: z.number().optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -181,7 +161,6 @@ const zInputSpec = z.union([
zGalleriaInputSpec,
zTextareaInputSpec,
zCurveInputSpec,
zRangeInputSpec,
zCustomInputSpec
])
@@ -227,7 +206,6 @@ export type ChartInputSpec = z.infer<typeof zChartInputSpec>
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CurveInputSpec = z.infer<typeof zCurveInputSpec>
export type RangeInputSpec = z.infer<typeof zRangeInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>

View File

@@ -56,14 +56,16 @@ export const zIntInputOptions = zNumericInputOptions.extend({
.optional()
})
export const zColorStop = z.object({
offset: z.number(),
color: z.tuple([z.number(), z.number(), z.number()])
})
export const zFloatInputOptions = zNumericInputOptions.extend({
round: z.union([z.number(), z.literal(false)]).optional(),
gradient_stops: z.array(zColorStop).optional()
gradient_stops: z
.array(
z.object({
offset: z.number(),
color: z.tuple([z.number(), z.number(), z.number()])
})
)
.optional()
})
export const zBooleanInputOptions = zBaseInputOptions.extend({

View File

@@ -20,7 +20,6 @@ import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/com
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
import { usePainterWidget } from '@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget'
import { useRangeWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRangeWidget'
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
@@ -311,7 +310,6 @@ export const ComfyWidgets = {
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
...dynamicWidgets
} as const

View File

@@ -1,197 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
async () => {
try {
await fn()
} catch (e) {
if (errorHandler) errorHandler(e)
else throw e
}
}
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
})
}))
describe('commandStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerCommand', () => {
it('registers a command by id', () => {
const store = useCommandStore()
store.registerCommand({
id: 'test.command',
function: vi.fn()
})
expect(store.isRegistered('test.command')).toBe(true)
})
it('warns on duplicate registration and overwrites with new function', async () => {
const store = useCommandStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const originalFn = vi.fn()
const replacementFn = vi.fn()
store.registerCommand({ id: 'dup', function: originalFn })
store.registerCommand({ id: 'dup', function: replacementFn })
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
warnSpy.mockRestore()
await store.getCommand('dup')?.function()
expect(replacementFn).toHaveBeenCalled()
expect(originalFn).not.toHaveBeenCalled()
})
})
describe('getCommand', () => {
it('returns the registered command', () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
const cmd = store.getCommand('get.test')
expect(cmd).toBeDefined()
expect(cmd?.label).toBe('Test')
})
it('returns undefined for unregistered command', () => {
const store = useCommandStore()
expect(store.getCommand('nonexistent')).toBeUndefined()
})
})
describe('execute', () => {
it('executes a registered command', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'exec.test', function: fn })
await store.execute('exec.test')
expect(fn).toHaveBeenCalled()
})
it('throws for unregistered command', async () => {
const store = useCommandStore()
await expect(store.execute('missing')).rejects.toThrow(
'Command missing not found'
)
})
it('passes metadata to the command function', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'meta.test', function: fn })
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
})
it('calls errorHandler on failure', async () => {
const store = useCommandStore()
const error = new Error('fail')
store.registerCommand({
id: 'err.test',
function: () => {
throw error
}
})
const handler = vi.fn()
await store.execute('err.test', { errorHandler: handler })
expect(handler).toHaveBeenCalledWith(error)
})
})
describe('isRegistered', () => {
it('returns false for unregistered command', () => {
const store = useCommandStore()
expect(store.isRegistered('nope')).toBe(false)
})
})
describe('loadExtensionCommands', () => {
it('registers commands from an extension', () => {
const store = useCommandStore()
store.loadExtensionCommands({
name: 'test-ext',
commands: [
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
]
})
expect(store.isRegistered('ext.cmd1')).toBe(true)
expect(store.isRegistered('ext.cmd2')).toBe(true)
expect(store.getCommand('ext.cmd1')?.source).toBe('test-ext')
expect(store.getCommand('ext.cmd2')?.source).toBe('test-ext')
})
it('skips extensions without commands', () => {
const store = useCommandStore()
store.loadExtensionCommands({ name: 'no-commands' })
expect(store.commands).toHaveLength(0)
})
})
describe('getCommand resolves dynamic properties', () => {
it('resolves label as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'label.fn',
function: vi.fn(),
label: () => 'Dynamic'
})
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
})
it('resolves tooltip as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'tip.fn',
function: vi.fn(),
tooltip: () => 'Dynamic tip'
})
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
})
it('uses explicit menubarLabel over label', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.explicit',
function: vi.fn(),
label: 'Label',
menubarLabel: 'Menu Label'
})
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
})
it('falls back menubarLabel to label', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.default',
function: vi.fn(),
label: 'My Label'
})
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
})
})
describe('formatKeySequence', () => {
it('returns empty string when command has no keybinding', () => {
const store = useCommandStore()
store.registerCommand({ id: 'no.kb', function: vi.fn() })
const cmd = store.getCommand('no.kb')!
expect(store.formatKeySequence(cmd)).toBe('')
})
})
})

View File

@@ -1,152 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
describe('extensionStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerExtension', () => {
it('registers an extension by name', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'test.ext' })
expect(store.isExtensionInstalled('test.ext')).toBe(true)
})
it('throws for extension without name', () => {
const store = useExtensionStore()
expect(() => store.registerExtension({ name: '' })).toThrow(
"Extensions must have a 'name' property."
)
})
it('throws for duplicate registration', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'dup' })
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
"Extension named 'dup' already registered."
)
})
it('warns when registering a disabled extension but still installs it', () => {
const store = useExtensionStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
store.loadDisabledExtensionNames(['disabled.ext'])
store.registerExtension({ name: 'disabled.ext' })
expect(warnSpy).toHaveBeenCalledWith(
'Extension disabled.ext is disabled.'
)
expect(store.isExtensionInstalled('disabled.ext')).toBe(true)
expect(store.isExtensionEnabled('disabled.ext')).toBe(false)
} finally {
warnSpy.mockRestore()
}
})
})
describe('isExtensionInstalled', () => {
it('returns false for uninstalled extension', () => {
const store = useExtensionStore()
expect(store.isExtensionInstalled('missing')).toBe(false)
})
})
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
it('all extensions are enabled by default', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'fresh' })
expect(store.isExtensionEnabled('fresh')).toBe(true)
})
it('disables extensions from provided list', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['off.ext'])
store.registerExtension({ name: 'off.ext' })
expect(store.isExtensionEnabled('off.ext')).toBe(false)
})
it('always disables hardcoded extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames([])
store.registerExtension({ name: 'pysssss.Locking' })
store.registerExtension({ name: 'regular.ext' })
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
expect(store.isExtensionEnabled('regular.ext')).toBe(true)
})
})
describe('enabledExtensions', () => {
it('filters out disabled extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ext.off'])
store.registerExtension({ name: 'ext.on' })
store.registerExtension({ name: 'ext.off' })
const enabled = store.enabledExtensions
expect(enabled).toHaveLength(1)
expect(enabled[0].name).toBe('ext.on')
})
})
describe('isExtensionReadOnly', () => {
it('returns true for always-disabled extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
})
it('returns false for normal extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
})
})
describe('inactiveDisabledExtensionNames', () => {
it('returns disabled names not currently installed', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
store.registerExtension({ name: 'installed.ext' })
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
expect(store.inactiveDisabledExtensionNames).not.toContain(
'installed.ext'
)
})
})
describe('core extensions', () => {
it('captures current extensions as core', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.a' })
store.registerExtension({ name: 'core.b' })
store.captureCoreExtensions()
expect(store.isCoreExtension('core.a')).toBe(true)
expect(store.isCoreExtension('core.b')).toBe(true)
})
it('identifies third-party extensions registered after capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.x' })
store.captureCoreExtensions()
expect(store.hasThirdPartyExtensions).toBe(false)
store.registerExtension({ name: 'third.party' })
expect(store.hasThirdPartyExtensions).toBe(true)
})
it('returns false for isCoreExtension before capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'ext.pre' })
expect(store.isCoreExtension('ext.pre')).toBe(false)
})
})
})

View File

@@ -1,76 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetStore } from '@/stores/widgetStore'
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
INT: vi.fn(),
FLOAT: vi.fn(),
STRING: vi.fn(),
BOOLEAN: vi.fn(),
COMBO: vi.fn()
}
}))
vi.mock('@/schemas/nodeDefSchema', () => ({
getInputSpecType: (spec: unknown[]) => spec[0]
}))
describe('widgetStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('widgets getter', () => {
it('includes custom widgets after registration', () => {
const store = useWidgetStore()
const customFn = vi.fn()
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
expect(store.widgets.get('CUSTOM_TYPE')).toBe(customFn)
})
it('core widgets take precedence over custom widgets with same key', () => {
const store = useWidgetStore()
const override = vi.fn()
store.registerCustomWidgets({ INT: override })
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
})
})
describe('inputIsWidget', () => {
it('returns true for known widget type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(['INT', {}] as const)).toBe(true)
})
it('returns false for unknown type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(['UNKNOWN_TYPE', {}] as const)).toBe(false)
})
it('returns true for v2 spec with known type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget({ type: 'STRING', name: 'test_input' })).toBe(
true
)
})
it('returns false for v2 spec with unknown type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget({ type: 'LATENT', name: 'test_input' })).toBe(
false
)
})
it('returns true for custom registered type', () => {
const store = useWidgetStore()
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
expect(
store.inputIsWidget({ type: 'MY_WIDGET', name: 'test_input' })
).toBe(true)
})
})
})

View File

@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest'
import { histogramToPath } from './histogramUtil'
describe('histogramToPath', () => {
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 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$/)
})
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)
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)
})
})

View File

@@ -1,27 +0,0 @@
/**
* Convert a histogram (arbitrary number of bins) into an SVG path string.
* Applies square-root scaling and normalizes using the 99.5th percentile
* to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
const len = histogram.length
if (len === 0) return ''
const sqrtValues = new Float32Array(len)
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
const max = sorted[Math.floor((len - 1) * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const lastIdx = len - 1
const parts: string[] = ['M0,1']
for (let i = 0; i < len; i++) {
const x = lastIdx === 0 ? 0.5 : i / lastIdx
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')
return parts.join(' ')
}