mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-07 04:50:08 +00:00
Compare commits
3 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35bd7e5b9 | ||
|
|
55b8236c8d | ||
|
|
5e17bbbf85 |
@@ -9,133 +9,686 @@ import {
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
describe('degenerate inputs', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0)).toBe(0)
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
expect(interpolate(1)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(0.5)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('handles two points as linear segment', () => {
|
||||
const interpolate = createMonotoneInterpolator([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.25)).toBeCloseTo(0.25, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(0.75)).toBeCloseTo(0.75, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('handles duplicate x values', () => {
|
||||
const interpolate = createMonotoneInterpolator([
|
||||
[0.5, 0.2],
|
||||
[0.5, 0.8]
|
||||
])
|
||||
// Should not throw or produce NaN
|
||||
const y = interpolate(0.5)
|
||||
expect(Number.isFinite(y)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
describe('pass-through and interpolation', () => {
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('passes through many control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.1, 0.05],
|
||||
[0.3, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.7, 0.8],
|
||||
[0.9, 0.95],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
for (const [x, y] of points) {
|
||||
expect(interpolate(x)).toBeCloseTo(y, 5)
|
||||
}
|
||||
})
|
||||
|
||||
it('interpolated values are between neighboring control points for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.1],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.9],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
// Test midpoints between each pair
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const midX = (points[i][0] + points[i + 1][0]) / 2
|
||||
const y = interpolate(midX)
|
||||
const minY = Math.min(points[i][1], points[i + 1][1])
|
||||
const maxY = Math.max(points[i][1], points[i + 1][1])
|
||||
expect(y).toBeGreaterThanOrEqual(minY - 1e-10)
|
||||
expect(y).toBeLessThanOrEqual(maxY + 1e-10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
describe('clamping and boundary behavior', () => {
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(0.1)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
expect(interpolate(1.5)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('clamps for negative x values', () => {
|
||||
const interpolate = createMonotoneInterpolator([
|
||||
[0, 0.5],
|
||||
[1, 1]
|
||||
])
|
||||
expect(interpolate(-0.5)).toBe(0.5)
|
||||
expect(interpolate(-100)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps for x values far beyond range', () => {
|
||||
const interpolate = createMonotoneInterpolator([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(interpolate(1000)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns correct value at exact boundary x values', () => {
|
||||
const interpolate = createMonotoneInterpolator([
|
||||
[0, 0.2],
|
||||
[1, 0.8]
|
||||
])
|
||||
expect(interpolate(0)).toBeCloseTo(0.2, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(0.8, 5)
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
describe('monotonicity', () => {
|
||||
it('produces monotone increasing output for monotone increasing input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.001) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev - 1e-10)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('produces monotone decreasing output for monotone decreasing input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 1],
|
||||
[0.25, 0.8],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.2],
|
||||
[1, 0]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = Infinity
|
||||
for (let x = 0; x <= 1; x += 0.001) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeLessThanOrEqual(prev + 1e-10)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves monotonicity with steep transitions', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.1, 0.01],
|
||||
[0.5, 0.5],
|
||||
[0.9, 0.99],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.001) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev - 1e-10)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('produces monotone output for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
describe('sorting and ordering', () => {
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev)
|
||||
prev = y
|
||||
}
|
||||
it('produces same result regardless of input order', () => {
|
||||
const sorted: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.3, 0.4],
|
||||
[0.6, 0.7],
|
||||
[1, 1]
|
||||
]
|
||||
const reversed: CurvePoint[] = [...sorted].reverse() as CurvePoint[]
|
||||
const shuffled: CurvePoint[] = [
|
||||
sorted[2],
|
||||
sorted[0],
|
||||
sorted[3],
|
||||
sorted[1]
|
||||
]
|
||||
|
||||
const f1 = createMonotoneInterpolator(sorted)
|
||||
const f2 = createMonotoneInterpolator(reversed)
|
||||
const f3 = createMonotoneInterpolator(shuffled)
|
||||
|
||||
for (let x = 0; x <= 1; x += 0.05) {
|
||||
expect(f1(x)).toBeCloseTo(f2(x), 10)
|
||||
expect(f1(x)).toBeCloseTo(f3(x), 10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
describe('non-monotone and special curves', () => {
|
||||
it('handles flat segment (constant y)', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0.5],
|
||||
[0.5, 0.5],
|
||||
[1, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
for (let x = 0; x <= 1; x += 0.1) {
|
||||
expect(interpolate(x)).toBeCloseTo(0.5, 5)
|
||||
}
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
it('handles step-like curve with flat regions', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.4, 0],
|
||||
[0.6, 1],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
// Flat regions should stay flat
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.2)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.8)).toBeCloseTo(1, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('handles non-monotone (wave-like) input without NaN', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0.5],
|
||||
[0.25, 1],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0],
|
||||
[1, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(Number.isFinite(y)).toBe(true)
|
||||
}
|
||||
// Should pass through control points
|
||||
expect(interpolate(0)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(0.25)).toBeCloseTo(1, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(0.75)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(0.5, 5)
|
||||
})
|
||||
|
||||
it('handles y values outside [0, 1]', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, -0.5],
|
||||
[0.5, 2],
|
||||
[1, -1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(-0.5, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(2, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(-1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
describe('numerical precision', () => {
|
||||
it('handles very closely spaced x values', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.0001, 0.5],
|
||||
[0.0002, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.0001)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(0.0002)).toBeCloseTo(1, 5)
|
||||
expect(Number.isFinite(interpolate(0.00015))).toBe(true)
|
||||
})
|
||||
|
||||
it('handles very large y values', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 1e6],
|
||||
[1, 0]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0.5)).toBeCloseTo(1e6, -1)
|
||||
expect(Number.isFinite(interpolate(0.25))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns finite values for all inputs across dense sampling', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.2, 0.3],
|
||||
[0.4, 0.7],
|
||||
[0.6, 0.4],
|
||||
[0.8, 0.9],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
for (let x = -0.1; x <= 1.1; x += 0.001) {
|
||||
expect(Number.isFinite(interpolate(x))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps output to [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
describe('slope limiting (Fritsch-Carlson condition)', () => {
|
||||
it('limits slopes when alpha^2 + beta^2 > 9', () => {
|
||||
// Create points where slopes would be excessively steep
|
||||
// without the Fritsch-Carlson limiting
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.1, 0.9],
|
||||
[0.2, 0.1],
|
||||
[0.3, 0.95],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
// Should still produce finite, reasonable values
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(Number.isFinite(y)).toBe(true)
|
||||
}
|
||||
// Should pass through control points
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.1)).toBeCloseTo(0.9, 5)
|
||||
expect(interpolate(0.2)).toBeCloseTo(0.1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('binary search correctness', () => {
|
||||
it('produces continuous output across segment boundaries', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.3],
|
||||
[0.5, 0.6],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
// Test continuity at segment boundaries
|
||||
const eps = 1e-8
|
||||
for (const [x] of points.slice(1, -1)) {
|
||||
const left = interpolate(x - eps)
|
||||
const at = interpolate(x)
|
||||
const right = interpolate(x + eps)
|
||||
expect(Math.abs(left - at)).toBeLessThan(1e-4)
|
||||
expect(Math.abs(right - at)).toBeLessThan(1e-4)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
describe('empty and zero inputs', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
describe('path structure', () => {
|
||||
it('returns a closed SVG path starting at M0,1 and ending at L1,1 Z', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('generates exactly 258 segments (M + 256 L + L1,1 Z)', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
histogram.fill(50)
|
||||
const path = histogramToPath(histogram)
|
||||
const parts = path.split(' ')
|
||||
// M0,1 + 256 L segments + "L1,1" + "Z"
|
||||
expect(parts.length).toBe(259) // M0,1, L0,y, L..., L1,y, L1,1, Z
|
||||
})
|
||||
|
||||
it('x values span from 0 to 1 in 256 steps', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
histogram.fill(100)
|
||||
const path = histogramToPath(histogram)
|
||||
const segments = path.split(' ').filter((s) => s.startsWith('L'))
|
||||
// Remove the closing L1,1
|
||||
const dataSegments = segments.slice(0, -1)
|
||||
expect(dataSegments.length).toBe(256)
|
||||
|
||||
const firstX = parseFloat(dataSegments[0].substring(1).split(',')[0])
|
||||
const lastX = parseFloat(
|
||||
dataSegments[dataSegments.length - 1].substring(1).split(',')[0]
|
||||
)
|
||||
expect(firstX).toBeCloseTo(0, 5)
|
||||
expect(lastX).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
describe('normalization', () => {
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
|
||||
it('clamps values exceeding the percentile max to y=0', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
histogram.fill(50)
|
||||
// Set last two bins to extreme values
|
||||
histogram[254] = 500
|
||||
histogram[255] = 10000
|
||||
const path = histogramToPath(histogram)
|
||||
// The outlier bins should have their y values clamped to 0
|
||||
// (1 - min(1, val/max) where val > max -> y = 0)
|
||||
const segments = path.split(' ').filter((s) => s.startsWith('L'))
|
||||
const lastDataSegment = segments[segments.length - 2] // second to last L (before L1,1)
|
||||
const y = parseFloat(lastDataSegment.split(',')[1])
|
||||
expect(y).toBe(0)
|
||||
})
|
||||
|
||||
it('uniform histogram produces uniform y values', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
histogram.fill(200)
|
||||
const path = histogramToPath(histogram)
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
// Skip first (M0,1) and last (L1,1 Z) entries
|
||||
const dataYValues = yValues.slice(1, -1)
|
||||
// All should be near 0 (1 - 200/200 = 0)
|
||||
for (const y of dataYValues) {
|
||||
expect(y).toBeCloseTo(0, 5)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
describe('single-bin histograms', () => {
|
||||
it('handles histogram with only one non-zero bin', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
histogram[128] = 1000
|
||||
const path = histogramToPath(histogram)
|
||||
// The 99.5th percentile of a mostly-zero array may be 0,
|
||||
// but since max is sorted[floor(255*0.995)] = sorted[253],
|
||||
// and we have 255 zeros and 1 non-zero, sorted[253] = 0
|
||||
// So max=0, function returns ''
|
||||
expect(path).toBe('')
|
||||
})
|
||||
|
||||
it('handles histogram with a few non-zero bins', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 10; i++) histogram[i] = 100
|
||||
const path = histogramToPath(histogram)
|
||||
// sorted[253] = 100 (non-zero bins sort to end), so path is generated
|
||||
// The non-zero bins should produce y < 1, zero bins produce y = 1
|
||||
expect(path).not.toBe('')
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
})
|
||||
|
||||
it('returns path when enough bins are non-zero', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
// Fill all bins to ensure percentile > 0
|
||||
histogram.fill(1)
|
||||
histogram[0] = 100
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).not.toBe('')
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('y value range', () => {
|
||||
it('all y values are in [0, 1]', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(Math.random() * 1000) + 1
|
||||
const path = histogramToPath(histogram)
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
for (const y of yValues) {
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
expect(y).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
describe('basic properties', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
})
|
||||
|
||||
it('all values are in [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 0.2]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity and constant curves', () => {
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('produces constant LUT for flat curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0.5],
|
||||
[1, 0.5]
|
||||
])
|
||||
const expected = Math.round(0.5 * 255) // 128
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('produces all-zero LUT for y=0 curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 0]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('produces all-255 LUT for y=1 curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 1],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBe(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('clamping', () => {
|
||||
it('clamps output when interpolation exceeds 1', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps output when interpolation goes below 0', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, -0.5],
|
||||
[1, 0]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpoint values', () => {
|
||||
it('first LUT entry matches curve y at x=0', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0.3],
|
||||
[1, 0.8]
|
||||
])
|
||||
expect(lut[0]).toBe(Math.round(0.3 * 255))
|
||||
})
|
||||
|
||||
it('last LUT entry matches curve y at x=1', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0.3],
|
||||
[1, 0.8]
|
||||
])
|
||||
expect(lut[255]).toBe(Math.round(0.8 * 255))
|
||||
})
|
||||
})
|
||||
|
||||
describe('monotonicity in LUT', () => {
|
||||
it('produces monotone increasing LUT for monotone increasing curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 1; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
it('produces monotone decreasing LUT for inverted curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 1],
|
||||
[0.5, 0.5],
|
||||
[1, 0]
|
||||
])
|
||||
for (let i = 1; i < 256; i++) {
|
||||
expect(lut[i]).toBeLessThanOrEqual(lut[i - 1])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty points (all zeros)', () => {
|
||||
const lut = curvesToLUT([])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles single point', () => {
|
||||
const lut = curvesToLUT([[0.5, 0.7]])
|
||||
const expected = Math.round(0.7 * 255) // 179
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('values are rounded to nearest integer', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0.5],
|
||||
[1, 0.5]
|
||||
])
|
||||
// 0.5 * 255 = 127.5 -> rounds to 128
|
||||
expect(lut[0]).toBe(128)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -905,6 +905,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
app.canvas.pasteFromClipboard()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
label: () => t('Paste with Connect'),
|
||||
function: () => {
|
||||
app.canvas.pasteFromClipboard({ connectInputs: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectAll',
|
||||
icon: 'icon-[lucide--lasso-select]',
|
||||
@@ -919,6 +927,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Delete Selected Items',
|
||||
versionAdded: '1.10.5',
|
||||
function: () => {
|
||||
if (app.canvas.selectedItems.size === 0) {
|
||||
app.canvas.canvas.dispatchEvent(
|
||||
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
|
||||
)
|
||||
return
|
||||
}
|
||||
app.canvas.deleteSelected()
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
@@ -189,11 +189,10 @@ export function useWorkflowActionsMenu(
|
||||
|
||||
addItem({
|
||||
id: 'share',
|
||||
label: t('menuLabels.Share'),
|
||||
label: t('breadcrumbsMenu.share'),
|
||||
icon: 'icon-[comfy--send]',
|
||||
command: async () => {},
|
||||
disabled: true,
|
||||
visible: isRoot
|
||||
visible: false
|
||||
})
|
||||
|
||||
addItem({
|
||||
|
||||
@@ -3791,13 +3791,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
private _noItemsSelected(): void {
|
||||
const event = new CustomEvent('litegraph:no-items-selected', {
|
||||
bubbles: true
|
||||
})
|
||||
this.canvas.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* process a key event
|
||||
*/
|
||||
@@ -3842,31 +3835,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_panel?.close()
|
||||
this.options_panel?.close()
|
||||
if (this.node_panel || this.options_panel) block_default = true
|
||||
} else if (e.keyCode === 65 && e.ctrlKey) {
|
||||
// select all Control A
|
||||
this.selectItems()
|
||||
block_default = true
|
||||
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||||
// copy
|
||||
if (this.selected_nodes) {
|
||||
this.copyToClipboard()
|
||||
block_default = true
|
||||
}
|
||||
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
|
||||
// paste
|
||||
this.pasteFromClipboard({ connectInputs: e.shiftKey })
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// delete or backspace
|
||||
// @ts-expect-error EventTarget.localName is not in standard types
|
||||
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
|
||||
if (this.selectedItems.size === 0) {
|
||||
this._noItemsSelected()
|
||||
return
|
||||
}
|
||||
|
||||
this.deleteSelected()
|
||||
block_default = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
|
||||
@@ -1262,6 +1262,7 @@
|
||||
"Move Selected Nodes Right": "Move Selected Nodes Right",
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Paste": "Paste",
|
||||
"Paste with Connect": "Paste with Connect",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Select All": "Select All",
|
||||
@@ -2603,7 +2604,8 @@
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
"enterNewName": "Enter new name",
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Shortcuts",
|
||||
|
||||
@@ -208,5 +208,52 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'Escape'
|
||||
},
|
||||
commandId: 'Comfy.Graph.ExitSubgraph'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'a'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'c'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.CopySelected',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.PasteFromClipboard',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Delete'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Backspace'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
return {
|
||||
app: {
|
||||
canvas: {
|
||||
processKey: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
@@ -36,13 +25,15 @@ function createTestKeyboardEvent(
|
||||
ctrlKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
shiftKey?: boolean
|
||||
} = {}
|
||||
): KeyboardEvent {
|
||||
const {
|
||||
target = document.body,
|
||||
ctrlKey = false,
|
||||
altKey = false,
|
||||
metaKey = false
|
||||
metaKey = false,
|
||||
shiftKey = false
|
||||
} = options
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
@@ -50,6 +41,7 @@ function createTestKeyboardEvent(
|
||||
ctrlKey,
|
||||
altKey,
|
||||
metaKey,
|
||||
shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
@@ -60,8 +52,10 @@ function createTestKeyboardEvent(
|
||||
return event
|
||||
}
|
||||
|
||||
describe('keybindingService - Event Forwarding', () => {
|
||||
describe('keybindingService - Canvas Keybindings', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let canvasContainer: HTMLDivElement
|
||||
let canvasChild: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -76,94 +70,156 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
canvasContainer = document.createElement('div')
|
||||
canvasContainer.id = 'graph-canvas-container'
|
||||
canvasChild = document.createElement('canvas')
|
||||
canvasContainer.appendChild(canvasChild)
|
||||
document.body.appendChild(canvasContainer)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('should forward Delete key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
afterEach(() => {
|
||||
canvasContainer.remove()
|
||||
})
|
||||
|
||||
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.DeleteSelectedItems'
|
||||
)
|
||||
})
|
||||
|
||||
it('should forward Backspace key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Backspace')
|
||||
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('Backspace', {
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.DeleteSelectedItems'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in input field', async () => {
|
||||
it('should not execute DeleteSelectedItems when typing in input field', async () => {
|
||||
const inputElement = document.createElement('input')
|
||||
const event = createTestKeyboardEvent('Delete', { target: inputElement })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in textarea', async () => {
|
||||
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
|
||||
const textareaElement = document.createElement('textarea')
|
||||
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: textareaElement
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas processKey is not available', async () => {
|
||||
// Temporarily replace processKey with undefined - testing edge case
|
||||
const originalProcessKey = vi.mocked(app.canvas).processKey
|
||||
vi.mocked(app.canvas).processKey = undefined!
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
try {
|
||||
await keybindingService.keybindHandler(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
// Restore processKey for other tests
|
||||
vi.mocked(app.canvas).processKey = originalProcessKey
|
||||
}
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas is not available', async () => {
|
||||
const originalCanvas = vi.mocked(app).canvas
|
||||
vi.mocked(app).canvas = null!
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
try {
|
||||
await keybindingService.keybindHandler(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
// Restore canvas for other tests
|
||||
vi.mocked(app).canvas = originalCanvas
|
||||
}
|
||||
})
|
||||
|
||||
it('should not forward non-canvas keys', async () => {
|
||||
const event = createTestKeyboardEvent('Enter')
|
||||
it('should execute SelectAll for Ctrl+A on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.SelectAll'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not forward when modifier keys are pressed', async () => {
|
||||
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
|
||||
it('should execute CopySelected for Ctrl+C on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('c', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.CopySelected'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboard'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboardWithConnect'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
|
||||
const event = createTestKeyboardEvent('=', {
|
||||
altKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.ZoomIn'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
|
||||
const outsideDiv = document.createElement('div')
|
||||
document.body.appendChild(outsideDiv)
|
||||
|
||||
const event = createTestKeyboardEvent('=', {
|
||||
altKey: true,
|
||||
target: outsideDiv
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
outsideDiv.remove()
|
||||
})
|
||||
|
||||
it('should not execute canvas commands when target is outside canvas container', async () => {
|
||||
const outsideDiv = document.createElement('div')
|
||||
document.body.appendChild(outsideDiv)
|
||||
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: outsideDiv
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
outsideDiv.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -15,16 +14,6 @@ export function useKeybindingService() {
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const canvasKeys = ['Delete', 'Backspace']
|
||||
|
||||
return canvasKeys.includes(event.key)
|
||||
}
|
||||
|
||||
async function keybindHandler(event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
if (keyCombo.isModifier) {
|
||||
@@ -44,7 +33,17 @@ export function useKeybindingService() {
|
||||
}
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
if (keybinding) {
|
||||
const targetElementId =
|
||||
keybinding.targetElementId === 'graph-canvas'
|
||||
? 'graph-canvas-container'
|
||||
: keybinding.targetElementId
|
||||
if (targetElementId) {
|
||||
const container = document.getElementById(targetElementId)
|
||||
if (!container?.contains(target)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.ctrlKey &&
|
||||
@@ -74,18 +73,6 @@ export function useKeybindingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!keybinding && shouldForwardToCanvas(event)) {
|
||||
const canvas = app.canvas
|
||||
if (
|
||||
canvas &&
|
||||
canvas.processKey &&
|
||||
typeof canvas.processKey === 'function'
|
||||
) {
|
||||
canvas.processKey(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -676,20 +676,6 @@ export class ComfyApp {
|
||||
e.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+C Copy
|
||||
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+V Paste
|
||||
if (
|
||||
(e.key === 'v' || e.key == 'V') &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to Litegraph defaults
|
||||
|
||||
Reference in New Issue
Block a user