cubic-bezier-editor.ts
· 5.2 KiB · TypeScript
Ham
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;
| 1 | type ChangeListener = (x1: number, y1: number, x2: number, y2: number) => void; |
| 2 | type Point = [number, number]; |
| 3 | type Quad = [number, number, number, number]; |
| 4 | |
| 5 | const POINT_SIZE = 10; |
| 6 | |
| 7 | function toFixedNumber(num: number, digits = 2, base = 10) { |
| 8 | const pow = Math.pow(base, digits); |
| 9 | return Math.round(num * pow) / pow; |
| 10 | } |
| 11 | |
| 12 | function clamp(x: number, min: number, max: number) { |
| 13 | return Math.max(Math.min(x, max), min); |
| 14 | } |
| 15 | |
| 16 | class CubicBezierEditor { |
| 17 | #element = document.createElement('div'); |
| 18 | #output = document.createElement('div'); |
| 19 | #canvas = document.createElement('canvas'); |
| 20 | #ctx = this.#canvas.getContext('2d')!; |
| 21 | #onChangeListener: ChangeListener | undefined; |
| 22 | #grabbedPoint: 0 | 1 | null = null; |
| 23 | |
| 24 | #x1 = 0; |
| 25 | #y1 = 0; |
| 26 | #x2 = 0; |
| 27 | #y2 = 0; |
| 28 | |
| 29 | constructor(width: number, height: number, x1 = 0, y1 = 0, x2 = 0, y2 = 0) { |
| 30 | this.#x1 = x1; |
| 31 | this.#y1 = y1; |
| 32 | this.#x2 = x2; |
| 33 | this.#y2 = y2; |
| 34 | |
| 35 | this.#element.classList.add('cubic-bezier-wrapper'); |
| 36 | this.#output.classList.add('cubic-bezier-output'); |
| 37 | this.#element.appendChild(this.#canvas); |
| 38 | this.#element.appendChild(this.#output); |
| 39 | this.#canvas.width = width; |
| 40 | this.#canvas.height = height; |
| 41 | } |
| 42 | |
| 43 | mount(root: HTMLElement, onChange: ChangeListener) { |
| 44 | root.appendChild(this.#element); |
| 45 | this.#onChangeListener = onChange; |
| 46 | this.#initialize(); |
| 47 | } |
| 48 | |
| 49 | set(x1: number, y1: number, x2: number, y2: number) { |
| 50 | this.#x1 = x1; |
| 51 | this.#y1 = y1; |
| 52 | this.#x2 = x2; |
| 53 | this.#y2 = y2; |
| 54 | this.#draw(); |
| 55 | } |
| 56 | |
| 57 | get points(): Quad { |
| 58 | return [this.#x1, this.#y1, this.#x2, this.#y2]; |
| 59 | } |
| 60 | |
| 61 | get string() { |
| 62 | return `${this.#x1},${this.#y1},${this.#x2},${this.#y2}`; |
| 63 | } |
| 64 | |
| 65 | get #controlPoints() { |
| 66 | const [w, h] = [this.#canvas.width, this.#canvas.height]; |
| 67 | return [this.#x1 * w, h - this.#y1 * h, this.#x2 * w, h - this.#y2 * h]; |
| 68 | } |
| 69 | |
| 70 | #draw() { |
| 71 | const [w, h] = [this.#canvas.width, this.#canvas.height]; |
| 72 | const [x1, y1, x2, y2] = this.#controlPoints; |
| 73 | |
| 74 | this.#ctx.clearRect(0, 0, w, h); |
| 75 | |
| 76 | // Linear line |
| 77 | this.#ctx.beginPath(); |
| 78 | this.#ctx.strokeStyle = 'grey'; |
| 79 | this.#ctx.lineWidth = 2; |
| 80 | this.#ctx.moveTo(0, h); |
| 81 | this.#ctx.lineTo(w, 0); |
| 82 | this.#ctx.stroke(); |
| 83 | |
| 84 | // Control point line |
| 85 | this.#ctx.beginPath(); |
| 86 | this.#ctx.strokeStyle = 'grey'; |
| 87 | this.#ctx.moveTo(0, h); |
| 88 | this.#ctx.lineTo(x1, y1); |
| 89 | this.#ctx.stroke(); |
| 90 | |
| 91 | this.#ctx.beginPath(); |
| 92 | this.#ctx.strokeStyle = 'grey'; |
| 93 | this.#ctx.moveTo(w, 0); |
| 94 | this.#ctx.lineTo(x2, y2); |
| 95 | this.#ctx.stroke(); |
| 96 | |
| 97 | // Bezier |
| 98 | this.#ctx.beginPath(); |
| 99 | this.#ctx.lineWidth = 4; |
| 100 | this.#ctx.strokeStyle = '#fff'; |
| 101 | this.#ctx.moveTo(0, h); |
| 102 | this.#ctx.bezierCurveTo(x1, y1, x2, y2, w, 0); |
| 103 | this.#ctx.stroke(); |
| 104 | |
| 105 | // Control point fill |
| 106 | this.#ctx.beginPath(); |
| 107 | this.#ctx.fillStyle = 'red'; |
| 108 | this.#ctx.arc(x1, y1, POINT_SIZE, 0, Math.PI * 2); |
| 109 | this.#ctx.fill(); |
| 110 | |
| 111 | this.#ctx.beginPath(); |
| 112 | this.#ctx.fillStyle = 'blue'; |
| 113 | this.#ctx.arc(x2, y2, POINT_SIZE, 0, Math.PI * 2); |
| 114 | this.#ctx.fill(); |
| 115 | |
| 116 | this.#output.innerText = `cubic-bezier(${this.string})`; |
| 117 | } |
| 118 | |
| 119 | #initialize() { |
| 120 | this.#draw(); |
| 121 | |
| 122 | window.addEventListener('pointerdown', (evt) => { |
| 123 | const pos = this.#getMousePosition(evt); |
| 124 | this.#grabbedPoint = this.#intersectsControlPoint(pos); |
| 125 | }); |
| 126 | |
| 127 | window.addEventListener('pointermove', (evt) => { |
| 128 | if (this.#grabbedPoint === null) return; |
| 129 | const pos = this.#getMousePosition(evt); |
| 130 | const [xp, yp] = this.#positionToControlPoint(pos); |
| 131 | |
| 132 | if (this.#grabbedPoint === 0) { |
| 133 | this.#x1 = clamp(xp, 0, 1); |
| 134 | this.#y1 = clamp(yp, 0, 1); |
| 135 | |
| 136 | // Symmetrize when CTRL is held |
| 137 | if (evt.ctrlKey || evt.metaKey) { |
| 138 | this.#x2 = clamp(toFixedNumber(1 - xp), 0, 1); |
| 139 | this.#y2 = clamp(toFixedNumber(1 - yp), 0, 1); |
| 140 | } |
| 141 | } else { |
| 142 | this.#x2 = clamp(xp, 0, 1); |
| 143 | this.#y2 = clamp(yp, 0, 1); |
| 144 | |
| 145 | // Symmetrize when CTRL is held |
| 146 | if (evt.ctrlKey || evt.metaKey) { |
| 147 | this.#x1 = clamp(toFixedNumber(1 - xp), 0, 1); |
| 148 | this.#y1 = clamp(toFixedNumber(1 - yp), 0, 1); |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | this.#draw(); |
| 153 | this.#onChangeListener?.(...this.points); |
| 154 | }); |
| 155 | |
| 156 | window.addEventListener('pointerup', () => { |
| 157 | this.#grabbedPoint = null; |
| 158 | this.#onChangeListener?.(...this.points); |
| 159 | }); |
| 160 | |
| 161 | window.addEventListener('pointerleave', () => { |
| 162 | this.#grabbedPoint = null; |
| 163 | this.#onChangeListener?.(...this.points); |
| 164 | }); |
| 165 | |
| 166 | this.#output.addEventListener('click', () => { |
| 167 | navigator.clipboard?.writeText(`cubic-bezier(${this.string})`); |
| 168 | }); |
| 169 | } |
| 170 | |
| 171 | #intersectsControlPoint([x, y]: Point): 0 | 1 | null { |
| 172 | const [x1, y1, x2, y2] = this.#controlPoints; |
| 173 | |
| 174 | if (Math.hypot(Math.abs(x - x1), Math.abs(y - y1)) < POINT_SIZE) { |
| 175 | return 0; |
| 176 | } |
| 177 | |
| 178 | if (Math.hypot(Math.abs(x - x2), Math.abs(y - y2)) < POINT_SIZE) { |
| 179 | return 1; |
| 180 | } |
| 181 | |
| 182 | return null; |
| 183 | } |
| 184 | |
| 185 | #positionToControlPoint([x, y]: Point): Point { |
| 186 | const [w, h] = [this.#canvas.width, this.#canvas.height]; |
| 187 | return [toFixedNumber(x / w), toFixedNumber((h - y) / h)]; |
| 188 | } |
| 189 | |
| 190 | #getMousePosition(evt: PointerEvent): Point { |
| 191 | const rect = this.#canvas.getBoundingClientRect(); |
| 192 | const xpos = evt.clientX - rect.left; |
| 193 | const ypos = evt.clientY - rect.top; |
| 194 | return [xpos, ypos]; |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | export default CubicBezierEditor; |
| 199 |