mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-08 21:39:58 +00:00
Compare commits
13 Commits
range-edit
...
fix/dropdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5591a11c2f | ||
|
|
b09778e879 | ||
|
|
3e587c7758 | ||
|
|
0d21b3aee6 | ||
|
|
8ecbbfcd48 | ||
|
|
bcbeaa8c91 | ||
|
|
77ee629d05 | ||
|
|
cc8dd9a981 | ||
|
|
0bb9daeb2c | ||
|
|
be20d5f563 | ||
|
|
0720125c77 | ||
|
|
0c3045ca5a | ||
|
|
8044bf39b7 |
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,6 +61,7 @@ const theButtonStyle = computed(() =>
|
||||
"
|
||||
>
|
||||
<button
|
||||
data-testid="form-dropdown-trigger"
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(' ')
|
||||
}
|
||||
Reference in New Issue
Block a user