最后活跃于 3 months ago

evert's Avatar evert 修订了这个 Gist 3 months ago. 转到此修订

1 file changed, 198 insertions

cubic-bezier-editor.ts(文件已创建)

@@ -0,0 +1,198 @@
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;
上一页 下一页