mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
feat: add CurveEditor component (#8860)
## Summary Prerequisite for upcoming native color correction nodes (ColorCurves). Reusable curve editor with monotone cubic Hermite interpolation, drag-to-add/move/delete control points, and SVG-based rendering. Includes CurvePoint type, LUT generation utility, and useCurveEditor composable for interaction logic. ## Screenshots (if applicable) https://github.com/user-attachments/assets/948352c7-bdf2-40f9-a8f0-35bc2b2f3202 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8860-feat-add-CurveEditor-component-and-d3-shape-dependency-3076d73d3650817f8421f98e349569d0) by [Unito](https://www.unito.io)
This commit is contained in:
140
src/composables/useCurveEditor.ts
Normal file
140
src/composables/useCurveEditor.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
modelValue: Ref<CurvePoint[]>
|
||||
}
|
||||
|
||||
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
const dragIndex = ref(-1)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
const curvePath = computed(() => {
|
||||
const points = modelValue.value
|
||||
if (points.length < 2) return ''
|
||||
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
const segments = 128
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = xMin + (xMax - xMin) * (i / segments)
|
||||
const y = 1 - interpolate(x)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
|
||||
function svgCoords(e: PointerEvent): [number, number] {
|
||||
const svg = svgRef.value
|
||||
if (!svg) return [0, 0]
|
||||
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return [0, 0]
|
||||
|
||||
const svgPt = new DOMPoint(e.clientX, e.clientY).matrixTransform(
|
||||
ctm.inverse()
|
||||
)
|
||||
return [
|
||||
Math.max(0, Math.min(1, svgPt.x)),
|
||||
Math.max(0, Math.min(1, 1 - svgPt.y))
|
||||
]
|
||||
}
|
||||
|
||||
function findNearestPoint(x: number, y: number): number {
|
||||
const threshold2 = 0.04 * 0.04
|
||||
let nearest = -1
|
||||
let minDist2 = threshold2
|
||||
for (let i = 0; i < modelValue.value.length; i++) {
|
||||
const dx = modelValue.value[i][0] - x
|
||||
const dy = modelValue.value[i][1] - y
|
||||
const dist2 = dx * dx + dy * dy
|
||||
if (dist2 < minDist2) {
|
||||
minDist2 = dist2
|
||||
nearest = i
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function handleSvgPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
|
||||
const [x, y] = svgCoords(e)
|
||||
|
||||
const nearby = findNearestPoint(x, y)
|
||||
if (nearby >= 0) {
|
||||
startDrag(nearby, e)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.ctrlKey) return
|
||||
|
||||
const newPoint: CurvePoint = [x, y]
|
||||
const newPoints: CurvePoint[] = [...modelValue.value, newPoint]
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
|
||||
startDrag(newPoints.indexOf(newPoint), e)
|
||||
}
|
||||
|
||||
function startDrag(index: number, e: PointerEvent) {
|
||||
cleanupDrag?.()
|
||||
|
||||
if (e.button === 2 || (e.button === 0 && e.ctrlKey)) {
|
||||
if (modelValue.value.length > 2) {
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints.splice(index, 1)
|
||||
modelValue.value = newPoints
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dragIndex.value = index
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
|
||||
svg.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (dragIndex.value < 0) return
|
||||
const [x, y] = svgCoords(ev)
|
||||
const movedPoint: CurvePoint = [x, y]
|
||||
const newPoints = [...modelValue.value]
|
||||
newPoints[dragIndex.value] = movedPoint
|
||||
newPoints.sort((a, b) => a[0] - b[0])
|
||||
modelValue.value = newPoints
|
||||
dragIndex.value = newPoints.indexOf(movedPoint)
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (dragIndex.value < 0) return
|
||||
dragIndex.value = -1
|
||||
svg.removeEventListener('pointermove', onMove)
|
||||
svg.removeEventListener('pointerup', endDrag)
|
||||
svg.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
svg.addEventListener('pointermove', onMove)
|
||||
svg.addEventListener('pointerup', endDrag)
|
||||
svg.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
curvePath,
|
||||
handleSvgPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user