Compare commits

...

3 Commits

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

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

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

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

## Changes

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

Fixes #1082
Fixes #2015

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9459-feat-expose-litegraph-internal-keybindings-31b6d73d3650819a8499fd96c8a6678f)
by [Unito](https://www.unito.io)
2026-03-06 18:30:35 +01:00
9 changed files with 853 additions and 241 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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