最後活躍 3 months ago

cubic-bezier-editor.ts 原始檔案
1type ChangeListener = (x1: number, y1: number, x2: number, y2: number) => void;
2type Point = [number, number];
3type Quad = [number, number, number, number];
4
5const POINT_SIZE = 10;
6
7function toFixedNumber(num: number, digits = 2, base = 10) {
8 const pow = Math.pow(base, digits);
9 return Math.round(num * pow) / pow;
10}
11
12function clamp(x: number, min: number, max: number) {
13 return Math.max(Math.min(x, max), min);
14}
15
16class 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
198export default CubicBezierEditor;
199