@rogieking/figui3 6.4.1 → 6.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.js ADDED
@@ -0,0 +1,1940 @@
1
+ /**
2
+ * FigUI3 Lab — Experimental components
3
+ *
4
+ * These components are unstable and may change or be removed without notice.
5
+ * Import alongside fig.js for opt-in access:
6
+ *
7
+ * <script src="fig.js"></script>
8
+ * <script src="fig-lab.js"></script>
9
+ */
10
+
11
+ /* Field + Slider wrapper */
12
+ class FigFieldSlider extends HTMLElement {
13
+ #field = null;
14
+ #label = null;
15
+ #slider = null;
16
+ #observer = null;
17
+ #managedSliderAttrs = new Set();
18
+ #steppersSyncFrame = 0;
19
+ #boundHandleSliderInput = null;
20
+ #boundHandleSliderChange = null;
21
+ #ignoredSliderAttrs = new Set(["variant", "color", "text", "full"]);
22
+
23
+ static get observedAttributes() {
24
+ return ["label", "direction"];
25
+ }
26
+
27
+ connectedCallback() {
28
+ if (!this.#field) {
29
+ this.#initialize();
30
+ }
31
+
32
+ this.#syncField();
33
+ this.#syncSliderAttributes();
34
+ this.#bindSliderEvents();
35
+
36
+ if (!this.#observer) {
37
+ this.#observer = new MutationObserver((mutations) => {
38
+ let syncField = false;
39
+ let syncSlider = false;
40
+
41
+ for (const mutation of mutations) {
42
+ if (mutation.type === "attributes") {
43
+ if (
44
+ mutation.attributeName &&
45
+ this.#ignoredSliderAttrs.has(mutation.attributeName)
46
+ ) {
47
+ continue;
48
+ }
49
+ if (
50
+ mutation.attributeName === "label" ||
51
+ mutation.attributeName === "direction"
52
+ ) {
53
+ syncField = true;
54
+ } else {
55
+ syncSlider = true;
56
+ }
57
+ }
58
+ }
59
+
60
+ if (syncField) this.#syncField();
61
+ if (syncSlider) this.#syncSliderAttributes();
62
+ });
63
+ }
64
+
65
+ this.#observer.observe(this, { attributes: true });
66
+ }
67
+
68
+ disconnectedCallback() {
69
+ this.#observer?.disconnect();
70
+ if (this.#steppersSyncFrame) {
71
+ cancelAnimationFrame(this.#steppersSyncFrame);
72
+ this.#steppersSyncFrame = 0;
73
+ }
74
+ this.#unbindSliderEvents();
75
+ }
76
+
77
+ attributeChangedCallback(name, oldValue, newValue) {
78
+ if (oldValue === newValue || !this.#field) return;
79
+ if (name === "label" || name === "direction") {
80
+ this.#syncField();
81
+ }
82
+ }
83
+
84
+ #initialize() {
85
+ const initialChildren = Array.from(this.childNodes).filter((node) => {
86
+ return (
87
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim())
88
+ );
89
+ });
90
+
91
+ const field = document.createElement("fig-field");
92
+ const label = document.createElement("label");
93
+ const slider = document.createElement("fig-slider");
94
+ slider.setAttribute("text", "true");
95
+ for (const attrName of this.#getForwardedSliderAttrNames()) {
96
+ const value = this.getAttribute(attrName);
97
+ slider.setAttribute(attrName, value ?? "");
98
+ }
99
+
100
+ field.append(label, slider);
101
+
102
+ this.#field = field;
103
+ this.#label = label;
104
+ this.#slider = slider;
105
+
106
+ this.replaceChildren(field);
107
+
108
+ for (const node of initialChildren) {
109
+ this.#slider.appendChild(node);
110
+ }
111
+ }
112
+
113
+ #syncField() {
114
+ if (!this.#field || !this.#label) return;
115
+ const hasLabelAttr = this.hasAttribute("label");
116
+ const rawLabel = this.getAttribute("label");
117
+ const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
118
+
119
+ if (isBlankLabel) {
120
+ if (this.#label.parentElement === this.#field) {
121
+ this.#label.remove();
122
+ }
123
+ } else {
124
+ this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
125
+ if (this.#label.parentElement !== this.#field) {
126
+ this.#field.prepend(this.#label);
127
+ }
128
+ }
129
+
130
+ this.#field.setAttribute(
131
+ "direction",
132
+ this.getAttribute("direction") || "horizontal",
133
+ );
134
+ }
135
+
136
+ #syncSliderAttributes() {
137
+ if (!this.#slider) return;
138
+ const hostAttrs = this.#getForwardedSliderAttrNames();
139
+
140
+ const nextManaged = new Set(hostAttrs.filter((name) => name !== "text"));
141
+
142
+ for (const attrName of this.#managedSliderAttrs) {
143
+ if (!nextManaged.has(attrName)) {
144
+ this.#slider.removeAttribute(attrName);
145
+ }
146
+ }
147
+
148
+ for (const attrName of hostAttrs) {
149
+ if (attrName === "text") continue;
150
+ const value = this.getAttribute(attrName);
151
+ this.#slider.setAttribute(attrName, value ?? "");
152
+ }
153
+
154
+ this.#slider.removeAttribute("variant");
155
+ this.#slider.removeAttribute("color");
156
+ this.#slider.removeAttribute("transform");
157
+ this.#slider.removeAttribute("full");
158
+ this.#slider.setAttribute("text", "true");
159
+
160
+ const sliderType = (this.getAttribute("type") || "range").toLowerCase();
161
+ if (sliderType === "delta" || sliderType === "stepper") {
162
+ this.#slider.setAttribute(
163
+ "default",
164
+ this.getAttribute("default") ?? "50",
165
+ );
166
+ } else if (!this.hasAttribute("default")) {
167
+ this.#slider.removeAttribute("default");
168
+ }
169
+ if (sliderType === "stepper") {
170
+ this.#slider.setAttribute("step", this.getAttribute("step") ?? "10");
171
+ } else if (!this.hasAttribute("step")) {
172
+ this.#slider.removeAttribute("step");
173
+ }
174
+ if (sliderType === "opacity") {
175
+ this.#slider.style.setProperty(
176
+ "--color",
177
+ "var(--figma-color-bg-tertiary)",
178
+ );
179
+ } else {
180
+ this.#slider.style.removeProperty("--color");
181
+ }
182
+
183
+ this.#managedSliderAttrs = nextManaged;
184
+ this.#queueSteppersSync();
185
+ }
186
+
187
+ #getForwardedSliderAttrNames() {
188
+ const reserved = new Set([
189
+ "label",
190
+ "direction",
191
+ "oninput",
192
+ "onchange",
193
+ "steppers",
194
+ ]);
195
+ return this.getAttributeNames().filter(
196
+ (name) => !reserved.has(name) && !this.#ignoredSliderAttrs.has(name),
197
+ );
198
+ }
199
+
200
+ #queueSteppersSync() {
201
+ if (this.#steppersSyncFrame) {
202
+ cancelAnimationFrame(this.#steppersSyncFrame);
203
+ }
204
+ this.#steppersSyncFrame = requestAnimationFrame(() => {
205
+ this.#steppersSyncFrame = 0;
206
+ this.#syncSteppersToNumberInput();
207
+ });
208
+ }
209
+
210
+ #syncSteppersToNumberInput() {
211
+ if (!this.#slider) return;
212
+ const numberInput = this.#slider.querySelector("fig-input-number");
213
+ if (!numberInput) return;
214
+
215
+ const hasSteppers =
216
+ this.hasAttribute("steppers") &&
217
+ this.getAttribute("steppers") !== "false";
218
+ if (!hasSteppers) {
219
+ numberInput.removeAttribute("steppers");
220
+ return;
221
+ }
222
+
223
+ const steppersValue = this.getAttribute("steppers");
224
+ numberInput.setAttribute("steppers", steppersValue ?? "");
225
+ }
226
+
227
+ #bindSliderEvents() {
228
+ if (!this.#slider) return;
229
+ if (!this.#boundHandleSliderInput) {
230
+ this.#boundHandleSliderInput = this.#forwardSliderEvent.bind(
231
+ this,
232
+ "input",
233
+ );
234
+ }
235
+ if (!this.#boundHandleSliderChange) {
236
+ this.#boundHandleSliderChange = this.#forwardSliderEvent.bind(
237
+ this,
238
+ "change",
239
+ );
240
+ }
241
+ this.#slider.addEventListener("input", this.#boundHandleSliderInput);
242
+ this.#slider.addEventListener("change", this.#boundHandleSliderChange);
243
+ }
244
+
245
+ #unbindSliderEvents() {
246
+ if (!this.#slider) return;
247
+ if (this.#boundHandleSliderInput) {
248
+ this.#slider.removeEventListener("input", this.#boundHandleSliderInput);
249
+ }
250
+ if (this.#boundHandleSliderChange) {
251
+ this.#slider.removeEventListener("change", this.#boundHandleSliderChange);
252
+ }
253
+ }
254
+
255
+ #forwardSliderEvent(type, event) {
256
+ event.stopPropagation();
257
+ const detail =
258
+ event instanceof CustomEvent && event.detail !== undefined
259
+ ? event.detail
260
+ : this.#slider?.value;
261
+ this.dispatchEvent(
262
+ new CustomEvent(type, {
263
+ detail,
264
+ bubbles: true,
265
+ cancelable: true,
266
+ composed: true,
267
+ }),
268
+ );
269
+ }
270
+ }
271
+ customElements.define("fig-field-slider", FigFieldSlider);
272
+
273
+ /* Canvas Control */
274
+ class FigCanvasControl extends HTMLElement {
275
+ static observedAttributes = [
276
+ "type",
277
+ "value",
278
+ "color",
279
+ "name",
280
+ "tooltips",
281
+ "disabled",
282
+ "drag-surface",
283
+ "snapping",
284
+ ];
285
+
286
+ #x = 50;
287
+ #y = 50;
288
+ #x2 = 75;
289
+ #y2 = 75;
290
+ #radius = 0;
291
+ #radiusIsPercent = false;
292
+ #angle = 0;
293
+ #pointHandle = null;
294
+ #secondHandle = null;
295
+ #angleHandle = null;
296
+ #radiusSvg = null;
297
+ #angleSvg = null;
298
+ #pointTooltip = null;
299
+ #secondTooltip = null;
300
+ #radiusTooltip = null;
301
+ #angleTooltip = null;
302
+ #isDragging = false;
303
+ #isSecondDragging = false;
304
+ #isRadiusDragging = false;
305
+ #isAngleDragging = false;
306
+ #prevBodyCursor = "";
307
+
308
+ get #type() {
309
+ return this.getAttribute("type") || "point";
310
+ }
311
+
312
+ get #hasRadius() {
313
+ return this.#type === "point-radius" || this.#type === "point-radius-angle";
314
+ }
315
+
316
+ get #hasAngle() {
317
+ return this.#type === "point-radius-angle";
318
+ }
319
+
320
+ get #hasSecondPoint() {
321
+ return this.#type === "point-point";
322
+ }
323
+
324
+ get #hasLine() {
325
+ return this.#type === "point-radius-angle" || this.#type === "point-point";
326
+ }
327
+
328
+ get #tooltipsEnabled() {
329
+ const v = this.getAttribute("tooltips");
330
+ return v === null || v !== "false";
331
+ }
332
+
333
+ get #snappingMode() {
334
+ const raw = this.getAttribute("snapping");
335
+ if (raw === null) return "false";
336
+ const n = raw.trim().toLowerCase();
337
+ if (n === "modifier") return "modifier";
338
+ if (n === "" || n === "true") return "true";
339
+ return "false";
340
+ }
341
+
342
+ #shouldSnap(shiftKey) {
343
+ const mode = this.#snappingMode;
344
+ if (mode === "true") return true;
345
+ if (mode === "modifier") return !!shiftKey;
346
+ return false;
347
+ }
348
+
349
+ get #pointTipText() {
350
+ const name = this.getAttribute("name");
351
+ if (name) {
352
+ const parts = name.split(",");
353
+ return parts[0].trim();
354
+ }
355
+ return `${Math.round(this.#x)}%, ${Math.round(this.#y)}%`;
356
+ }
357
+
358
+ get #secondTipText() {
359
+ const name = this.getAttribute("name");
360
+ if (name) {
361
+ const parts = name.split(",");
362
+ if (parts.length > 1) return parts[1].trim();
363
+ }
364
+ return `${Math.round(this.#x2)}%, ${Math.round(this.#y2)}%`;
365
+ }
366
+
367
+ get #dragSurface() {
368
+ return this.getAttribute("drag-surface") || "parent";
369
+ }
370
+
371
+ get #container() {
372
+ const surface = this.#dragSurface;
373
+ if (surface === "parent") return this.parentElement;
374
+ return this.closest(surface);
375
+ }
376
+
377
+ get #handleDragSurface() {
378
+ const surface = this.#dragSurface;
379
+ if (surface === "parent") {
380
+ const container = this.parentElement;
381
+ if (container) {
382
+ container.setAttribute("data-fig-canvas-control-surface", "");
383
+ return "[data-fig-canvas-control-surface]";
384
+ }
385
+ }
386
+ return surface;
387
+ }
388
+
389
+ #resolveRadius(containerWidth) {
390
+ if (this.#radiusIsPercent) return (this.#radius / 100) * containerWidth;
391
+ return this.#radius;
392
+ }
393
+
394
+ #formatRadius() {
395
+ if (this.#radiusIsPercent) return `Radius ${Math.round(this.#radius)}%`;
396
+ return `Radius ${Math.round(this.#radius)}`;
397
+ }
398
+
399
+ connectedCallback() {
400
+ this.#parseValue();
401
+ this.#render();
402
+ }
403
+
404
+ disconnectedCallback() {
405
+ this.#teardownRadiusDrag();
406
+ }
407
+
408
+ attributeChangedCallback(name, oldVal, newVal) {
409
+ if (oldVal === newVal) return;
410
+ if (
411
+ name === "value" &&
412
+ !this.#isDragging &&
413
+ !this.#isSecondDragging &&
414
+ !this.#isRadiusDragging &&
415
+ !this.#isAngleDragging
416
+ ) {
417
+ this.#parseValue();
418
+ if (this.#pointHandle) this.#syncPositions();
419
+ else this.#render();
420
+ }
421
+ if (name === "type") {
422
+ this.#parseValue();
423
+ this.#render();
424
+ }
425
+ if (name === "color" && this.#pointHandle) {
426
+ if (newVal) this.#pointHandle.setAttribute("color", newVal);
427
+ else this.#pointHandle.removeAttribute("color");
428
+ }
429
+ if (name === "disabled") {
430
+ this.#render();
431
+ }
432
+ if (name === "tooltips") {
433
+ this.#render();
434
+ }
435
+ if (name === "snapping" && this.#pointHandle) {
436
+ this.#pointHandle.setAttribute("drag-snapping", newVal || "false");
437
+ if (this.#secondHandle)
438
+ this.#secondHandle.setAttribute("drag-snapping", newVal || "false");
439
+ }
440
+ if (name === "name") {
441
+ if (this.#pointTooltip)
442
+ this.#pointTooltip.setAttribute("text", this.#pointTipText);
443
+ if (this.#secondTooltip)
444
+ this.#secondTooltip.setAttribute("text", this.#secondTipText);
445
+ }
446
+ }
447
+
448
+ #parseValue() {
449
+ const raw = this.getAttribute("value");
450
+ if (!raw) return;
451
+ try {
452
+ const v = JSON.parse(raw);
453
+ if (typeof v.x === "number") this.#x = v.x;
454
+ if (typeof v.y === "number") this.#y = v.y;
455
+ if (v.radius !== undefined) {
456
+ const rs = String(v.radius);
457
+ if (rs.endsWith("%")) {
458
+ this.#radiusIsPercent = true;
459
+ this.#radius = parseFloat(rs);
460
+ } else {
461
+ this.#radiusIsPercent = false;
462
+ this.#radius = parseFloat(rs);
463
+ }
464
+ if (!Number.isFinite(this.#radius)) this.#radius = 0;
465
+ }
466
+ if (typeof v.angle === "number") this.#angle = v.angle;
467
+ if (typeof v.x2 === "number") this.#x2 = v.x2;
468
+ if (typeof v.y2 === "number") this.#y2 = v.y2;
469
+ } catch {
470
+ /* ignore */
471
+ }
472
+ }
473
+
474
+ get value() {
475
+ const v = { x: this.#x, y: this.#y };
476
+ if (this.#type === "color") {
477
+ const color =
478
+ this.getAttribute("color") || this.#pointHandle?.getAttribute("color");
479
+ if (color) v.color = color;
480
+ }
481
+ if (this.#hasRadius) {
482
+ v.radius = this.#radiusIsPercent ? `${this.#radius}%` : this.#radius;
483
+ }
484
+ if (this.#hasAngle) v.angle = this.#angle;
485
+ if (this.#hasSecondPoint) {
486
+ v.x2 = this.#x2;
487
+ v.y2 = this.#y2;
488
+ }
489
+ return v;
490
+ }
491
+
492
+ set value(val) {
493
+ if (typeof val === "object") {
494
+ this.setAttribute("value", JSON.stringify(val));
495
+ } else if (typeof val === "string") {
496
+ this.setAttribute("value", val);
497
+ }
498
+ }
499
+
500
+ #render() {
501
+ this.innerHTML = "";
502
+ this.#pointHandle = null;
503
+ this.#secondHandle = null;
504
+ this.#angleHandle = null;
505
+ this.#radiusSvg = null;
506
+ this.#angleSvg = null;
507
+ this.#pointTooltip = null;
508
+ this.#secondTooltip = null;
509
+ this.#radiusTooltip = null;
510
+ this.#angleTooltip = null;
511
+
512
+ const disabled = this.hasAttribute("disabled");
513
+ const type = this.#type;
514
+ const tooltips = this.#tooltipsEnabled;
515
+
516
+ const handleSurface = this.#handleDragSurface;
517
+
518
+ const handle = document.createElement("fig-handle");
519
+ handle.setAttribute("drag", "true");
520
+ handle.setAttribute("drag-surface", handleSurface);
521
+ handle.setAttribute("drag-axes", "x,y");
522
+ handle.setAttribute("drag-snapping", this.#snappingMode);
523
+ handle.setAttribute("value", `${this.#x}% ${this.#y}%`);
524
+ if (disabled) handle.setAttribute("disabled", "");
525
+ if (type === "color") {
526
+ handle.setAttribute("type", "color");
527
+ const color = this.getAttribute("color");
528
+ if (color) handle.setAttribute("color", color);
529
+ } else {
530
+ handle.setAttribute("type", "canvas");
531
+ }
532
+ if (this.#hasSecondPoint) {
533
+ handle.setAttribute("hit-area", "12 circle");
534
+ handle.setAttribute("hit-area-mode", "delegate");
535
+ }
536
+ this.#pointHandle = handle;
537
+
538
+ if (this.#hasRadius) {
539
+ this.#createRadiusSvg();
540
+ }
541
+
542
+ if (this.#hasLine) {
543
+ this.#createAngleSvg();
544
+ }
545
+
546
+ if (tooltips) {
547
+ const tip = document.createElement("fig-tooltip");
548
+ tip.setAttribute("action", "manual");
549
+ tip.setAttribute("theme", "canvas");
550
+ tip.setAttribute("pointer", "false");
551
+ tip.setAttribute("text", this.#pointTipText);
552
+ tip.appendChild(handle);
553
+ this.appendChild(tip);
554
+ this.#pointTooltip = tip;
555
+ } else {
556
+ this.appendChild(handle);
557
+ }
558
+
559
+ if (this.#hasAngle) {
560
+ this.#createAngleHandle(disabled, tooltips, handleSurface);
561
+ }
562
+
563
+ if (this.#hasSecondPoint) {
564
+ this.#createSecondHandle(disabled, tooltips, handleSurface);
565
+ }
566
+
567
+ this.#setupEventListeners();
568
+ this.#wireHoverTooltips();
569
+ requestAnimationFrame(() => this.#syncPositions());
570
+ }
571
+
572
+ #wireHoverTooltip(target, getTooltip, getText, isDraggingRef) {
573
+ if (!target) return;
574
+ const shouldSuppress = () => !!isDraggingRef?.();
575
+ const hideTooltip = () => {
576
+ const tip = getTooltip();
577
+ if (!tip) return;
578
+ tip.removeAttribute("show");
579
+ tip.hidePopup?.();
580
+ };
581
+ const show = () => {
582
+ if (shouldSuppress()) {
583
+ hideTooltip();
584
+ return;
585
+ }
586
+ const tip = getTooltip();
587
+ if (!tip) return;
588
+ if (getText) tip.setAttribute("text", getText());
589
+ tip.setAttribute("show", "true");
590
+ tip.showPopup?.();
591
+ };
592
+ const hide = () => {
593
+ hideTooltip();
594
+ };
595
+ target.addEventListener("pointerenter", show);
596
+ target.addEventListener("pointerleave", hide);
597
+ }
598
+
599
+ #hasActiveInteraction() {
600
+ return (
601
+ this.#isDragging ||
602
+ this.#isSecondDragging ||
603
+ this.#isRadiusDragging ||
604
+ this.#isAngleDragging
605
+ );
606
+ }
607
+
608
+ #wireHoverTooltips() {
609
+ if (this.#pointHandle) {
610
+ this.#wireHoverTooltip(
611
+ this.#pointHandle,
612
+ () => this.#pointTooltip,
613
+ () => this.#pointTipText,
614
+ () =>
615
+ this.#hasActiveInteraction() ||
616
+ !!this.#pointHandle?.querySelector("fig-color-tip"),
617
+ );
618
+ }
619
+ if (this.#angleHandle) {
620
+ this.#wireHoverTooltip(
621
+ this.#angleHandle,
622
+ () => this.#angleTooltip,
623
+ () => `Angle ${Math.round(this.#angle)}°`,
624
+ () => this.#hasActiveInteraction(),
625
+ );
626
+ }
627
+ if (this.#secondHandle) {
628
+ this.#wireHoverTooltip(
629
+ this.#secondHandle,
630
+ () => this.#secondTooltip,
631
+ () => this.#secondTipText,
632
+ () => this.#hasActiveInteraction(),
633
+ );
634
+ }
635
+ if (this.#radiusSvg) {
636
+ const hit = this.#radiusSvg.querySelector(
637
+ ".fig-canvas-control-radius-hit",
638
+ );
639
+ this.#wireRadiusHoverTooltip(hit || this.#radiusSvg);
640
+ }
641
+
642
+ if (this.#type === "color" && this.#pointHandle && this.#pointTooltip) {
643
+ const obs = new MutationObserver(() => {
644
+ if (this.#pointHandle?.querySelector("fig-color-tip")) {
645
+ this.#pointTooltip?.removeAttribute("show");
646
+ this.#pointTooltip?.hidePopup?.();
647
+ }
648
+ });
649
+ obs.observe(this.#pointHandle, { childList: true, subtree: true });
650
+ }
651
+ }
652
+
653
+ #setRadiusTooltipAnchorAt(clientX, clientY) {
654
+ const tip = this.#radiusTooltip;
655
+ if (!tip?.popup) return;
656
+ const y = clientY - 8;
657
+ tip.popup.anchor = {
658
+ getBoundingClientRect: () => ({
659
+ left: clientX,
660
+ top: y,
661
+ right: clientX,
662
+ bottom: y,
663
+ width: 0,
664
+ height: 0,
665
+ x: clientX,
666
+ y,
667
+ }),
668
+ };
669
+ tip.popup.queueReposition?.();
670
+ }
671
+
672
+ #wireRadiusHoverTooltip(target) {
673
+ if (!target) return;
674
+ target.addEventListener("pointerenter", (e) => {
675
+ const tip = this.#radiusTooltip;
676
+ if (!tip) return;
677
+ tip.setAttribute("text", this.#formatRadius());
678
+ tip.setAttribute("show", "true");
679
+ tip.showPopup?.();
680
+ this.#setRadiusTooltipAnchorAt(e.clientX, e.clientY);
681
+ });
682
+ target.addEventListener("pointermove", (e) => {
683
+ if (this.#isRadiusDragging) return;
684
+ this.#setRadiusTooltipAnchorAt(e.clientX, e.clientY);
685
+ });
686
+ target.addEventListener("pointerleave", () => {
687
+ if (this.#isRadiusDragging) return;
688
+ const tip = this.#radiusTooltip;
689
+ if (!tip) return;
690
+ tip.removeAttribute("show");
691
+ });
692
+ }
693
+
694
+ #createRadiusSvg() {
695
+ const ns = "http://www.w3.org/2000/svg";
696
+ const svg = document.createElementNS(ns, "svg");
697
+ svg.classList.add("fig-canvas-control-radius");
698
+ svg.setAttribute("overflow", "visible");
699
+ const hitCircle = document.createElementNS(ns, "circle");
700
+ hitCircle.classList.add("fig-canvas-control-radius-hit");
701
+ svg.appendChild(hitCircle);
702
+ const haloCircle = document.createElementNS(ns, "circle");
703
+ haloCircle.classList.add("fig-canvas-control-radius-halo");
704
+ svg.appendChild(haloCircle);
705
+ const circle = document.createElementNS(ns, "circle");
706
+ svg.appendChild(circle);
707
+ this.#radiusSvg = svg;
708
+
709
+ if (this.#tooltipsEnabled) {
710
+ const tip = document.createElement("fig-tooltip");
711
+ tip.setAttribute("action", "manual");
712
+ tip.setAttribute("theme", "canvas");
713
+ tip.setAttribute("pointer", "false");
714
+ tip.setAttribute("text", this.#formatRadius());
715
+ tip.appendChild(svg);
716
+ this.appendChild(tip);
717
+ this.#radiusTooltip = tip;
718
+ } else {
719
+ this.appendChild(svg);
720
+ }
721
+
722
+ this.#setupRadiusDrag(hitCircle);
723
+ }
724
+
725
+ #createAngleSvg() {
726
+ const ns = "http://www.w3.org/2000/svg";
727
+ const svg = document.createElementNS(ns, "svg");
728
+ svg.classList.add("fig-canvas-control-angle-svg");
729
+ svg.setAttribute("overflow", "visible");
730
+ svg.style.position = "absolute";
731
+ svg.style.pointerEvents = "none";
732
+ if (this.#hasSecondPoint) {
733
+ const hitLine = document.createElementNS(ns, "line");
734
+ hitLine.classList.add("fig-canvas-control-angle-line-hit");
735
+ hitLine.setAttribute("stroke", "transparent");
736
+ hitLine.setAttribute("stroke-width", "12");
737
+ hitLine.setAttribute("stroke-linecap", "round");
738
+ hitLine.style.pointerEvents = "stroke";
739
+ hitLine.style.cursor = "move";
740
+ svg.appendChild(hitLine);
741
+ this.#setupLineDrag(hitLine);
742
+ }
743
+ const haloLine = document.createElementNS(ns, "line");
744
+ haloLine.classList.add("fig-canvas-control-angle-line-halo");
745
+ svg.appendChild(haloLine);
746
+ const line = document.createElementNS(ns, "line");
747
+ line.classList.add("fig-canvas-control-angle-line");
748
+ svg.appendChild(line);
749
+ this.#angleSvg = svg;
750
+ this.appendChild(svg);
751
+ }
752
+
753
+ #setupLineDrag(hitLine) {
754
+ hitLine.addEventListener("pointerdown", (e) => {
755
+ if (this.hasAttribute("disabled")) return;
756
+ e.preventDefault();
757
+ e.stopPropagation();
758
+ const container = this.#container;
759
+ if (!container) return;
760
+ const rect0 = container.getBoundingClientRect();
761
+ const startX = e.clientX;
762
+ const startY = e.clientY;
763
+ const x0 = this.#x;
764
+ const y0 = this.#y;
765
+ const x20 = this.#x2;
766
+ const y20 = this.#y2;
767
+ this.#isDragging = true;
768
+ this.#isSecondDragging = true;
769
+ const prevBodyCursor = document.body.style.cursor;
770
+ document.body.style.cursor = "move";
771
+ hitLine.style.pointerEvents = "none";
772
+
773
+ const onMove = (ev) => {
774
+ const rect = container.getBoundingClientRect();
775
+ const dxPctRaw =
776
+ rect.width > 0 ? ((ev.clientX - startX) / rect.width) * 100 : 0;
777
+ const dyPctRaw =
778
+ rect.height > 0 ? ((ev.clientY - startY) / rect.height) * 100 : 0;
779
+ const minDx = -Math.min(x0, x20);
780
+ const maxDx = 100 - Math.max(x0, x20);
781
+ const minDy = -Math.min(y0, y20);
782
+ const maxDy = 100 - Math.max(y0, y20);
783
+ const dxPct = Math.max(minDx, Math.min(maxDx, dxPctRaw));
784
+ const dyPct = Math.max(minDy, Math.min(maxDy, dyPctRaw));
785
+ this.#x = x0 + dxPct;
786
+ this.#y = y0 + dyPct;
787
+ this.#x2 = x20 + dxPct;
788
+ this.#y2 = y20 + dyPct;
789
+ this.#syncPositions();
790
+ this.#emitInput();
791
+ };
792
+
793
+ const onUp = () => {
794
+ document.body.style.cursor = prevBodyCursor;
795
+ hitLine.style.pointerEvents = "stroke";
796
+ this.#syncValueAttribute();
797
+ this.#emitChange();
798
+ window.removeEventListener("pointermove", onMove);
799
+ window.removeEventListener("pointerup", onUp);
800
+ requestAnimationFrame(() => {
801
+ this.#isDragging = false;
802
+ this.#isSecondDragging = false;
803
+ });
804
+ };
805
+
806
+ window.addEventListener("pointermove", onMove);
807
+ window.addEventListener("pointerup", onUp);
808
+ });
809
+ }
810
+
811
+ #createAngleHandle(disabled, tooltips, handleSurface) {
812
+ const handle = document.createElement("fig-handle");
813
+ handle.setAttribute("type", "canvas");
814
+ handle.setAttribute("drag", "true");
815
+ handle.setAttribute("drag-surface", handleSurface);
816
+ handle.setAttribute("drag-axes", "x,y");
817
+ handle.setAttribute("size", "small");
818
+ handle.setAttribute("hit-area", "12 circle");
819
+ handle.setAttribute("hit-area-mode", "delegate");
820
+ if (disabled) handle.setAttribute("disabled", "");
821
+ this.#angleHandle = handle;
822
+
823
+ if (tooltips) {
824
+ const tip = document.createElement("fig-tooltip");
825
+ tip.setAttribute("action", "manual");
826
+ tip.setAttribute("theme", "canvas");
827
+ tip.setAttribute("pointer", "false");
828
+ tip.setAttribute("text", `${Math.round(this.#angle)}°`);
829
+ tip.appendChild(handle);
830
+ this.appendChild(tip);
831
+ this.#angleTooltip = tip;
832
+ } else {
833
+ this.appendChild(handle);
834
+ }
835
+ }
836
+
837
+ #createSecondHandle(disabled, tooltips, handleSurface) {
838
+ const handle = document.createElement("fig-handle");
839
+ handle.setAttribute("type", "canvas");
840
+ handle.setAttribute("drag", "true");
841
+ handle.setAttribute("drag-surface", handleSurface);
842
+ handle.setAttribute("drag-axes", "x,y");
843
+ handle.setAttribute("drag-snapping", this.#snappingMode);
844
+ handle.setAttribute("hit-area", "12 circle");
845
+ handle.setAttribute("hit-area-mode", "delegate");
846
+ handle.setAttribute("value", `${this.#x2}% ${this.#y2}%`);
847
+ if (disabled) handle.setAttribute("disabled", "");
848
+ this.#secondHandle = handle;
849
+
850
+ if (tooltips) {
851
+ const tip = document.createElement("fig-tooltip");
852
+ tip.setAttribute("action", "manual");
853
+ tip.setAttribute("theme", "canvas");
854
+ tip.setAttribute("pointer", "false");
855
+ tip.setAttribute("text", this.#secondTipText);
856
+ tip.appendChild(handle);
857
+ this.appendChild(tip);
858
+ this.#secondTooltip = tip;
859
+ } else {
860
+ this.appendChild(handle);
861
+ }
862
+ }
863
+
864
+ #resizeCursorSvg(deg) {
865
+ const r = Math.round(deg);
866
+ return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11.1212 16.9998L11.5607 17.4394C12.1465 18.0252 12.1464 18.975 11.5606 19.5607C10.9748 20.1465 10.0251 20.1465 9.4393 19.5606L6.4393 16.5604C5.85354 15.9746 5.85357 15.0249 6.43938 14.4391L9.43938 11.4393C10.0252 10.8535 10.9749 10.8536 11.5607 11.4394C12.1465 12.0252 12.1464 12.9749 11.5606 13.5607L11.1215 13.9998L20.8786 13.9999L20.4394 13.5607C19.8536 12.9749 19.8535 12.0252 20.4393 11.4394C21.0251 10.8536 21.9749 10.8536 22.5606 11.4394L25.5606 14.4393C25.842 14.7206 26 15.1021 26 15.4999C26 15.8978 25.842 16.2793 25.5607 16.5606L22.5607 19.5607C21.9749 20.1465 21.0251 20.1465 20.4393 19.5607C19.8536 18.9749 19.8535 18.0252 20.4393 17.4394L20.8788 16.9999L11.1212 16.9998Z' fill='white'/%3E%3C/g%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.8536 12.1465C11.0488 12.3417 11.0488 12.6583 10.8535 12.8536L8.70715 14.9998L23.2929 14.9999L21.1465 12.8536C20.9512 12.6583 20.9512 12.3417 21.1464 12.1465C21.3417 11.9512 21.6583 11.9512 21.8535 12.1465L24.8535 15.1464C24.9473 15.2402 25 15.3673 25 15.4999C25 15.6326 24.9473 15.7597 24.8536 15.8535L21.8536 18.8536C21.6583 19.0488 21.3417 19.0488 21.1465 18.8536C20.9512 18.6583 20.9512 18.3417 21.1464 18.1465L23.2929 15.9999L8.70705 15.9998L10.8536 18.1465C11.0488 18.3417 11.0488 18.6583 10.8535 18.8536C10.6583 19.0488 10.3417 19.0488 10.1464 18.8535L7.14643 15.8533C6.95118 15.658 6.95119 15.3415 7.14646 15.1462L10.1465 12.1464C10.3417 11.9512 10.6583 11.9512 10.8536 12.1465Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='3' y='9' width='26' height='15' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, nwse-resize`;
867
+ }
868
+
869
+ #rotateCursorSvg(deg) {
870
+ const r = Math.round(deg - 45);
871
+ return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath d='M12.5607 22.4393L12.0216 21.9002C17.1558 21.2216 21.2216 17.1558 21.9002 12.0216L22.4393 12.5607C23.0251 13.1464 23.9749 13.1464 24.5607 12.5607C25.1464 11.9749 25.1464 11.0251 24.5607 10.4393L21.5607 7.43934C20.9749 6.85355 20.0251 6.85355 19.4393 7.43934L16.4393 10.4393C15.8536 11.0251 15.8536 11.9749 16.4393 12.5607C17.0251 13.1464 17.9749 13.1464 18.5607 12.5607L18.8056 12.3157C18.1013 15.5527 15.5527 18.1013 12.3157 18.8056L12.5607 18.5607C13.1464 17.9749 13.1464 17.0251 12.5607 16.4393C11.9749 15.8536 11.0251 15.8536 10.4393 16.4393L7.43934 19.4393C6.85356 20.0251 6.85356 20.9749 7.43934 21.5607L10.4393 24.5607C11.0251 25.1464 11.9749 25.1464 12.5607 24.5607C13.1464 23.9749 13.1464 23.0251 12.5607 22.4393Z' fill='white'/%3E%3C/g%3E%3Cpath d='M23.8536 11.8536C23.6583 12.0488 23.3417 12.0488 23.1464 11.8536L21 9.70711V10.5C21 16.299 16.299 21 10.5 21H9.70711L11.8536 23.1464C12.0488 23.3417 12.0488 23.6583 11.8536 23.8536C11.6583 24.0488 11.3417 24.0488 11.1464 23.8536L8.14645 20.8536C7.95119 20.6583 7.95119 20.3417 8.14645 20.1464L11.1464 17.1464C11.3417 16.9512 11.6583 16.9512 11.8536 17.1464C12.0488 17.3417 12.0488 17.6583 11.8536 17.8536L9.70711 20H10.5C15.7467 20 20 15.7467 20 10.5V9.70711L17.8536 11.8536C17.6583 12.0488 17.3417 12.0488 17.1464 11.8536C16.9512 11.6583 16.9512 11.3417 17.1464 11.1464L20.1464 8.14645C20.3417 7.95119 20.6583 7.95119 20.8536 8.14645L23.8536 11.1464C24.0488 11.3417 24.0488 11.6583 23.8536 11.8536Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='4' y='5' width='24' height='24' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, pointer`;
872
+ }
873
+
874
+ #syncAngleCursor() {
875
+ if (!this.#angleHandle || !this.#hasAngle) return;
876
+ const hitArea = this.#angleHandle.querySelector(".fig-handle-hit-area");
877
+ if (!hitArea) return;
878
+ hitArea.style.cursor = this.#rotateCursorSvg(this.#angle);
879
+ }
880
+
881
+ #pointPointLineDeg() {
882
+ return (Math.atan2(this.#y2 - this.#y, this.#x2 - this.#x) * 180) / Math.PI;
883
+ }
884
+
885
+ #syncPointPointCursors() {
886
+ if (!this.#hasSecondPoint) return;
887
+ const deg = this.#pointPointLineDeg();
888
+ const setHitCursor = (handle, rotateDeg) => {
889
+ if (!handle) return;
890
+ const hitArea = handle.querySelector(".fig-handle-hit-area");
891
+ if (hitArea) hitArea.style.cursor = this.#rotateCursorSvg(rotateDeg);
892
+ };
893
+ setHitCursor(this.#pointHandle, deg + 180);
894
+ setHitCursor(this.#secondHandle, deg);
895
+ }
896
+
897
+ #positionHandle(handle, xPct, yPct, rect) {
898
+ handle.style.setProperty("--fig-handle-position-translate", "-50% -50%");
899
+ handle.style.left = `${(xPct / 100) * rect.width}px`;
900
+ handle.style.top = `${(yPct / 100) * rect.height}px`;
901
+ }
902
+
903
+ #syncPositions() {
904
+ const container = this.#container;
905
+ if (!container || !this.#pointHandle) return;
906
+ const rect = container.getBoundingClientRect();
907
+
908
+ this.#positionHandle(this.#pointHandle, this.#x, this.#y, rect);
909
+
910
+ if (this.#radiusSvg) {
911
+ const cx = (this.#x / 100) * rect.width;
912
+ const cy = (this.#y / 100) * rect.height;
913
+ const r = this.#resolveRadius(rect.width);
914
+ const svg = this.#radiusSvg;
915
+ const d = Math.max(r * 2, 1);
916
+ svg.style.position = "absolute";
917
+ svg.style.width = `${d}px`;
918
+ svg.style.height = `${d}px`;
919
+ svg.style.left = `${cx - r}px`;
920
+ svg.style.top = `${cy - r}px`;
921
+ svg.setAttribute("viewBox", `0 0 ${d} ${d}`);
922
+ const circles = svg.querySelectorAll("circle");
923
+ for (const c of circles) {
924
+ c.setAttribute("cx", String(r));
925
+ c.setAttribute("cy", String(r));
926
+ c.setAttribute("r", String(Math.max(r - 1, 0)));
927
+ }
928
+ }
929
+
930
+ if (this.#angleSvg && this.#hasLine) {
931
+ const cx = (this.#x / 100) * rect.width;
932
+ const cy = (this.#y / 100) * rect.height;
933
+ let lx2, ly2;
934
+ if (this.#hasSecondPoint) {
935
+ lx2 = (this.#x2 / 100) * rect.width;
936
+ ly2 = (this.#y2 / 100) * rect.height;
937
+ } else {
938
+ const r = this.#resolveRadius(rect.width);
939
+ const angleRad = (this.#angle * Math.PI) / 180;
940
+ lx2 = cx + r * Math.cos(angleRad);
941
+ ly2 = cy + r * Math.sin(angleRad);
942
+ }
943
+
944
+ const svg = this.#angleSvg;
945
+ svg.style.width = `${rect.width}px`;
946
+ svg.style.height = `${rect.height}px`;
947
+ svg.style.left = "0";
948
+ svg.style.top = "0";
949
+ svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
950
+ const lines = svg.querySelectorAll(
951
+ ".fig-canvas-control-angle-line, .fig-canvas-control-angle-line-halo",
952
+ );
953
+ for (const line of lines) {
954
+ line.setAttribute("x1", String(cx));
955
+ line.setAttribute("y1", String(cy));
956
+ line.setAttribute("x2", String(lx2));
957
+ line.setAttribute("y2", String(ly2));
958
+ }
959
+ }
960
+
961
+ if (this.#angleHandle && this.#hasAngle) {
962
+ const cx = (this.#x / 100) * rect.width;
963
+ const cy = (this.#y / 100) * rect.height;
964
+ const r = this.#resolveRadius(rect.width);
965
+ const angleRad = (this.#angle * Math.PI) / 180;
966
+ const ax = cx + r * Math.cos(angleRad);
967
+ const ay = cy + r * Math.sin(angleRad);
968
+ const pxPct = rect.width > 0 ? (ax / rect.width) * 100 : 0;
969
+ const pyPct = rect.height > 0 ? (ay / rect.height) * 100 : 0;
970
+ this.#positionHandle(this.#angleHandle, pxPct, pyPct, rect);
971
+ }
972
+
973
+ if (this.#secondHandle && this.#hasSecondPoint) {
974
+ this.#positionHandle(this.#secondHandle, this.#x2, this.#y2, rect);
975
+ }
976
+
977
+ this.#syncAngleCursor();
978
+ this.#syncPointPointCursors();
979
+ }
980
+
981
+ #emitInput() {
982
+ this.dispatchEvent(
983
+ new CustomEvent("input", { bubbles: true, detail: this.value }),
984
+ );
985
+ }
986
+
987
+ #emitChange() {
988
+ this.dispatchEvent(
989
+ new CustomEvent("change", { bubbles: true, detail: this.value }),
990
+ );
991
+ }
992
+
993
+ #syncValueAttribute() {
994
+ this.setAttribute("value", JSON.stringify(this.value));
995
+ }
996
+
997
+ #setupEventListeners() {
998
+ if (!this.#pointHandle) return;
999
+
1000
+ this.#pointHandle.addEventListener("input", (e) => {
1001
+ e.stopPropagation();
1002
+ if (e.detail?.color) {
1003
+ this.setAttribute("color", e.detail.color);
1004
+ this.#emitInput();
1005
+ return;
1006
+ }
1007
+ if (!this.#isDragging && this.#hasSecondPoint) {
1008
+ this.#prevBodyCursor = document.body.style.cursor;
1009
+ }
1010
+ this.#isDragging = true;
1011
+ const px = e.detail?.px ?? this.#x / 100;
1012
+ const py = e.detail?.py ?? this.#y / 100;
1013
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
1014
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
1015
+ if (this.#pointTooltip) {
1016
+ this.#pointTooltip.removeAttribute("show");
1017
+ this.#pointTooltip.hidePopup?.();
1018
+ }
1019
+ this.#syncPositions();
1020
+ if (this.#hasSecondPoint) {
1021
+ document.body.style.cursor = this.#resizeCursorSvg(
1022
+ this.#pointPointLineDeg(),
1023
+ );
1024
+ }
1025
+ this.#emitInput();
1026
+ });
1027
+
1028
+ this.#pointHandle.addEventListener("change", (e) => {
1029
+ e.stopPropagation();
1030
+ if (e.detail?.color) {
1031
+ this.setAttribute("color", e.detail.color);
1032
+ this.#emitChange();
1033
+ return;
1034
+ }
1035
+ const px = e.detail?.px ?? this.#x / 100;
1036
+ const py = e.detail?.py ?? this.#y / 100;
1037
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
1038
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
1039
+ if (this.#pointTooltip) this.#pointTooltip.removeAttribute("show");
1040
+ if (this.#hasSecondPoint) {
1041
+ document.body.style.cursor = this.#prevBodyCursor ?? "";
1042
+ }
1043
+ this.#syncPositions();
1044
+ this.#syncValueAttribute();
1045
+ this.#emitChange();
1046
+ requestAnimationFrame(() => {
1047
+ this.#isDragging = false;
1048
+ });
1049
+ });
1050
+
1051
+ if (this.#angleHandle) {
1052
+ this.#angleHandle.addEventListener("input", (e) => {
1053
+ e.stopPropagation();
1054
+ this.#isAngleDragging = true;
1055
+ this.classList.add("fig-canvas-control-ring-active");
1056
+ const container = this.#container;
1057
+ if (!container) return;
1058
+ const rect = container.getBoundingClientRect();
1059
+ const cx = (this.#x / 100) * rect.width;
1060
+ const cy = (this.#y / 100) * rect.height;
1061
+ const hx = e.detail?.x ?? 0;
1062
+ const hy = e.detail?.y ?? 0;
1063
+ const hw = this.#angleHandle.offsetWidth / 2;
1064
+ const hh = this.#angleHandle.offsetHeight / 2;
1065
+ const dx = hx + hw - cx;
1066
+ const dy = hy + hh - cy;
1067
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1068
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
1069
+ angle = Math.round(angle / 15) * 15;
1070
+ }
1071
+ this.#angle = angle;
1072
+
1073
+ let dist = Math.sqrt(dx * dx + dy * dy);
1074
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
1075
+ const step = this.#radiusIsPercent ? 5 : 10;
1076
+ if (this.#radiusIsPercent) {
1077
+ let pct = (dist / rect.width) * 100;
1078
+ pct = Math.round(pct / step) * step;
1079
+ dist = (pct / 100) * rect.width;
1080
+ } else {
1081
+ dist = Math.round(dist / step) * step;
1082
+ }
1083
+ }
1084
+ if (this.#radiusIsPercent) {
1085
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
1086
+ } else {
1087
+ this.#radius = Math.max(0, dist);
1088
+ }
1089
+
1090
+ if (this.#angleTooltip) {
1091
+ this.#angleTooltip.setAttribute(
1092
+ "text",
1093
+ `Angle ${Math.round(this.#angle)}°`,
1094
+ );
1095
+ this.#angleTooltip.setAttribute("show", "true");
1096
+ this.#angleTooltip.showPopup?.();
1097
+ }
1098
+ this.#syncPositions();
1099
+ this.#emitInput();
1100
+ });
1101
+
1102
+ this.#angleHandle.addEventListener("change", (e) => {
1103
+ e.stopPropagation();
1104
+ this.classList.remove("fig-canvas-control-ring-active");
1105
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
1106
+ this.#syncPositions();
1107
+ this.#syncValueAttribute();
1108
+ this.#emitChange();
1109
+ requestAnimationFrame(() => {
1110
+ this.#isAngleDragging = false;
1111
+ });
1112
+ });
1113
+
1114
+ this.#angleHandle.addEventListener("hitareadown", (e) => {
1115
+ e.stopPropagation();
1116
+ const origEvent = e.detail?.originalEvent;
1117
+ if (!origEvent) return;
1118
+ origEvent.preventDefault();
1119
+ this.#isAngleDragging = true;
1120
+ this.classList.add("fig-canvas-control-ring-active");
1121
+ this.#angleHandle.setAttribute("selected", "");
1122
+ const container = this.#container;
1123
+ if (!container) return;
1124
+
1125
+ if (this.#angleTooltip) {
1126
+ this.#angleTooltip.setAttribute("show", "true");
1127
+ this.#angleTooltip.showPopup?.();
1128
+ }
1129
+
1130
+ const prevBodyCursor = document.body.style.cursor;
1131
+ let lastCursorDeg = Math.round(this.#angle);
1132
+ document.body.style.cursor = this.#rotateCursorSvg(lastCursorDeg);
1133
+
1134
+ const onMove = (ev) => {
1135
+ const rect = container.getBoundingClientRect();
1136
+ const cx = (this.#x / 100) * rect.width;
1137
+ const cy = (this.#y / 100) * rect.height;
1138
+ const dx = ev.clientX - rect.left - cx;
1139
+ const dy = ev.clientY - rect.top - cy;
1140
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1141
+ if (this.#shouldSnap(ev.shiftKey)) {
1142
+ angle = Math.round(angle / 15) * 15;
1143
+ }
1144
+ this.#angle = angle;
1145
+ if (this.#angleTooltip)
1146
+ this.#angleTooltip.setAttribute(
1147
+ "text",
1148
+ `Angle ${Math.round(angle)}°`,
1149
+ );
1150
+ this.#syncPositions();
1151
+ const curDeg = Math.round(angle);
1152
+ if (curDeg !== lastCursorDeg) {
1153
+ lastCursorDeg = curDeg;
1154
+ document.body.style.cursor = this.#rotateCursorSvg(curDeg);
1155
+ }
1156
+ this.#emitInput();
1157
+ };
1158
+
1159
+ const onUp = () => {
1160
+ this.#isAngleDragging = false;
1161
+ this.classList.remove("fig-canvas-control-ring-active");
1162
+ this.#angleHandle.removeAttribute("selected");
1163
+ document.body.style.cursor = prevBodyCursor;
1164
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
1165
+ this.#syncValueAttribute();
1166
+ this.#emitChange();
1167
+ window.removeEventListener("pointermove", onMove);
1168
+ window.removeEventListener("pointerup", onUp);
1169
+ };
1170
+
1171
+ window.addEventListener("pointermove", onMove);
1172
+ window.addEventListener("pointerup", onUp);
1173
+ });
1174
+ }
1175
+
1176
+ if (this.#secondHandle) {
1177
+ this.#secondHandle.addEventListener("input", (e) => {
1178
+ e.stopPropagation();
1179
+ if (!this.#isSecondDragging) {
1180
+ this.#prevBodyCursor = document.body.style.cursor;
1181
+ }
1182
+ this.#isSecondDragging = true;
1183
+ const px = e.detail?.px ?? this.#x2 / 100;
1184
+ const py = e.detail?.py ?? this.#y2 / 100;
1185
+ this.#x2 = Math.round(Math.max(0, Math.min(100, px * 100)));
1186
+ this.#y2 = Math.round(Math.max(0, Math.min(100, py * 100)));
1187
+ if (this.#secondTooltip) {
1188
+ this.#secondTooltip.removeAttribute("show");
1189
+ this.#secondTooltip.hidePopup?.();
1190
+ }
1191
+ this.#syncPositions();
1192
+ document.body.style.cursor = this.#resizeCursorSvg(
1193
+ this.#pointPointLineDeg(),
1194
+ );
1195
+ this.#emitInput();
1196
+ });
1197
+
1198
+ this.#secondHandle.addEventListener("change", (e) => {
1199
+ e.stopPropagation();
1200
+ document.body.style.cursor = this.#prevBodyCursor ?? "";
1201
+ if (this.#secondTooltip) this.#secondTooltip.removeAttribute("show");
1202
+ this.#syncPositions();
1203
+ this.#syncValueAttribute();
1204
+ this.#emitChange();
1205
+ requestAnimationFrame(() => {
1206
+ this.#isSecondDragging = false;
1207
+ });
1208
+ });
1209
+
1210
+ this.#setupPointPointHitArea(this.#pointHandle, true);
1211
+ this.#setupPointPointHitArea(this.#secondHandle, false);
1212
+ }
1213
+ }
1214
+
1215
+ #setupPointPointHitArea(handle, isFirst) {
1216
+ if (!handle) return;
1217
+ handle.addEventListener("hitareadown", (e) => {
1218
+ e.stopPropagation();
1219
+ const origEvent = e.detail?.originalEvent;
1220
+ if (!origEvent) return;
1221
+ origEvent.preventDefault();
1222
+ this.#isDragging = true;
1223
+ const container = this.#container;
1224
+ if (!container) return;
1225
+ const rect = container.getBoundingClientRect();
1226
+
1227
+ const pivotX = isFirst ? this.#x2 : this.#x;
1228
+ const pivotY = isFirst ? this.#y2 : this.#y;
1229
+ const movingX = isFirst ? this.#x : this.#x2;
1230
+ const movingY = isFirst ? this.#y : this.#y2;
1231
+ const pcx = (pivotX / 100) * rect.width;
1232
+ const pcy = (pivotY / 100) * rect.height;
1233
+ const mcx = (movingX / 100) * rect.width;
1234
+ const mcy = (movingY / 100) * rect.height;
1235
+ const fixedLen = Math.sqrt((mcx - pcx) ** 2 + (mcy - pcy) ** 2);
1236
+
1237
+ const tooltip = isFirst ? this.#pointTooltip : this.#secondTooltip;
1238
+ if (tooltip) {
1239
+ tooltip.removeAttribute("show");
1240
+ tooltip.hidePopup?.();
1241
+ }
1242
+
1243
+ const prevBodyCursor = document.body.style.cursor;
1244
+ const initDeg = this.#pointPointLineDeg();
1245
+ let lastCursorDeg = Math.round(isFirst ? initDeg + 180 : initDeg);
1246
+ document.body.style.cursor = this.#rotateCursorSvg(lastCursorDeg);
1247
+
1248
+ const onMove = (ev) => {
1249
+ const r = container.getBoundingClientRect();
1250
+ const px = (pivotX / 100) * r.width;
1251
+ const py = (pivotY / 100) * r.height;
1252
+ const dx = ev.clientX - r.left - px;
1253
+ const dy = ev.clientY - r.top - py;
1254
+ let angle = Math.atan2(dy, dx);
1255
+ if (this.#shouldSnap(ev.shiftKey)) {
1256
+ const snapDeg = Math.round((angle * 180) / Math.PI / 15) * 15;
1257
+ angle = (snapDeg * Math.PI) / 180;
1258
+ }
1259
+ const nx = px + fixedLen * Math.cos(angle);
1260
+ const ny = py + fixedLen * Math.sin(angle);
1261
+ const newPctX = Math.max(0, Math.min(100, (nx / r.width) * 100));
1262
+ const newPctY = Math.max(0, Math.min(100, (ny / r.height) * 100));
1263
+ if (isFirst) {
1264
+ this.#x = newPctX;
1265
+ this.#y = newPctY;
1266
+ } else {
1267
+ this.#x2 = newPctX;
1268
+ this.#y2 = newPctY;
1269
+ }
1270
+ this.#syncPositions();
1271
+ const curDeg = Math.round(
1272
+ isFirst ? this.#pointPointLineDeg() + 180 : this.#pointPointLineDeg(),
1273
+ );
1274
+ if (curDeg !== lastCursorDeg) {
1275
+ lastCursorDeg = curDeg;
1276
+ document.body.style.cursor = this.#rotateCursorSvg(curDeg);
1277
+ }
1278
+ this.#emitInput();
1279
+ };
1280
+
1281
+ const onUp = () => {
1282
+ this.#isDragging = false;
1283
+ document.body.style.cursor = prevBodyCursor;
1284
+ if (tooltip) tooltip.removeAttribute("show");
1285
+ this.#syncValueAttribute();
1286
+ this.#emitChange();
1287
+ window.removeEventListener("pointermove", onMove);
1288
+ window.removeEventListener("pointerup", onUp);
1289
+ };
1290
+
1291
+ window.addEventListener("pointermove", onMove);
1292
+ window.addEventListener("pointerup", onUp);
1293
+ });
1294
+ }
1295
+
1296
+ #setupRadiusDrag(circle) {
1297
+ if (!circle) return;
1298
+ circle.addEventListener("pointermove", (e) => {
1299
+ if (this.#isRadiusDragging) return;
1300
+ const container = this.#container;
1301
+ if (!container) return;
1302
+ const rect = container.getBoundingClientRect();
1303
+ const cx = (this.#x / 100) * rect.width;
1304
+ const cy = (this.#y / 100) * rect.height;
1305
+ const deg =
1306
+ (Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx) *
1307
+ 180) /
1308
+ Math.PI;
1309
+ circle.style.cursor = this.#resizeCursorSvg(deg);
1310
+ });
1311
+ const onDown = (e) => {
1312
+ if (this.hasAttribute("disabled")) return;
1313
+ e.preventDefault();
1314
+ e.stopPropagation();
1315
+ this.#isRadiusDragging = true;
1316
+ this.classList.add("fig-canvas-control-ring-active");
1317
+ const container = this.#container;
1318
+ if (!container) return;
1319
+
1320
+ if (this.#radiusTooltip) {
1321
+ this.#radiusTooltip.setAttribute("show", "true");
1322
+ this.#radiusTooltip.showPopup?.();
1323
+ this.#setRadiusTooltipAnchorAt(e.clientX, e.clientY);
1324
+ }
1325
+ if (this.#angleTooltip) {
1326
+ this.#angleTooltip.removeAttribute("show");
1327
+ this.#angleTooltip.hidePopup?.();
1328
+ }
1329
+ const prevAnglePointerEvents = this.#angleHandle?.style.pointerEvents;
1330
+ const angleHitArea = this.#angleHandle?.querySelector(
1331
+ ".fig-handle-hit-area",
1332
+ );
1333
+ const prevAngleHitPointerEvents = angleHitArea?.style.pointerEvents;
1334
+ if (this.#angleHandle) {
1335
+ this.#angleHandle.style.pointerEvents = "none";
1336
+ }
1337
+ if (angleHitArea) {
1338
+ angleHitArea.style.pointerEvents = "none";
1339
+ }
1340
+
1341
+ const prevBodyCursor = document.body.style.cursor;
1342
+ circle.style.pointerEvents = "none";
1343
+ const rect0 = container.getBoundingClientRect();
1344
+ const cx0 = (this.#x / 100) * rect0.width;
1345
+ const cy0 = (this.#y / 100) * rect0.height;
1346
+ const initDeg =
1347
+ (Math.atan2(e.clientY - rect0.top - cy0, e.clientX - rect0.left - cx0) *
1348
+ 180) /
1349
+ Math.PI;
1350
+ let lastCursorDeg = Math.round(initDeg);
1351
+ document.body.style.cursor = this.#resizeCursorSvg(lastCursorDeg);
1352
+
1353
+ const onMove = (ev) => {
1354
+ const rect = container.getBoundingClientRect();
1355
+ const cx = (this.#x / 100) * rect.width;
1356
+ const cy = (this.#y / 100) * rect.height;
1357
+ const dx = ev.clientX - rect.left - cx;
1358
+ const dy = ev.clientY - rect.top - cy;
1359
+ const curDeg = Math.round((Math.atan2(dy, dx) * 180) / Math.PI);
1360
+ if (curDeg !== lastCursorDeg) {
1361
+ lastCursorDeg = curDeg;
1362
+ document.body.style.cursor = this.#resizeCursorSvg(curDeg);
1363
+ }
1364
+ let dist = Math.sqrt(dx * dx + dy * dy);
1365
+ if (this.#shouldSnap(ev.shiftKey)) {
1366
+ const step = this.#radiusIsPercent ? 5 : 10;
1367
+ if (this.#radiusIsPercent) {
1368
+ let pct = (dist / rect.width) * 100;
1369
+ pct = Math.round(pct / step) * step;
1370
+ dist = (pct / 100) * rect.width;
1371
+ } else {
1372
+ dist = Math.round(dist / step) * step;
1373
+ }
1374
+ }
1375
+ if (this.#radiusIsPercent) {
1376
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
1377
+ } else {
1378
+ this.#radius = Math.max(0, dist);
1379
+ }
1380
+ if (this.#radiusTooltip) {
1381
+ this.#radiusTooltip.setAttribute("text", this.#formatRadius());
1382
+ this.#setRadiusTooltipAnchorAt(ev.clientX, ev.clientY);
1383
+ }
1384
+ this.#syncPositions();
1385
+ this.#emitInput();
1386
+ };
1387
+
1388
+ const onUp = () => {
1389
+ this.#isRadiusDragging = false;
1390
+ this.classList.remove("fig-canvas-control-ring-active");
1391
+ circle.style.pointerEvents = "";
1392
+ if (this.#angleHandle) {
1393
+ this.#angleHandle.style.pointerEvents = prevAnglePointerEvents ?? "";
1394
+ }
1395
+ if (angleHitArea) {
1396
+ angleHitArea.style.pointerEvents = prevAngleHitPointerEvents ?? "";
1397
+ }
1398
+ document.body.style.cursor = prevBodyCursor;
1399
+ if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
1400
+ this.#syncValueAttribute();
1401
+ this.#emitChange();
1402
+ window.removeEventListener("pointermove", onMove);
1403
+ window.removeEventListener("pointerup", onUp);
1404
+ };
1405
+
1406
+ window.addEventListener("pointermove", onMove);
1407
+ window.addEventListener("pointerup", onUp);
1408
+ };
1409
+ circle.addEventListener("pointerdown", onDown);
1410
+ this._radiusDragCleanup = () =>
1411
+ circle.removeEventListener("pointerdown", onDown);
1412
+ }
1413
+
1414
+ #teardownRadiusDrag() {
1415
+ if (this._radiusDragCleanup) {
1416
+ this._radiusDragCleanup();
1417
+ this._radiusDragCleanup = null;
1418
+ }
1419
+ }
1420
+ }
1421
+ customElements.define("fig-canvas-control", FigCanvasControl);
1422
+
1423
+ /* Angle Input */
1424
+ /**
1425
+ * A custom angle chooser input element.
1426
+ * @attr {number} value - The current angle of the handle in degrees.
1427
+ * @attr {number} precision - The number of decimal places for the output.
1428
+ * @attr {boolean} text - Whether to display a text input for the angle value.
1429
+ * @attr {boolean} dial - Whether to display the circular dial control. Defaults to true.
1430
+ * @attr {number} adjacent - The adjacent value of the angle.
1431
+ * @attr {number} opposite - The opposite value of the angle.
1432
+ * @attr {boolean} rotations - Whether to display a rotation count (×N) when rotations > 1. Defaults to false.
1433
+ */
1434
+ class FigInputAngle extends HTMLElement {
1435
+ // Private fields
1436
+ #adjacent;
1437
+ #opposite;
1438
+ #prevRawAngle = null;
1439
+ #boundHandleRawChange;
1440
+ #boundHandleMouseDown;
1441
+ #boundHandleTouchStart;
1442
+ #boundHandleKeyDown;
1443
+ #boundHandleKeyUp;
1444
+ #boundHandleAngleInput;
1445
+
1446
+ constructor() {
1447
+ super();
1448
+
1449
+ this.angle = 0;
1450
+ this.#adjacent = 1;
1451
+ this.#opposite = 0;
1452
+ this.isDragging = false;
1453
+ this.isShiftHeld = false;
1454
+ this.handle = null;
1455
+ this.angleInput = null;
1456
+ this.plane = null;
1457
+ this.units = "°";
1458
+ this.min = null;
1459
+ this.max = null;
1460
+ this.dial = true;
1461
+ this.showRotations = false;
1462
+ this.rotationSpan = null;
1463
+
1464
+ this.#boundHandleRawChange = this.#handleRawChange.bind(this);
1465
+ this.#boundHandleMouseDown = this.#handleMouseDown.bind(this);
1466
+ this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
1467
+ this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
1468
+ this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
1469
+ this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
1470
+ }
1471
+
1472
+ connectedCallback() {
1473
+ requestAnimationFrame(() => {
1474
+ this.precision = this.getAttribute("precision") || 1;
1475
+ this.precision = parseInt(this.precision);
1476
+ this.text = this.getAttribute("text") === "true";
1477
+
1478
+ let rawUnits = this.getAttribute("units") || "°";
1479
+ if (rawUnits === "deg") rawUnits = "°";
1480
+ this.units = rawUnits;
1481
+
1482
+ this.min = this.hasAttribute("min")
1483
+ ? Number(this.getAttribute("min"))
1484
+ : null;
1485
+ this.max = this.hasAttribute("max")
1486
+ ? Number(this.getAttribute("max"))
1487
+ : null;
1488
+ this.dial = this.#readBooleanAttribute("dial", true);
1489
+ this.showRotations = this.#readRotationsEnabled();
1490
+
1491
+ this.#render();
1492
+ this.#setupListeners();
1493
+
1494
+ this.#syncHandlePosition();
1495
+ if (this.text && this.angleInput) {
1496
+ this.angleInput.setAttribute(
1497
+ "value",
1498
+ this.angle.toFixed(this.precision),
1499
+ );
1500
+ }
1501
+ });
1502
+ }
1503
+
1504
+ disconnectedCallback() {
1505
+ this.#cleanupListeners();
1506
+ }
1507
+
1508
+ #render() {
1509
+ this.innerHTML = this.#getInnerHTML();
1510
+ }
1511
+
1512
+ #readBooleanAttribute(name, defaultValue = false) {
1513
+ const value = this.getAttribute(name);
1514
+ if (value === null) return defaultValue;
1515
+ const normalized = value.trim().toLowerCase();
1516
+ if (normalized === "" || normalized === "true") return true;
1517
+ if (normalized === "false") return false;
1518
+ return true;
1519
+ }
1520
+
1521
+ #readRotationsEnabled() {
1522
+ if (this.hasAttribute("rotations")) {
1523
+ return this.#readBooleanAttribute("rotations", false);
1524
+ }
1525
+ if (this.hasAttribute("show-rotations")) {
1526
+ return this.#readBooleanAttribute("show-rotations", false);
1527
+ }
1528
+ return false;
1529
+ }
1530
+
1531
+ #getInnerHTML() {
1532
+ const step = this.#getStepForUnit();
1533
+ const minAttr = this.min !== null ? `min="${this.min}"` : "";
1534
+ const maxAttr = this.max !== null ? `max="${this.max}"` : "";
1535
+ return `
1536
+ ${
1537
+ this.dial
1538
+ ? `<div class="fig-input-angle-plane" tabindex="0">
1539
+ <div class="fig-input-angle-handle"></div>
1540
+ </div>`
1541
+ : ""
1542
+ }
1543
+ ${
1544
+ this.text
1545
+ ? `<fig-input-number
1546
+ name="angle"
1547
+ step="${step}"
1548
+ value="${this.angle}"
1549
+ ${minAttr}
1550
+ ${maxAttr}
1551
+ units="${this.units}">
1552
+ ${this.showRotations ? `<span slot="append" class="fig-input-angle-rotations"></span>` : ""}
1553
+ </fig-input-number>`
1554
+ : ""
1555
+ }
1556
+ `;
1557
+ }
1558
+
1559
+ #getRotationCount() {
1560
+ const degrees = Math.abs(this.#toDegrees(this.angle));
1561
+ return Math.floor(degrees / 360);
1562
+ }
1563
+
1564
+ #updateRotationDisplay() {
1565
+ if (!this.rotationSpan) return;
1566
+ const rotations = this.#getRotationCount();
1567
+ if (rotations > 1) {
1568
+ this.rotationSpan.textContent = `\u00d7${rotations}`;
1569
+ this.rotationSpan.style.display = "";
1570
+ } else {
1571
+ this.rotationSpan.textContent = "";
1572
+ this.rotationSpan.style.display = "none";
1573
+ }
1574
+ }
1575
+
1576
+ #getStepForUnit() {
1577
+ switch (this.units) {
1578
+ case "rad":
1579
+ return 0.01;
1580
+ case "turn":
1581
+ return 0.001;
1582
+ default:
1583
+ return 0.1;
1584
+ }
1585
+ }
1586
+
1587
+ #toDegrees(value) {
1588
+ switch (this.units) {
1589
+ case "rad":
1590
+ return (value * 180) / Math.PI;
1591
+ case "turn":
1592
+ return value * 360;
1593
+ default:
1594
+ return value;
1595
+ }
1596
+ }
1597
+
1598
+ #fromDegrees(degrees) {
1599
+ switch (this.units) {
1600
+ case "rad":
1601
+ return (degrees * Math.PI) / 180;
1602
+ case "turn":
1603
+ return degrees / 360;
1604
+ default:
1605
+ return degrees;
1606
+ }
1607
+ }
1608
+
1609
+ #convertAngle(value, fromUnit, toUnit) {
1610
+ let degrees;
1611
+ switch (fromUnit) {
1612
+ case "rad":
1613
+ degrees = (value * 180) / Math.PI;
1614
+ break;
1615
+ case "turn":
1616
+ degrees = value * 360;
1617
+ break;
1618
+ default:
1619
+ degrees = value;
1620
+ }
1621
+ switch (toUnit) {
1622
+ case "rad":
1623
+ return (degrees * Math.PI) / 180;
1624
+ case "turn":
1625
+ return degrees / 360;
1626
+ default:
1627
+ return degrees;
1628
+ }
1629
+ }
1630
+
1631
+ #setupListeners() {
1632
+ this.handle = this.querySelector(".fig-input-angle-handle");
1633
+ this.plane = this.querySelector(".fig-input-angle-plane");
1634
+ this.angleInput = this.querySelector("fig-input-number[name='angle']");
1635
+ this.rotationSpan = this.querySelector(".fig-input-angle-rotations");
1636
+ this.#updateRotationDisplay();
1637
+ this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
1638
+ this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
1639
+ window.addEventListener("keydown", this.#boundHandleKeyDown);
1640
+ window.addEventListener("keyup", this.#boundHandleKeyUp);
1641
+ if (this.text && this.angleInput) {
1642
+ this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
1643
+ }
1644
+ this.addEventListener("change", this.#boundHandleRawChange, true);
1645
+ }
1646
+
1647
+ #cleanupListeners() {
1648
+ this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
1649
+ this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
1650
+ window.removeEventListener("keydown", this.#boundHandleKeyDown);
1651
+ window.removeEventListener("keyup", this.#boundHandleKeyUp);
1652
+ if (this.text && this.angleInput) {
1653
+ this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
1654
+ }
1655
+ this.removeEventListener("change", this.#boundHandleRawChange, true);
1656
+ }
1657
+
1658
+ #handleRawChange(e) {
1659
+ if (!e.target?.matches?.("input")) return;
1660
+ const raw = e.target.value;
1661
+ const match = raw.match(/^(-?\d*\.?\d+)\s*(turn|rad|deg|°)$/i);
1662
+ if (match) {
1663
+ const num = parseFloat(match[1]);
1664
+ let fromUnit = match[2].toLowerCase();
1665
+ if (fromUnit === "deg") fromUnit = "°";
1666
+ if (fromUnit !== this.units) {
1667
+ const converted = this.#convertAngle(num, fromUnit, this.units);
1668
+ e.target.value = String(converted);
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ #handleAngleInput(e) {
1674
+ e.stopPropagation();
1675
+ this.angle = Number(e.target.value);
1676
+ this.#calculateAdjacentAndOpposite();
1677
+ this.#syncHandlePosition();
1678
+ this.#updateRotationDisplay();
1679
+ this.#emitInputEvent();
1680
+ this.#emitChangeEvent();
1681
+ }
1682
+
1683
+ #calculateAdjacentAndOpposite() {
1684
+ const degrees = this.#toDegrees(this.angle);
1685
+ const radians = (degrees * Math.PI) / 180;
1686
+ this.#adjacent = Math.cos(radians);
1687
+ this.#opposite = Math.sin(radians);
1688
+ }
1689
+
1690
+ #snapToIncrement(angle) {
1691
+ if (!this.isShiftHeld) return angle;
1692
+ const increment = 45;
1693
+ return Math.round(angle / increment) * increment;
1694
+ }
1695
+
1696
+ #getRawAngle(e) {
1697
+ const rect = this.plane.getBoundingClientRect();
1698
+ const centerX = rect.left + rect.width / 2;
1699
+ const centerY = rect.top + rect.height / 2;
1700
+ const deltaX = e.clientX - centerX;
1701
+ const deltaY = e.clientY - centerY;
1702
+ return (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
1703
+ }
1704
+
1705
+ #updateAngle(e) {
1706
+ let rawAngle = this.#getRawAngle(e);
1707
+ let normalizedAngle = ((rawAngle % 360) + 360) % 360;
1708
+ normalizedAngle = this.#snapToIncrement(normalizedAngle);
1709
+
1710
+ const isBounded = this.min !== null || this.max !== null;
1711
+
1712
+ if (isBounded) {
1713
+ this.angle = this.#fromDegrees(normalizedAngle);
1714
+ } else {
1715
+ if (this.#prevRawAngle === null) {
1716
+ this.#prevRawAngle = normalizedAngle;
1717
+ const currentDeg = this.#toDegrees(this.angle);
1718
+ const currentMod = ((currentDeg % 360) + 360) % 360;
1719
+ let delta = normalizedAngle - currentMod;
1720
+ if (delta > 180) delta -= 360;
1721
+ if (delta < -180) delta += 360;
1722
+ this.angle += this.#fromDegrees(delta);
1723
+ } else {
1724
+ let delta = normalizedAngle - this.#prevRawAngle;
1725
+ if (delta > 180) delta -= 360;
1726
+ if (delta < -180) delta += 360;
1727
+ this.angle += this.#fromDegrees(delta);
1728
+ this.#prevRawAngle = normalizedAngle;
1729
+ }
1730
+ }
1731
+
1732
+ this.#calculateAdjacentAndOpposite();
1733
+
1734
+ this.#syncHandlePosition();
1735
+ if (this.text && this.angleInput) {
1736
+ this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
1737
+ }
1738
+ this.#updateRotationDisplay();
1739
+
1740
+ this.#emitInputEvent();
1741
+ }
1742
+
1743
+ #emitInputEvent() {
1744
+ this.dispatchEvent(
1745
+ new CustomEvent("input", {
1746
+ bubbles: true,
1747
+ cancelable: true,
1748
+ detail: { value: this.value, angle: this.angle },
1749
+ }),
1750
+ );
1751
+ }
1752
+
1753
+ #emitChangeEvent() {
1754
+ this.dispatchEvent(
1755
+ new CustomEvent("change", {
1756
+ bubbles: true,
1757
+ cancelable: true,
1758
+ detail: { value: this.value, angle: this.angle },
1759
+ }),
1760
+ );
1761
+ }
1762
+
1763
+ #syncHandlePosition() {
1764
+ if (this.handle) {
1765
+ const degrees = this.#toDegrees(this.angle);
1766
+ const radians = (degrees * Math.PI) / 180;
1767
+ const radius = this.plane.offsetWidth / 2 - this.handle.offsetWidth / 2;
1768
+ const x = Math.cos(radians) * radius;
1769
+ const y = Math.sin(radians) * radius;
1770
+ this.handle.style.transform = `translate(${x}px, ${y}px)`;
1771
+ }
1772
+ }
1773
+
1774
+ #handleMouseDown(e) {
1775
+ this.isDragging = true;
1776
+ this.#prevRawAngle = null;
1777
+ this.#updateAngle(e);
1778
+
1779
+ const handleMouseMove = (e) => {
1780
+ this.plane.classList.add("dragging");
1781
+ if (this.isDragging) this.#updateAngle(e);
1782
+ };
1783
+
1784
+ const handleMouseUp = () => {
1785
+ this.isDragging = false;
1786
+ this.#prevRawAngle = null;
1787
+ this.plane.classList.remove("dragging");
1788
+ window.removeEventListener("mousemove", handleMouseMove);
1789
+ window.removeEventListener("mouseup", handleMouseUp);
1790
+ this.#emitChangeEvent();
1791
+ };
1792
+
1793
+ window.addEventListener("mousemove", handleMouseMove);
1794
+ window.addEventListener("mouseup", handleMouseUp);
1795
+ }
1796
+
1797
+ #handleTouchStart(e) {
1798
+ e.preventDefault();
1799
+ this.isDragging = true;
1800
+ this.#prevRawAngle = null;
1801
+ this.#updateAngle(e.touches[0]);
1802
+
1803
+ const handleTouchMove = (e) => {
1804
+ this.plane.classList.add("dragging");
1805
+ if (this.isDragging) this.#updateAngle(e.touches[0]);
1806
+ };
1807
+
1808
+ const handleTouchEnd = () => {
1809
+ this.isDragging = false;
1810
+ this.#prevRawAngle = null;
1811
+ this.plane.classList.remove("dragging");
1812
+ window.removeEventListener("touchmove", handleTouchMove);
1813
+ window.removeEventListener("touchend", handleTouchEnd);
1814
+ this.#emitChangeEvent();
1815
+ };
1816
+
1817
+ window.addEventListener("touchmove", handleTouchMove);
1818
+ window.addEventListener("touchend", handleTouchEnd);
1819
+ }
1820
+
1821
+ #handleKeyDown(e) {
1822
+ if (e.key === "Shift") this.isShiftHeld = true;
1823
+ }
1824
+
1825
+ #handleKeyUp(e) {
1826
+ if (e.key === "Shift") this.isShiftHeld = false;
1827
+ }
1828
+
1829
+ focus() {
1830
+ this.plane?.focus();
1831
+ }
1832
+
1833
+ static get observedAttributes() {
1834
+ return [
1835
+ "value",
1836
+ "precision",
1837
+ "text",
1838
+ "min",
1839
+ "max",
1840
+ "units",
1841
+ "dial",
1842
+ "rotations",
1843
+ "show-rotations",
1844
+ ];
1845
+ }
1846
+
1847
+ get value() {
1848
+ return this.angle;
1849
+ }
1850
+
1851
+ get adjacent() {
1852
+ return this.#adjacent;
1853
+ }
1854
+
1855
+ get opposite() {
1856
+ return this.#opposite;
1857
+ }
1858
+
1859
+ set value(value) {
1860
+ if (isNaN(value)) {
1861
+ console.error("Invalid value: must be a number.");
1862
+ return;
1863
+ }
1864
+ this.angle = value;
1865
+ this.#calculateAdjacentAndOpposite();
1866
+ this.#syncHandlePosition();
1867
+ if (this.angleInput) {
1868
+ this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
1869
+ }
1870
+ this.#updateRotationDisplay();
1871
+ }
1872
+
1873
+ attributeChangedCallback(name, oldValue, newValue) {
1874
+ switch (name) {
1875
+ case "value":
1876
+ if (this.isDragging) break;
1877
+ this.value = Number(newValue);
1878
+ break;
1879
+ case "precision":
1880
+ this.precision = parseInt(newValue);
1881
+ break;
1882
+ case "text":
1883
+ if (newValue !== oldValue) {
1884
+ this.text = newValue?.toLowerCase() === "true";
1885
+ if (this.isConnected) {
1886
+ this.#render();
1887
+ this.#setupListeners();
1888
+ this.#syncHandlePosition();
1889
+ }
1890
+ }
1891
+ break;
1892
+ case "dial":
1893
+ this.dial = this.#readBooleanAttribute("dial", true);
1894
+ if (this.isConnected) {
1895
+ this.#render();
1896
+ this.#setupListeners();
1897
+ this.#syncHandlePosition();
1898
+ }
1899
+ break;
1900
+ case "units": {
1901
+ let units = newValue || "°";
1902
+ if (units === "deg") units = "°";
1903
+ this.units = units;
1904
+ if (this.isConnected) {
1905
+ this.#render();
1906
+ this.#setupListeners();
1907
+ this.#syncHandlePosition();
1908
+ }
1909
+ break;
1910
+ }
1911
+ case "min":
1912
+ this.min = newValue !== null ? Number(newValue) : null;
1913
+ if (this.isConnected) {
1914
+ this.#render();
1915
+ this.#setupListeners();
1916
+ this.#syncHandlePosition();
1917
+ }
1918
+ break;
1919
+ case "max":
1920
+ this.max = newValue !== null ? Number(newValue) : null;
1921
+ if (this.isConnected) {
1922
+ this.#render();
1923
+ this.#setupListeners();
1924
+ this.#syncHandlePosition();
1925
+ }
1926
+ break;
1927
+ case "rotations":
1928
+ case "show-rotations":
1929
+ this.showRotations = this.#readRotationsEnabled();
1930
+ if (this.isConnected) {
1931
+ this.#render();
1932
+ this.#setupListeners();
1933
+ this.#syncHandlePosition();
1934
+ }
1935
+ break;
1936
+ }
1937
+ }
1938
+ }
1939
+ customElements.define("fig-input-angle", FigInputAngle);
1940
+