mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
1 Commits
fix/codera
...
range-edit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904acd0f5c |
125
src/components/range/RangeEditor.test.ts
Normal file
125
src/components/range/RangeEditor.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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(highlight.attributes('width')).toBe('0.6000000000000001')
|
||||
})
|
||||
|
||||
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"]')
|
||||
// 64/255 ≈ 25.1%, 192/255 ≈ 75.3%
|
||||
expect(minHandle.attributes('style')).toContain('left: 25.0')
|
||||
expect(maxHandle.attributes('style')).toContain('left: 75.')
|
||||
})
|
||||
})
|
||||
283
src/components/range/RangeEditor.vue
Normal file
283
src/components/range/RangeEditor.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="relative select-none"
|
||||
@pointerdown.stop="handleTrackPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<!-- Track -->
|
||||
<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>
|
||||
|
||||
<!-- Min handle -->
|
||||
<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>
|
||||
|
||||
<!-- Midpoint handle -->
|
||||
<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>
|
||||
|
||||
<!-- Max handle -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Value inputs -->
|
||||
<div 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 '@/components/curve/curveUtils'
|
||||
import { useRangeEditor } from '@/composables/useRangeEditor'
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
clamp,
|
||||
clamp01,
|
||||
gammaToPosition,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
const {
|
||||
display = 'plain',
|
||||
gradientStops,
|
||||
showMidpoint = false,
|
||||
midpointScale = 'linear',
|
||||
histogram,
|
||||
valueMin = 0,
|
||||
valueMax = 1
|
||||
} = defineProps<{
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradientStops?: ColorStop[]
|
||||
showMidpoint?: boolean
|
||||
midpointScale?: 'linear' | 'gamma'
|
||||
histogram?: Uint32Array | null
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Normalize an actual value to 0-1 for SVG positioning */
|
||||
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' ? clamp01(gammaToPosition(val)) : clamp01(val)
|
||||
modelValue.value = { ...modelValue.value, midpoint: position }
|
||||
}
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
30
src/components/range/WidgetRange.vue
Normal file
30
src/components/range/WidgetRange.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<RangeEditor
|
||||
v-model="modelValue"
|
||||
:display="widget?.options?.display"
|
||||
:gradient-stops="widget?.options?.gradient_stops"
|
||||
:show-midpoint="widget?.options?.show_midpoint"
|
||||
:midpoint-scale="widget?.options?.midpoint_scale"
|
||||
:histogram="widget?.options?.histogram"
|
||||
:value-min="widget?.options?.value_min"
|
||||
:value-max="widget?.options?.value_max"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
defineProps<{
|
||||
widget?: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({
|
||||
default: () => ({ min: 0, max: 1 })
|
||||
})
|
||||
</script>
|
||||
131
src/components/range/rangeUtils.test.ts
Normal file
131
src/components/range/rangeUtils.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clamp,
|
||||
clamp01,
|
||||
constrainRange,
|
||||
denormalize,
|
||||
formatMidpointLabel,
|
||||
gammaToPosition,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
describe('clamp', () => {
|
||||
it('clamps to arbitrary range', () => {
|
||||
expect(clamp(128, 0, 255)).toBe(128)
|
||||
expect(clamp(-10, 0, 255)).toBe(0)
|
||||
expect(clamp(300, 0, 255)).toBe(255)
|
||||
})
|
||||
})
|
||||
|
||||
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('clamp01', () => {
|
||||
it('returns value within bounds unchanged', () => {
|
||||
expect(clamp01(0.5)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps values below 0', () => {
|
||||
expect(clamp01(-0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('clamps values above 1', () => {
|
||||
expect(clamp01(1.5)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 for 0', () => {
|
||||
expect(clamp01(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 1 for 1', () => {
|
||||
expect(clamp01(1)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
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 [0, 1]', () => {
|
||||
const result = constrainRange({ min: -0.5, max: 1.5 })
|
||||
expect(result.min).toBe(0)
|
||||
expect(result.max).toBe(1)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
48
src/components/range/rangeUtils.ts
Normal file
48
src/components/range/rangeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
export function clamp01(value: number): number {
|
||||
return clamp(value, 0, 1)
|
||||
}
|
||||
|
||||
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 = Math.max(0.001, Math.min(0.999, position))
|
||||
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): RangeValue {
|
||||
const min = clamp01(value.min)
|
||||
const max = clamp01(Math.max(min, value.max))
|
||||
const midpoint =
|
||||
value.midpoint !== undefined ? clamp01(value.midpoint) : undefined
|
||||
return { min, max, midpoint }
|
||||
}
|
||||
114
src/composables/useRangeEditor.ts
Normal file
114
src/composables/useRangeEditor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { clamp, 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
|
||||
|
||||
/** Convert pointer event to actual value in [valueMin, valueMax] */
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ if (!isCloud) {
|
||||
}
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './rangeHistogram'
|
||||
import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
|
||||
37
src/extensions/core/rangeHistogram.ts
Normal file
37
src/extensions/core/rangeHistogram.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
const HISTOGRAM_KEY_PREFIX = 'range_histogram_'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.RangeHistogram',
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
const hasRangeWidget = node.widgets?.some((w) => w.type === 'range')
|
||||
if (!hasRangeWidget) return
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: Record<string, unknown>) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.type !== 'range') continue
|
||||
|
||||
const data = output[HISTOGRAM_KEY_PREFIX + widget.name]
|
||||
if (!Array.isArray(data)) continue
|
||||
|
||||
if (widget.options) {
|
||||
;(widget.options as Record<string, unknown>).histogram =
|
||||
new Uint32Array(data as number[])
|
||||
// Force reactive update: widget.options is not reactive, but
|
||||
// widget.value is (via BaseWidget._state). Re-assigning value
|
||||
// triggers processedWidgets recomputation in NodeWidgets.vue,
|
||||
// which then reads the updated options from the store proxy.
|
||||
widget.value = { ...(widget.value as RangeValue) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -139,6 +139,7 @@ export type IWidget =
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -341,6 +342,31 @@ 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[]
|
||||
|
||||
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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,6 +22,7 @@ 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'
|
||||
@@ -60,6 +61,7 @@ export type WidgetTypeMap = {
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -140,6 +142,8 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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,6 +63,9 @@ const WidgetCurve = defineAsyncComponent(
|
||||
const WidgetPainter = defineAsyncComponent(
|
||||
() => import('@/components/painter/WidgetPainter.vue')
|
||||
)
|
||||
const WidgetRange = defineAsyncComponent(
|
||||
() => import('@/components/range/WidgetRange.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['PAINTER'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'range',
|
||||
{
|
||||
component: WidgetRange,
|
||||
aliases: ['RANGE'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -233,7 +244,8 @@ const EXPANDING_TYPES = [
|
||||
'markdown',
|
||||
'load3D',
|
||||
'curve',
|
||||
'painter'
|
||||
'painter',
|
||||
'range'
|
||||
] as const
|
||||
|
||||
export function shouldExpand(type: string): boolean {
|
||||
|
||||
@@ -135,6 +135,30 @@ const zCurveInputSpec = zBaseInputOptions.extend({
|
||||
default: z.array(zCurvePoint).optional()
|
||||
})
|
||||
|
||||
const zRangeValue = z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
midpoint: z.number().optional()
|
||||
})
|
||||
|
||||
const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
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(),
|
||||
@@ -156,6 +180,7 @@ const zInputSpec = z.union([
|
||||
zGalleriaInputSpec,
|
||||
zTextareaInputSpec,
|
||||
zCurveInputSpec,
|
||||
zRangeInputSpec,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
@@ -201,6 +226,7 @@ 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>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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'
|
||||
@@ -310,6 +311,7 @@ export const ComfyWidgets = {
|
||||
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
|
||||
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
|
||||
...dynamicWidgets
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user