type ChangeListener = (x1: number, y1: number, x2: number, y2: number) => void; type Point = [number, number]; type Quad = [number, number, number, number]; const POINT_SIZE = 10; function toFixedNumber(num: number, digits = 2, base = 10) { const pow = Math.pow(base, digits); return Math.round(num * pow) / pow; } function clamp(x: number, min: number, max: number) { return Math.max(Math.min(x, max), min); } class CubicBezierEditor { #element = document.createElement('div'); #output = document.createElement('div'); #canvas = document.createElement('canvas'); #ctx = this.#canvas.getContext('2d')!; #onChangeListener: ChangeListener | undefined; #grabbedPoint: 0 | 1 | null = null; #x1 = 0; #y1 = 0; #x2 = 0; #y2 = 0; constructor(width: number, height: number, x1 = 0, y1 = 0, x2 = 0, y2 = 0) { this.#x1 = x1; this.#y1 = y1; this.#x2 = x2; this.#y2 = y2; this.#element.classList.add('cubic-bezier-wrapper'); this.#output.classList.add('cubic-bezier-output'); this.#element.appendChild(this.#canvas); this.#element.appendChild(this.#output); this.#canvas.width = width; this.#canvas.height = height; } mount(root: HTMLElement, onChange: ChangeListener) { root.appendChild(this.#element); this.#onChangeListener = onChange; this.#initialize(); } set(x1: number, y1: number, x2: number, y2: number) { this.#x1 = x1; this.#y1 = y1; this.#x2 = x2; this.#y2 = y2; this.#draw(); } get points(): Quad { return [this.#x1, this.#y1, this.#x2, this.#y2]; } get string() { return `${this.#x1},${this.#y1},${this.#x2},${this.#y2}`; } get #controlPoints() { const [w, h] = [this.#canvas.width, this.#canvas.height]; return [this.#x1 * w, h - this.#y1 * h, this.#x2 * w, h - this.#y2 * h]; } #draw() { const [w, h] = [this.#canvas.width, this.#canvas.height]; const [x1, y1, x2, y2] = this.#controlPoints; this.#ctx.clearRect(0, 0, w, h); // Linear line this.#ctx.beginPath(); this.#ctx.strokeStyle = 'grey'; this.#ctx.lineWidth = 2; this.#ctx.moveTo(0, h); this.#ctx.lineTo(w, 0); this.#ctx.stroke(); // Control point line this.#ctx.beginPath(); this.#ctx.strokeStyle = 'grey'; this.#ctx.moveTo(0, h); this.#ctx.lineTo(x1, y1); this.#ctx.stroke(); this.#ctx.beginPath(); this.#ctx.strokeStyle = 'grey'; this.#ctx.moveTo(w, 0); this.#ctx.lineTo(x2, y2); this.#ctx.stroke(); // Bezier this.#ctx.beginPath(); this.#ctx.lineWidth = 4; this.#ctx.strokeStyle = '#fff'; this.#ctx.moveTo(0, h); this.#ctx.bezierCurveTo(x1, y1, x2, y2, w, 0); this.#ctx.stroke(); // Control point fill this.#ctx.beginPath(); this.#ctx.fillStyle = 'red'; this.#ctx.arc(x1, y1, POINT_SIZE, 0, Math.PI * 2); this.#ctx.fill(); this.#ctx.beginPath(); this.#ctx.fillStyle = 'blue'; this.#ctx.arc(x2, y2, POINT_SIZE, 0, Math.PI * 2); this.#ctx.fill(); this.#output.innerText = `cubic-bezier(${this.string})`; } #initialize() { this.#draw(); window.addEventListener('pointerdown', (evt) => { const pos = this.#getMousePosition(evt); this.#grabbedPoint = this.#intersectsControlPoint(pos); }); window.addEventListener('pointermove', (evt) => { if (this.#grabbedPoint === null) return; const pos = this.#getMousePosition(evt); const [xp, yp] = this.#positionToControlPoint(pos); if (this.#grabbedPoint === 0) { this.#x1 = clamp(xp, 0, 1); this.#y1 = clamp(yp, 0, 1); // Symmetrize when CTRL is held if (evt.ctrlKey || evt.metaKey) { this.#x2 = clamp(toFixedNumber(1 - xp), 0, 1); this.#y2 = clamp(toFixedNumber(1 - yp), 0, 1); } } else { this.#x2 = clamp(xp, 0, 1); this.#y2 = clamp(yp, 0, 1); // Symmetrize when CTRL is held if (evt.ctrlKey || evt.metaKey) { this.#x1 = clamp(toFixedNumber(1 - xp), 0, 1); this.#y1 = clamp(toFixedNumber(1 - yp), 0, 1); } } this.#draw(); this.#onChangeListener?.(...this.points); }); window.addEventListener('pointerup', () => { this.#grabbedPoint = null; this.#onChangeListener?.(...this.points); }); window.addEventListener('pointerleave', () => { this.#grabbedPoint = null; this.#onChangeListener?.(...this.points); }); this.#output.addEventListener('click', () => { navigator.clipboard?.writeText(`cubic-bezier(${this.string})`); }); } #intersectsControlPoint([x, y]: Point): 0 | 1 | null { const [x1, y1, x2, y2] = this.#controlPoints; if (Math.hypot(Math.abs(x - x1), Math.abs(y - y1)) < POINT_SIZE) { return 0; } if (Math.hypot(Math.abs(x - x2), Math.abs(y - y2)) < POINT_SIZE) { return 1; } return null; } #positionToControlPoint([x, y]: Point): Point { const [w, h] = [this.#canvas.width, this.#canvas.height]; return [toFixedNumber(x / w), toFixedNumber((h - y) / h)]; } #getMousePosition(evt: PointerEvent): Point { const rect = this.#canvas.getBoundingClientRect(); const xpos = evt.clientX - rect.left; const ypos = evt.clientY - rect.top; return [xpos, ypos]; } } export default CubicBezierEditor;