@rogieking/figui3 3.16.0 → 3.18.0

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.js CHANGED
@@ -75,24 +75,31 @@ function figUniqueId() {
75
75
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
76
76
  }
77
77
 
78
- /**
79
- * Gets the highest z-index currently in use on the page
80
- * @returns {number} The highest z-index found, minimum of 1000
81
- */
78
+ let _figZCounter = 10000;
82
79
  function figGetHighestZIndex() {
83
- let highest = 1000; // Baseline minimum
80
+ return _figZCounter++;
81
+ }
84
82
 
85
- // Check all elements with inline z-index or computed z-index
86
- const elements = document.querySelectorAll("*");
87
- for (const el of elements) {
88
- const zIndex = parseInt(getComputedStyle(el).zIndex, 10);
89
- if (!isNaN(zIndex) && zIndex > highest) {
90
- highest = zIndex;
91
- }
83
+ function figSyncCssVar(el, prop, value) {
84
+ if (value && value.trim()) {
85
+ el.style.setProperty(prop, value.trim());
86
+ } else {
87
+ el.style.removeProperty(prop);
92
88
  }
89
+ }
93
90
 
94
- return highest;
91
+ let _figSharedCanvas = null;
92
+ let _figSharedCtx = null;
93
+ function figGetSharedCanvas(width = 1, height = 1) {
94
+ if (!_figSharedCanvas) {
95
+ _figSharedCanvas = document.createElement("canvas");
96
+ _figSharedCtx = _figSharedCanvas.getContext("2d");
97
+ }
98
+ if (_figSharedCanvas.width !== width) _figSharedCanvas.width = width;
99
+ if (_figSharedCanvas.height !== height) _figSharedCanvas.height = height;
100
+ return { canvas: _figSharedCanvas, ctx: _figSharedCtx };
95
101
  }
102
+
96
103
  /**
97
104
  * Checks if the browser supports the native popover API
98
105
  * @returns {boolean} True if popover is supported
@@ -253,6 +260,7 @@ class FigDropdown extends HTMLElement {
253
260
  set label(value) {
254
261
  this.#label = value;
255
262
  }
263
+ #boundSlotChange;
256
264
  constructor() {
257
265
  super();
258
266
  this.select = document.createElement("select");
@@ -260,6 +268,7 @@ class FigDropdown extends HTMLElement {
260
268
  this.attachShadow({ mode: "open" });
261
269
  this.#boundHandleSelectInput = this.#handleSelectInput.bind(this);
262
270
  this.#boundHandleSelectChange = this.#handleSelectChange.bind(this);
271
+ this.#boundSlotChange = this.slotChange.bind(this);
263
272
  }
264
273
 
265
274
  #supportsSelectedContent() {
@@ -342,7 +351,7 @@ class FigDropdown extends HTMLElement {
342
351
  this.appendChild(this.select);
343
352
  this.shadowRoot.appendChild(this.optionsSlot);
344
353
 
345
- this.optionsSlot.addEventListener("slotchange", this.slotChange.bind(this));
354
+ this.optionsSlot.addEventListener("slotchange", this.#boundSlotChange);
346
355
 
347
356
  this.#addEventListeners();
348
357
  }
@@ -478,6 +487,12 @@ class FigDropdown extends HTMLElement {
478
487
  this.select.setAttribute("aria-label", this.#label);
479
488
  }
480
489
  }
490
+
491
+ disconnectedCallback() {
492
+ this.optionsSlot.removeEventListener("slotchange", this.#boundSlotChange);
493
+ this.select.removeEventListener("input", this.#boundHandleSelectInput);
494
+ this.select.removeEventListener("change", this.#boundHandleSelectChange);
495
+ }
481
496
  }
482
497
 
483
498
  customElements.define("fig-dropdown", FigDropdown);
@@ -496,6 +511,12 @@ class FigTooltip extends HTMLElement {
496
511
 
497
512
  #boundHideOnChromeOpen;
498
513
  #boundHidePopupOutsideClick;
514
+ #boundShowDelayedPopup;
515
+ #boundHandlePointerLeave;
516
+ #boundHandleTouchStart;
517
+ #boundHandleTouchMove;
518
+ #boundHandleTouchEnd;
519
+ #boundHandleTouchCancel;
499
520
  #touchTimeout;
500
521
  #isTouching = false;
501
522
  #observer = null;
@@ -506,9 +527,14 @@ class FigTooltip extends HTMLElement {
506
527
  let delay = parseInt(this.getAttribute("delay"));
507
528
  this.delay = !isNaN(delay) ? delay : 500;
508
529
 
509
- // Bind methods that will be used as event listeners
510
530
  this.#boundHideOnChromeOpen = this.#hideOnChromeOpen.bind(this);
511
531
  this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
532
+ this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
533
+ this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this);
534
+ this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
535
+ this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
536
+ this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
537
+ this.#boundHandleTouchCancel = this.#handleTouchCancel.bind(this);
512
538
  }
513
539
  connectedCallback() {
514
540
  this.setup();
@@ -517,16 +543,13 @@ class FigTooltip extends HTMLElement {
517
543
 
518
544
  disconnectedCallback() {
519
545
  this.destroy();
520
- // Remove global listeners
521
546
  document.removeEventListener(
522
547
  "mousedown",
523
548
  this.#boundHideOnChromeOpen,
524
549
  true,
525
550
  );
526
- // Disconnect mutation observer
527
551
  this.#stopObserving();
528
552
 
529
- // Remove click outside listener for click action
530
553
  if (this.action === "click") {
531
554
  document.body.removeEventListener(
532
555
  "click",
@@ -534,15 +557,17 @@ class FigTooltip extends HTMLElement {
534
557
  );
535
558
  }
536
559
 
537
- // Clean up touch-related timers and listeners
538
560
  clearTimeout(this.#touchTimeout);
539
561
  if (this.action === "hover") {
540
- this.removeEventListener("touchstart", this.#handleTouchStart);
541
- this.removeEventListener("touchmove", this.#handleTouchMove);
542
- this.removeEventListener("touchend", this.#handleTouchEnd);
543
- this.removeEventListener("touchcancel", this.#handleTouchCancel);
562
+ this.removeEventListener("pointerenter", this.#boundShowDelayedPopup);
563
+ this.removeEventListener("pointerleave", this.#boundHandlePointerLeave);
564
+ this.removeEventListener("touchstart", this.#boundHandleTouchStart);
565
+ this.removeEventListener("touchmove", this.#boundHandleTouchMove);
566
+ this.removeEventListener("touchend", this.#boundHandleTouchEnd);
567
+ this.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
544
568
  } else if (this.action === "click") {
545
- this.removeEventListener("touchstart", this.showDelayedPopup);
569
+ this.removeEventListener("click", this.#boundShowDelayedPopup);
570
+ this.removeEventListener("touchstart", this.#boundShowDelayedPopup);
546
571
  }
547
572
  }
548
573
 
@@ -613,36 +638,29 @@ class FigTooltip extends HTMLElement {
613
638
  if (this.action === "manual") return;
614
639
  if (this.action === "hover") {
615
640
  if (!this.isTouchDevice()) {
616
- this.addEventListener("pointerenter", this.showDelayedPopup.bind(this));
617
- this.addEventListener(
618
- "pointerleave",
619
- this.#handlePointerLeave.bind(this),
620
- );
641
+ this.addEventListener("pointerenter", this.#boundShowDelayedPopup);
642
+ this.addEventListener("pointerleave", this.#boundHandlePointerLeave);
621
643
  }
622
- // Touch support for mobile hover simulation
623
- this.addEventListener("touchstart", this.#handleTouchStart.bind(this), {
644
+ this.addEventListener("touchstart", this.#boundHandleTouchStart, {
624
645
  passive: true,
625
646
  });
626
- this.addEventListener("touchmove", this.#handleTouchMove.bind(this), {
647
+ this.addEventListener("touchmove", this.#boundHandleTouchMove, {
627
648
  passive: true,
628
649
  });
629
- this.addEventListener("touchend", this.#handleTouchEnd.bind(this), {
650
+ this.addEventListener("touchend", this.#boundHandleTouchEnd, {
630
651
  passive: true,
631
652
  });
632
- this.addEventListener("touchcancel", this.#handleTouchCancel.bind(this), {
653
+ this.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
633
654
  passive: true,
634
655
  });
635
656
  } else if (this.action === "click") {
636
- this.addEventListener("click", this.showDelayedPopup.bind(this));
657
+ this.addEventListener("click", this.#boundShowDelayedPopup);
637
658
  document.body.addEventListener("click", this.#boundHidePopupOutsideClick);
638
-
639
- // Touch support for better mobile responsiveness
640
- this.addEventListener("touchstart", this.showDelayedPopup.bind(this), {
659
+ this.addEventListener("touchstart", this.#boundShowDelayedPopup, {
641
660
  passive: true,
642
661
  });
643
662
  }
644
663
 
645
- // Add listener for chrome interactions
646
664
  document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
647
665
  }
648
666
 
@@ -934,6 +952,7 @@ class FigDialog extends HTMLDialogElement {
934
952
  #boundPointerDown;
935
953
  #boundPointerMove;
936
954
  #boundPointerUp;
955
+ #boundClose;
937
956
  #offset = 16; // 1rem in pixels
938
957
  #positionInitialized = false;
939
958
  #dragThreshold = 3; // pixels before drag starts
@@ -943,6 +962,7 @@ class FigDialog extends HTMLDialogElement {
943
962
  this.#boundPointerDown = this.#handlePointerDown.bind(this);
944
963
  this.#boundPointerMove = this.#handlePointerMove.bind(this);
945
964
  this.#boundPointerUp = this.#handlePointerUp.bind(this);
965
+ this.#boundClose = this.close.bind(this);
946
966
  }
947
967
 
948
968
  connectedCallback() {
@@ -962,12 +982,15 @@ class FigDialog extends HTMLDialogElement {
962
982
 
963
983
  disconnectedCallback() {
964
984
  this.#removeDragListeners();
985
+ this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
986
+ button.removeEventListener("click", this.#boundClose);
987
+ });
965
988
  }
966
989
 
967
990
  #addCloseListeners() {
968
991
  this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
969
- button.removeEventListener("click", this.close);
970
- button.addEventListener("click", this.close.bind(this));
992
+ button.removeEventListener("click", this.#boundClose);
993
+ button.addEventListener("click", this.#boundClose);
971
994
  });
972
995
  }
973
996
 
@@ -2242,16 +2265,18 @@ figDefineCustomizedBuiltIn("fig-popup", FigPopup, { extends: "dialog" });
2242
2265
  */
2243
2266
  class FigTab extends HTMLElement {
2244
2267
  #selected;
2268
+ #boundHandleClick;
2245
2269
  constructor() {
2246
2270
  super();
2247
2271
  this.content = null;
2248
2272
  this.#selected = false;
2273
+ this.#boundHandleClick = this.handleClick.bind(this);
2249
2274
  }
2250
2275
  connectedCallback() {
2251
2276
  this.setAttribute("label", this.innerText);
2252
2277
  this.setAttribute("role", "tab");
2253
2278
  this.setAttribute("tabindex", "0");
2254
- this.addEventListener("click", this.handleClick.bind(this));
2279
+ this.addEventListener("click", this.#boundHandleClick);
2255
2280
 
2256
2281
  requestAnimationFrame(() => {
2257
2282
  if (typeof this.getAttribute("content") === "string") {
@@ -2276,7 +2301,7 @@ class FigTab extends HTMLElement {
2276
2301
  this.setAttribute("selected", value ? "true" : "false");
2277
2302
  }
2278
2303
  disconnectedCallback() {
2279
- this.removeEventListener("click", this.handleClick);
2304
+ this.removeEventListener("click", this.#boundHandleClick);
2280
2305
  }
2281
2306
  handleClick() {
2282
2307
  this.selected = true;
@@ -2305,8 +2330,12 @@ customElements.define("fig-tab", FigTab);
2305
2330
  * @attr {string} name - Identifier for the tabs group
2306
2331
  */
2307
2332
  class FigTabs extends HTMLElement {
2333
+ #boundHandleClick;
2334
+ #boundHandleKeyDown;
2308
2335
  constructor() {
2309
2336
  super();
2337
+ this.#boundHandleClick = this.handleClick.bind(this);
2338
+ this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
2310
2339
  }
2311
2340
 
2312
2341
  static get observedAttributes() {
@@ -2316,8 +2345,8 @@ class FigTabs extends HTMLElement {
2316
2345
  connectedCallback() {
2317
2346
  this.name = this.getAttribute("name") || "tabs";
2318
2347
  this.setAttribute("role", "tablist");
2319
- this.addEventListener("click", this.handleClick.bind(this));
2320
- this.addEventListener("keydown", this.#handleKeyDown.bind(this));
2348
+ this.addEventListener("click", this.#boundHandleClick);
2349
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
2321
2350
  requestAnimationFrame(() => {
2322
2351
  const value = this.getAttribute("value");
2323
2352
  if (value) {
@@ -2345,8 +2374,8 @@ class FigTabs extends HTMLElement {
2345
2374
  }
2346
2375
 
2347
2376
  disconnectedCallback() {
2348
- this.removeEventListener("click", this.handleClick);
2349
- this.removeEventListener("keydown", this.#handleKeyDown);
2377
+ this.removeEventListener("click", this.#boundHandleClick);
2378
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
2350
2379
  }
2351
2380
 
2352
2381
  #handleKeyDown(event) {
@@ -2446,14 +2475,16 @@ customElements.define("fig-tabs", FigTabs);
2446
2475
  class FigSegment extends HTMLElement {
2447
2476
  #value;
2448
2477
  #selected;
2478
+ #boundHandleClick;
2449
2479
  constructor() {
2450
2480
  super();
2481
+ this.#boundHandleClick = this.handleClick.bind(this);
2451
2482
  }
2452
2483
  connectedCallback() {
2453
- this.addEventListener("click", this.handleClick.bind(this));
2484
+ this.addEventListener("click", this.#boundHandleClick);
2454
2485
  }
2455
2486
  disconnectedCallback() {
2456
- this.removeEventListener("click", this.handleClick);
2487
+ this.removeEventListener("click", this.#boundHandleClick);
2457
2488
  }
2458
2489
  handleClick() {
2459
2490
  const parentControl = this.closest("fig-segmented-control");
@@ -5084,6 +5115,12 @@ class FigInputFill extends HTMLElement {
5084
5115
  this.#render();
5085
5116
  }
5086
5117
 
5118
+ disconnectedCallback() {
5119
+ this.#fillPicker = null;
5120
+ this.#opacityInput = null;
5121
+ this.#hexInput = null;
5122
+ }
5123
+
5087
5124
  #parseValue() {
5088
5125
  const valueAttr = this.getAttribute("value");
5089
5126
  if (!valueAttr) return;
@@ -6074,6 +6111,16 @@ class FigInputGradient extends HTMLElement {
6074
6111
 
6075
6112
  disconnectedCallback() {
6076
6113
  document.removeEventListener("keydown", this.#onKeyDown);
6114
+ if (this.#colorObserver) {
6115
+ this.#colorObserver.disconnect();
6116
+ this.#colorObserver = null;
6117
+ }
6118
+ clearTimeout(this.#arrowTooltipTimer);
6119
+ this.removeEventListener("pointerenter", this.#onTrackEnter);
6120
+ this.removeEventListener("pointermove", this.#onTrackMove);
6121
+ this.removeEventListener("pointerleave", this.#onTrackLeave);
6122
+ this.removeEventListener("click", this.#onTrackClick);
6123
+ this.removeEventListener("dblclick", this.#onTrackDblClick);
6077
6124
  }
6078
6125
 
6079
6126
  #onKeyDown = (e) => {
@@ -6208,10 +6255,8 @@ class FigInputGradient extends HTMLElement {
6208
6255
  }
6209
6256
 
6210
6257
  #sampleGradientColor(position) {
6211
- const canvas = document.createElement("canvas");
6212
- canvas.width = 256;
6213
- canvas.height = 1;
6214
- const ctx = canvas.getContext("2d");
6258
+ const { ctx } = figGetSharedCanvas(256, 1);
6259
+ ctx.clearRect(0, 0, 256, 1);
6215
6260
  const grad = ctx.createLinearGradient(0, 0, 256, 0);
6216
6261
  for (const stop of this.#gradient.stops) {
6217
6262
  try {
@@ -6450,6 +6495,44 @@ class FigInputGradient extends HTMLElement {
6450
6495
  #setupEventListeners() {
6451
6496
  if (!this.#track) return;
6452
6497
 
6498
+ this.#track.addEventListener("pointerdown", (e) => {
6499
+ if (this.hasAttribute("disabled")) return;
6500
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
6501
+ if (e.button !== 0) return;
6502
+ e.preventDefault();
6503
+
6504
+ const trackRect = this.#track.getBoundingClientRect();
6505
+ const pct = Math.max(0, Math.min(1, (e.clientX - trackRect.left) / trackRect.width));
6506
+ const position = Math.round(pct * 100);
6507
+ const color = this.#sampleGradientColor(pct);
6508
+ this.#gradient.stops.push({ position, color, opacity: 100 });
6509
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
6510
+ const newIndex = this.#gradient.stops.findIndex(
6511
+ (s) => s.position === position && s.color === color,
6512
+ );
6513
+ this.#syncHandles();
6514
+ this.#syncChit();
6515
+ this.#emitInput();
6516
+ this.#hideGhost();
6517
+
6518
+ const handles = this.#track.querySelectorAll(
6519
+ "fig-handle:not(.fig-input-gradient-ghost)",
6520
+ );
6521
+ const newHandle = handles[newIndex];
6522
+ if (newHandle) {
6523
+ newHandle.select();
6524
+ newHandle.dispatchEvent(new PointerEvent("pointerdown", {
6525
+ bubbles: true,
6526
+ clientX: e.clientX,
6527
+ clientY: e.clientY,
6528
+ pointerId: e.pointerId,
6529
+ pointerType: e.pointerType,
6530
+ button: e.button,
6531
+ buttons: e.buttons,
6532
+ }));
6533
+ }
6534
+ });
6535
+
6453
6536
  this.#track.addEventListener("input", (e) => {
6454
6537
  const handle = e.target.closest("fig-handle");
6455
6538
  if (!handle) return;
@@ -6851,9 +6934,11 @@ customElements.define("fig-switch", FigSwitch);
6851
6934
  class FigToast extends HTMLDialogElement {
6852
6935
  _defaultOffset = 16; // 1rem in pixels
6853
6936
  _autoCloseTimer = null;
6937
+ #boundHandleClose;
6854
6938
 
6855
6939
  constructor() {
6856
6940
  super();
6941
+ this.#boundHandleClose = this.handleClose.bind(this);
6857
6942
  }
6858
6943
 
6859
6944
  getOffset() {
@@ -6903,8 +6988,8 @@ class FigToast extends HTMLDialogElement {
6903
6988
 
6904
6989
  addCloseListeners() {
6905
6990
  this.querySelectorAll("[close-toast]").forEach((button) => {
6906
- button.removeEventListener("click", this.handleClose);
6907
- button.addEventListener("click", this.handleClose.bind(this));
6991
+ button.removeEventListener("click", this.#boundHandleClose);
6992
+ button.addEventListener("click", this.#boundHandleClose);
6908
6993
  });
6909
6994
  }
6910
6995
 
@@ -7191,12 +7276,10 @@ class FigChit extends HTMLElement {
7191
7276
  }
7192
7277
 
7193
7278
  #toHex(color) {
7194
- // Convert color to hex for the native input
7195
7279
  if (!color) return "#D9D9D9";
7196
7280
  if (color.startsWith("#")) return color.slice(0, 7);
7197
- // Use canvas to convert rgba/named colors to hex
7198
7281
  try {
7199
- const ctx = document.createElement("canvas").getContext("2d");
7282
+ const { ctx } = figGetSharedCanvas(1, 1);
7200
7283
  ctx.fillStyle = color;
7201
7284
  return ctx.fillStyle;
7202
7285
  } catch {
@@ -7665,7 +7748,7 @@ class FigEasingCurve extends HTMLElement {
7665
7748
 
7666
7749
  connectedCallback() {
7667
7750
  this.#precision = parseInt(this.getAttribute("precision") || "2");
7668
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
7751
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
7669
7752
  const val = this.getAttribute("value");
7670
7753
  if (val) this.#parseValue(val);
7671
7754
  this.#presetName = this.#matchPreset();
@@ -7681,17 +7764,9 @@ class FigEasingCurve extends HTMLElement {
7681
7764
  }
7682
7765
  }
7683
7766
 
7684
- #syncAspectRatioVar(value) {
7685
- if (value && value.trim()) {
7686
- this.style.setProperty("--aspect-ratio", value.trim());
7687
- } else {
7688
- this.style.removeProperty("--aspect-ratio");
7689
- }
7690
- }
7691
-
7692
7767
  attributeChangedCallback(name, oldValue, newValue) {
7693
7768
  if (name === "aspect-ratio") {
7694
- this.#syncAspectRatioVar(newValue);
7769
+ figSyncCssVar(this, "--aspect-ratio", newValue);
7695
7770
  if (this.#svg) {
7696
7771
  this.#syncViewportSize();
7697
7772
  this.#updatePaths();
@@ -8486,12 +8561,9 @@ class Fig3DRotate extends HTMLElement {
8486
8561
 
8487
8562
  connectedCallback() {
8488
8563
  this.#precision = parseInt(this.getAttribute("precision") || "1");
8489
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
8490
- this.#syncPerspectiveVar(this.getAttribute("perspective"));
8491
- this.#syncCSSVar(
8492
- "--perspective-origin",
8493
- this.getAttribute("perspective-origin"),
8494
- );
8564
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
8565
+ figSyncCssVar(this, "--perspective", this.getAttribute("perspective"));
8566
+ figSyncCssVar(this, "--perspective-origin", this.getAttribute("perspective-origin"));
8495
8567
  this.#syncTransformOrigin(this.getAttribute("transform-origin"));
8496
8568
  this.#parseFields(this.getAttribute("fields"));
8497
8569
  const val = this.getAttribute("value");
@@ -8509,29 +8581,7 @@ class Fig3DRotate extends HTMLElement {
8509
8581
  }
8510
8582
  }
8511
8583
 
8512
- #syncAspectRatioVar(value) {
8513
- if (value && value.trim()) {
8514
- this.style.setProperty("--aspect-ratio", value.trim());
8515
- } else {
8516
- this.style.removeProperty("--aspect-ratio");
8517
- }
8518
- }
8519
-
8520
- #syncPerspectiveVar(value) {
8521
- if (value && value.trim()) {
8522
- this.style.setProperty("--perspective", value.trim());
8523
- } else {
8524
- this.style.removeProperty("--perspective");
8525
- }
8526
- }
8527
-
8528
- #syncCSSVar(prop, value) {
8529
- if (value && value.trim()) {
8530
- this.style.setProperty(prop, value.trim());
8531
- } else {
8532
- this.style.removeProperty(prop);
8533
- }
8534
- }
8584
+
8535
8585
 
8536
8586
  #syncTransformOrigin(value) {
8537
8587
  if (!value || !value.trim()) {
@@ -8580,15 +8630,15 @@ class Fig3DRotate extends HTMLElement {
8580
8630
 
8581
8631
  attributeChangedCallback(name, oldValue, newValue) {
8582
8632
  if (name === "aspect-ratio") {
8583
- this.#syncAspectRatioVar(newValue);
8633
+ figSyncCssVar(this, "--aspect-ratio", newValue);
8584
8634
  return;
8585
8635
  }
8586
8636
  if (name === "perspective") {
8587
- this.#syncPerspectiveVar(newValue);
8637
+ figSyncCssVar(this, "--perspective", newValue);
8588
8638
  return;
8589
8639
  }
8590
8640
  if (name === "perspective-origin") {
8591
- this.#syncCSSVar("--perspective-origin", newValue);
8641
+ figSyncCssVar(this, "--perspective-origin", newValue);
8592
8642
  return;
8593
8643
  }
8594
8644
  if (name === "transform-origin") {
@@ -8838,7 +8888,7 @@ class FigOriginGrid extends HTMLElement {
8838
8888
 
8839
8889
  connectedCallback() {
8840
8890
  this.#precision = parseInt(this.getAttribute("precision") || "0");
8841
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
8891
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
8842
8892
  this.#applyIncomingValue(this.getAttribute("value"));
8843
8893
 
8844
8894
  this.#render();
@@ -8862,7 +8912,7 @@ class FigOriginGrid extends HTMLElement {
8862
8912
 
8863
8913
  attributeChangedCallback(name, oldValue, newValue) {
8864
8914
  if (name === "aspect-ratio") {
8865
- this.#syncAspectRatioVar(newValue);
8915
+ figSyncCssVar(this, "--aspect-ratio", newValue);
8866
8916
  return;
8867
8917
  }
8868
8918
  if (name === "drag") {
@@ -8901,14 +8951,6 @@ class FigOriginGrid extends HTMLElement {
8901
8951
  return attr.toLowerCase() !== "false";
8902
8952
  }
8903
8953
 
8904
- #syncAspectRatioVar(value) {
8905
- if (value && value.trim()) {
8906
- this.style.setProperty("--aspect-ratio", value.trim());
8907
- } else {
8908
- this.style.removeProperty("--aspect-ratio");
8909
- }
8910
- }
8911
-
8912
8954
  #syncDragState() {
8913
8955
  if (!this.#grid) return;
8914
8956
  this.#grid.classList.toggle("drag-disabled", !this.#dragEnabled);
@@ -9292,6 +9334,7 @@ class FigInputJoystick extends HTMLElement {
9292
9334
  #boundYFocusOut = null;
9293
9335
  #isSyncingValueAttr = false;
9294
9336
  #defaultPosition = { x: 0.5, y: 0.5 };
9337
+ #initialized = false;
9295
9338
 
9296
9339
  constructor() {
9297
9340
  super();
@@ -9303,7 +9346,6 @@ class FigInputJoystick extends HTMLElement {
9303
9346
  this.xInput = null;
9304
9347
  this.yInput = null;
9305
9348
  this.coordinates = "screen";
9306
- this.#initialized = false;
9307
9349
  this.#boundPlanePointerDown = (e) => this.#handlePlanePointerDown(e);
9308
9350
  this.#boundHandlePointerDown = () => {
9309
9351
  this.isDragging = true;
@@ -9317,8 +9359,6 @@ class FigInputJoystick extends HTMLElement {
9317
9359
  this.#boundYFocusOut = () => this.#handleFieldFocusOut();
9318
9360
  }
9319
9361
 
9320
- #initialized = false;
9321
-
9322
9362
  connectedCallback() {
9323
9363
  // Initialize position
9324
9364
  requestAnimationFrame(() => {
@@ -9327,7 +9367,7 @@ class FigInputJoystick extends HTMLElement {
9327
9367
  this.transform = this.getAttribute("transform") || 1;
9328
9368
  this.transform = Number(this.transform);
9329
9369
  this.coordinates = this.getAttribute("coordinates") || "screen";
9330
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
9370
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
9331
9371
  if (!this.hasAttribute("value")) {
9332
9372
  this.setAttribute("value", "50% 50%");
9333
9373
  }
@@ -9346,14 +9386,6 @@ class FigInputJoystick extends HTMLElement {
9346
9386
  return this.coordinates === "math" ? 1 - y : y;
9347
9387
  }
9348
9388
 
9349
- #syncAspectRatioVar(value) {
9350
- if (value && value.trim()) {
9351
- this.style.setProperty("--aspect-ratio", value.trim());
9352
- } else {
9353
- this.style.removeProperty("--aspect-ratio");
9354
- }
9355
- }
9356
-
9357
9389
  disconnectedCallback() {
9358
9390
  this.#cleanupListeners();
9359
9391
  }
@@ -9655,7 +9687,7 @@ class FigInputJoystick extends HTMLElement {
9655
9687
  }
9656
9688
  attributeChangedCallback(name, oldValue, newValue) {
9657
9689
  if (name === "aspect-ratio") {
9658
- this.#syncAspectRatioVar(newValue);
9690
+ figSyncCssVar(this, "--aspect-ratio", newValue);
9659
9691
  return;
9660
9692
  }
9661
9693
  if (name === "value") {
@@ -9705,6 +9737,11 @@ class FigInputAngle extends HTMLElement {
9705
9737
  #opposite;
9706
9738
  #prevRawAngle = null;
9707
9739
  #boundHandleRawChange;
9740
+ #boundHandleMouseDown;
9741
+ #boundHandleTouchStart;
9742
+ #boundHandleKeyDown;
9743
+ #boundHandleKeyUp;
9744
+ #boundHandleAngleInput;
9708
9745
 
9709
9746
  constructor() {
9710
9747
  super();
@@ -9725,6 +9762,11 @@ class FigInputAngle extends HTMLElement {
9725
9762
  this.rotationSpan = null;
9726
9763
 
9727
9764
  this.#boundHandleRawChange = this.#handleRawChange.bind(this);
9765
+ this.#boundHandleMouseDown = this.#handleMouseDown.bind(this);
9766
+ this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
9767
+ this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
9768
+ this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
9769
+ this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
9728
9770
  }
9729
9771
 
9730
9772
  connectedCallback() {
@@ -9899,30 +9941,23 @@ class FigInputAngle extends HTMLElement {
9899
9941
  this.angleInput = this.querySelector("fig-input-number[name='angle']");
9900
9942
  this.rotationSpan = this.querySelector(".fig-input-angle-rotations");
9901
9943
  this.#updateRotationDisplay();
9902
- this.plane?.addEventListener("mousedown", this.#handleMouseDown.bind(this));
9903
- this.plane?.addEventListener(
9904
- "touchstart",
9905
- this.#handleTouchStart.bind(this),
9906
- );
9907
- window.addEventListener("keydown", this.#handleKeyDown.bind(this));
9908
- window.addEventListener("keyup", this.#handleKeyUp.bind(this));
9944
+ this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
9945
+ this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
9946
+ window.addEventListener("keydown", this.#boundHandleKeyDown);
9947
+ window.addEventListener("keyup", this.#boundHandleKeyUp);
9909
9948
  if (this.text && this.angleInput) {
9910
- this.angleInput.addEventListener(
9911
- "input",
9912
- this.#handleAngleInput.bind(this),
9913
- );
9949
+ this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
9914
9950
  }
9915
- // Capture-phase listener for unit suffix parsing
9916
9951
  this.addEventListener("change", this.#boundHandleRawChange, true);
9917
9952
  }
9918
9953
 
9919
9954
  #cleanupListeners() {
9920
- this.plane?.removeEventListener("mousedown", this.#handleMouseDown);
9921
- this.plane?.removeEventListener("touchstart", this.#handleTouchStart);
9922
- window.removeEventListener("keydown", this.#handleKeyDown);
9923
- window.removeEventListener("keyup", this.#handleKeyUp);
9955
+ this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
9956
+ this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
9957
+ window.removeEventListener("keydown", this.#boundHandleKeyDown);
9958
+ window.removeEventListener("keyup", this.#boundHandleKeyUp);
9924
9959
  if (this.text && this.angleInput) {
9925
- this.angleInput.removeEventListener("input", this.#handleAngleInput);
9960
+ this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
9926
9961
  }
9927
9962
  this.removeEventListener("change", this.#boundHandleRawChange, true);
9928
9963
  }
@@ -10427,6 +10462,8 @@ class FigFillPicker extends HTMLElement {
10427
10462
  #opacitySlider = null;
10428
10463
  #isDraggingColor = false;
10429
10464
  #teardownColorAreaEvents = null;
10465
+ #dialogOpenObserver = null;
10466
+ #webcamTabObserver = null;
10430
10467
 
10431
10468
  constructor() {
10432
10469
  super();
@@ -10452,10 +10489,26 @@ class FigFillPicker extends HTMLElement {
10452
10489
  this.#teardownColorAreaEvents();
10453
10490
  this.#teardownColorAreaEvents = null;
10454
10491
  }
10492
+ if (this.#dialogOpenObserver) {
10493
+ this.#dialogOpenObserver.disconnect();
10494
+ this.#dialogOpenObserver = null;
10495
+ }
10496
+ if (this.#webcamTabObserver) {
10497
+ this.#webcamTabObserver.disconnect();
10498
+ this.#webcamTabObserver = null;
10499
+ }
10500
+ if (this.#webcam.stream) {
10501
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
10502
+ this.#webcam.stream = null;
10503
+ }
10504
+ if (this.#video.url && this.#video.url.startsWith("blob:")) {
10505
+ URL.revokeObjectURL(this.#video.url);
10506
+ }
10455
10507
  if (this.#chit) this.#chit.removeAttribute("selected");
10456
10508
  if (this.#dialog) {
10457
10509
  this.#dialog.close();
10458
10510
  this.#dialog.remove();
10511
+ this.#dialog = null;
10459
10512
  }
10460
10513
  }
10461
10514
 
@@ -10792,11 +10845,11 @@ class FigFillPicker extends HTMLElement {
10792
10845
  };
10793
10846
  this.#dialog.addEventListener("close", onDialogClose);
10794
10847
 
10795
- const observer = new MutationObserver(() => {
10848
+ this.#dialogOpenObserver = new MutationObserver(() => {
10796
10849
  const isOpen = this.#dialog.hasAttribute("open") && this.#dialog.getAttribute("open") !== "false";
10797
10850
  if (!isOpen) onDialogClose();
10798
10851
  });
10799
- observer.observe(this.#dialog, { attributes: true, attributeFilter: ["open"] });
10852
+ this.#dialogOpenObserver.observe(this.#dialog, { attributes: true, attributeFilter: ["open"] });
10800
10853
 
10801
10854
  // Initialize built-in tabs (skip any overridden by custom slots)
10802
10855
  const builtinInits = {
@@ -11718,10 +11771,15 @@ class FigFillPicker extends HTMLElement {
11718
11771
  }
11719
11772
  }
11720
11773
 
11774
+ static #gradientSupportCache = new Map();
11721
11775
  #testGradientSupport(css) {
11776
+ const cached = FigFillPicker.#gradientSupportCache.get(css);
11777
+ if (cached !== undefined) return cached;
11722
11778
  const el = document.createElement("div");
11723
11779
  el.style.background = css;
11724
- return !!el.style.background;
11780
+ const result = !!el.style.background;
11781
+ FigFillPicker.#gradientSupportCache.set(css, result);
11782
+ return result;
11725
11783
  }
11726
11784
 
11727
11785
  #getGradientCSS() {
@@ -12066,13 +12124,12 @@ class FigFillPicker extends HTMLElement {
12066
12124
  }
12067
12125
  };
12068
12126
 
12069
- // Start webcam when tab is shown
12070
- const observer = new MutationObserver(() => {
12127
+ this.#webcamTabObserver = new MutationObserver(() => {
12071
12128
  if (container.style.display !== "none" && !this.#webcam.stream) {
12072
12129
  startWebcam();
12073
12130
  }
12074
12131
  });
12075
- observer.observe(container, {
12132
+ this.#webcamTabObserver.observe(container, {
12076
12133
  attributes: true,
12077
12134
  attributeFilter: ["style"],
12078
12135
  });
@@ -12591,8 +12648,8 @@ class FigColorTip extends HTMLElement {
12591
12648
  }
12592
12649
 
12593
12650
  try {
12594
- const ctx = document.createElement("canvas").getContext("2d");
12595
- if (!ctx) return "#D9D9D9";
12651
+ const { ctx } = figGetSharedCanvas(1, 1);
12652
+ ctx.fillStyle = "#000000";
12596
12653
  ctx.fillStyle = value;
12597
12654
  const resolved = ctx.fillStyle;
12598
12655
  if (resolved.startsWith("#")) {
@@ -13286,6 +13343,640 @@ class FigChooser extends HTMLElement {
13286
13343
  }
13287
13344
  customElements.define("fig-chooser", FigChooser);
13288
13345
 
13346
+ /* Canvas Point */
13347
+ class FigCanvasPoint extends HTMLElement {
13348
+ static observedAttributes = [
13349
+ "type",
13350
+ "value",
13351
+ "color",
13352
+ "name",
13353
+ "tooltips",
13354
+ "disabled",
13355
+ "drag-surface",
13356
+ "snapping",
13357
+ ];
13358
+
13359
+ #x = 50;
13360
+ #y = 50;
13361
+ #radius = 0;
13362
+ #radiusIsPercent = false;
13363
+ #angle = 0;
13364
+ #pointHandle = null;
13365
+ #angleHandle = null;
13366
+ #radiusSvg = null;
13367
+ #angleSvg = null;
13368
+ #pointTooltip = null;
13369
+ #radiusTooltip = null;
13370
+ #angleTooltip = null;
13371
+ #isDragging = false;
13372
+ #isRadiusDragging = false;
13373
+ #isAngleDragging = false;
13374
+
13375
+ get #type() {
13376
+ return this.getAttribute("type") || "point";
13377
+ }
13378
+
13379
+ get #hasRadius() {
13380
+ return this.#type === "point-radius" || this.#type === "point-radius-angle";
13381
+ }
13382
+
13383
+ get #hasAngle() {
13384
+ return this.#type === "point-radius-angle";
13385
+ }
13386
+
13387
+ get #tooltipsEnabled() {
13388
+ const v = this.getAttribute("tooltips");
13389
+ return v === null || v !== "false";
13390
+ }
13391
+
13392
+ get #snappingMode() {
13393
+ const raw = this.getAttribute("snapping");
13394
+ if (raw === null) return "false";
13395
+ const n = raw.trim().toLowerCase();
13396
+ if (n === "modifier") return "modifier";
13397
+ if (n === "" || n === "true") return "true";
13398
+ return "false";
13399
+ }
13400
+
13401
+ #shouldSnap(shiftKey) {
13402
+ const mode = this.#snappingMode;
13403
+ if (mode === "true") return true;
13404
+ if (mode === "modifier") return !!shiftKey;
13405
+ return false;
13406
+ }
13407
+
13408
+ get #pointTipText() {
13409
+ const name = this.getAttribute("name");
13410
+ if (name) return name;
13411
+ return `${Math.round(this.#x)}%, ${Math.round(this.#y)}%`;
13412
+ }
13413
+
13414
+ get #dragSurface() {
13415
+ return this.getAttribute("drag-surface") || "parent";
13416
+ }
13417
+
13418
+ get #container() {
13419
+ const surface = this.#dragSurface;
13420
+ if (surface === "parent") return this.parentElement;
13421
+ return this.closest(surface);
13422
+ }
13423
+
13424
+ get #handleDragSurface() {
13425
+ const surface = this.#dragSurface;
13426
+ if (surface === "parent") {
13427
+ const container = this.parentElement;
13428
+ if (container) {
13429
+ container.setAttribute("data-fig-canvas-point-surface", "");
13430
+ return "[data-fig-canvas-point-surface]";
13431
+ }
13432
+ }
13433
+ return surface;
13434
+ }
13435
+
13436
+ #resolveRadius(containerWidth) {
13437
+ if (this.#radiusIsPercent) return (this.#radius / 100) * containerWidth;
13438
+ return this.#radius;
13439
+ }
13440
+
13441
+ #formatRadius() {
13442
+ if (this.#radiusIsPercent) return `${Math.round(this.#radius)}%`;
13443
+ return `${Math.round(this.#radius)}px`;
13444
+ }
13445
+
13446
+ connectedCallback() {
13447
+ this.#parseValue();
13448
+ this.#render();
13449
+ }
13450
+
13451
+ disconnectedCallback() {
13452
+ this.#teardownRadiusDrag();
13453
+ }
13454
+
13455
+ attributeChangedCallback(name, oldVal, newVal) {
13456
+ if (oldVal === newVal) return;
13457
+ if (name === "value" && !this.#isDragging && !this.#isRadiusDragging && !this.#isAngleDragging) {
13458
+ this.#parseValue();
13459
+ if (this.#pointHandle) this.#syncPositions();
13460
+ else this.#render();
13461
+ }
13462
+ if (name === "type") {
13463
+ this.#parseValue();
13464
+ this.#render();
13465
+ }
13466
+ if (name === "color" && this.#pointHandle) {
13467
+ if (newVal) this.#pointHandle.setAttribute("color", newVal);
13468
+ else this.#pointHandle.removeAttribute("color");
13469
+ }
13470
+ if (name === "disabled") {
13471
+ this.#render();
13472
+ }
13473
+ if (name === "tooltips") {
13474
+ this.#render();
13475
+ }
13476
+ if (name === "snapping" && this.#pointHandle) {
13477
+ this.#pointHandle.setAttribute("drag-snapping", newVal || "false");
13478
+ }
13479
+ if (name === "name" && this.#pointTooltip) {
13480
+ this.#pointTooltip.setAttribute("text", this.#pointTipText);
13481
+ }
13482
+ }
13483
+
13484
+ #parseValue() {
13485
+ const raw = this.getAttribute("value");
13486
+ if (!raw) return;
13487
+ try {
13488
+ const v = JSON.parse(raw);
13489
+ if (typeof v.x === "number") this.#x = v.x;
13490
+ if (typeof v.y === "number") this.#y = v.y;
13491
+ if (v.radius !== undefined) {
13492
+ const rs = String(v.radius);
13493
+ if (rs.endsWith("%")) {
13494
+ this.#radiusIsPercent = true;
13495
+ this.#radius = parseFloat(rs);
13496
+ } else {
13497
+ this.#radiusIsPercent = false;
13498
+ this.#radius = parseFloat(rs);
13499
+ }
13500
+ if (!Number.isFinite(this.#radius)) this.#radius = 0;
13501
+ }
13502
+ if (typeof v.angle === "number") this.#angle = v.angle;
13503
+ } catch { /* ignore */ }
13504
+ }
13505
+
13506
+ get value() {
13507
+ const v = { x: this.#x, y: this.#y };
13508
+ if (this.#hasRadius) {
13509
+ v.radius = this.#radiusIsPercent ? `${this.#radius}%` : this.#radius;
13510
+ }
13511
+ if (this.#hasAngle) v.angle = this.#angle;
13512
+ return v;
13513
+ }
13514
+
13515
+ set value(val) {
13516
+ if (typeof val === "object") {
13517
+ this.setAttribute("value", JSON.stringify(val));
13518
+ } else if (typeof val === "string") {
13519
+ this.setAttribute("value", val);
13520
+ }
13521
+ }
13522
+
13523
+ #render() {
13524
+ this.innerHTML = "";
13525
+ this.#pointHandle = null;
13526
+ this.#angleHandle = null;
13527
+ this.#radiusSvg = null;
13528
+ this.#angleSvg = null;
13529
+ this.#pointTooltip = null;
13530
+ this.#radiusTooltip = null;
13531
+ this.#angleTooltip = null;
13532
+
13533
+ const disabled = this.hasAttribute("disabled");
13534
+ const type = this.#type;
13535
+ const tooltips = this.#tooltipsEnabled;
13536
+
13537
+ const handleSurface = this.#handleDragSurface;
13538
+
13539
+ const handle = document.createElement("fig-handle");
13540
+ handle.setAttribute("drag", "true");
13541
+ handle.setAttribute("drag-surface", handleSurface);
13542
+ handle.setAttribute("drag-axes", "x,y");
13543
+ handle.setAttribute("drag-snapping", this.#snappingMode);
13544
+ handle.setAttribute("value", `${this.#x}% ${this.#y}%`);
13545
+ if (disabled) handle.setAttribute("disabled", "");
13546
+ if (type === "color") {
13547
+ handle.setAttribute("type", "color");
13548
+ const color = this.getAttribute("color");
13549
+ if (color) handle.setAttribute("color", color);
13550
+ }
13551
+ this.#pointHandle = handle;
13552
+
13553
+ if (this.#hasRadius) {
13554
+ this.#createRadiusSvg();
13555
+ }
13556
+
13557
+ if (this.#hasAngle) {
13558
+ this.#createAngleSvg();
13559
+ }
13560
+
13561
+ if (tooltips) {
13562
+ const tip = document.createElement("fig-tooltip");
13563
+ tip.setAttribute("action", "manual");
13564
+ tip.setAttribute("text", this.#pointTipText);
13565
+ tip.appendChild(handle);
13566
+ this.appendChild(tip);
13567
+ this.#pointTooltip = tip;
13568
+ } else {
13569
+ this.appendChild(handle);
13570
+ }
13571
+
13572
+ if (this.#hasAngle) {
13573
+ this.#createAngleHandle(disabled, tooltips, handleSurface);
13574
+ }
13575
+
13576
+ this.#setupEventListeners();
13577
+ requestAnimationFrame(() => this.#syncPositions());
13578
+ }
13579
+
13580
+ #createRadiusSvg() {
13581
+ const ns = "http://www.w3.org/2000/svg";
13582
+ const svg = document.createElementNS(ns, "svg");
13583
+ svg.classList.add("fig-canvas-point-radius");
13584
+ svg.setAttribute("overflow", "visible");
13585
+ const hitCircle = document.createElementNS(ns, "circle");
13586
+ hitCircle.classList.add("fig-canvas-point-radius-hit");
13587
+ svg.appendChild(hitCircle);
13588
+ const circle = document.createElementNS(ns, "circle");
13589
+ svg.appendChild(circle);
13590
+ this.#radiusSvg = svg;
13591
+
13592
+ if (this.#tooltipsEnabled) {
13593
+ const tip = document.createElement("fig-tooltip");
13594
+ tip.setAttribute("action", "manual");
13595
+ tip.setAttribute("text", this.#formatRadius());
13596
+ tip.appendChild(svg);
13597
+ this.appendChild(tip);
13598
+ this.#radiusTooltip = tip;
13599
+ } else {
13600
+ this.appendChild(svg);
13601
+ }
13602
+
13603
+ this.#setupRadiusDrag(hitCircle);
13604
+ }
13605
+
13606
+ #createAngleSvg() {
13607
+ const ns = "http://www.w3.org/2000/svg";
13608
+ const svg = document.createElementNS(ns, "svg");
13609
+ svg.classList.add("fig-canvas-point-angle-svg");
13610
+ svg.setAttribute("overflow", "visible");
13611
+ svg.style.position = "absolute";
13612
+ svg.style.pointerEvents = "none";
13613
+ const line = document.createElementNS(ns, "line");
13614
+ line.classList.add("fig-canvas-point-angle-line");
13615
+ svg.appendChild(line);
13616
+ this.#angleSvg = svg;
13617
+ this.appendChild(svg);
13618
+ }
13619
+
13620
+ #createAngleHandle(disabled, tooltips, handleSurface) {
13621
+ const handle = document.createElement("fig-handle");
13622
+ handle.setAttribute("drag", "true");
13623
+ handle.setAttribute("drag-surface", handleSurface);
13624
+ handle.setAttribute("drag-axes", "x,y");
13625
+ handle.setAttribute("size", "small");
13626
+ handle.setAttribute("hit-area", "12 circle");
13627
+ handle.setAttribute("hit-area-mode", "delegate");
13628
+ if (disabled) handle.setAttribute("disabled", "");
13629
+ this.#angleHandle = handle;
13630
+
13631
+ if (tooltips) {
13632
+ const tip = document.createElement("fig-tooltip");
13633
+ tip.setAttribute("action", "manual");
13634
+ tip.setAttribute("text", `${Math.round(this.#angle)}°`);
13635
+ tip.appendChild(handle);
13636
+ this.appendChild(tip);
13637
+ this.#angleTooltip = tip;
13638
+ } else {
13639
+ this.appendChild(handle);
13640
+ }
13641
+ }
13642
+
13643
+ #resizeCursorSvg(deg) {
13644
+ const r = Math.round(deg);
13645
+ 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`;
13646
+ }
13647
+
13648
+ #rotateCursorSvg(deg) {
13649
+ const r = Math.round(deg - 45);
13650
+ 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`;
13651
+ }
13652
+
13653
+ #syncAngleCursor() {
13654
+ if (!this.#angleHandle || !this.#hasAngle) return;
13655
+ const hitArea = this.#angleHandle.querySelector(".fig-handle-hit-area");
13656
+ if (!hitArea) return;
13657
+ hitArea.style.cursor = this.#rotateCursorSvg(this.#angle);
13658
+ }
13659
+
13660
+ #syncPositions() {
13661
+ const container = this.#container;
13662
+ if (!container || !this.#pointHandle) return;
13663
+ const rect = container.getBoundingClientRect();
13664
+
13665
+ this.#pointHandle.setAttribute("value", `${this.#x}% ${this.#y}%`);
13666
+
13667
+ if (this.#radiusSvg) {
13668
+ const cx = (this.#x / 100) * rect.width;
13669
+ const cy = (this.#y / 100) * rect.height;
13670
+ const r = this.#resolveRadius(rect.width);
13671
+ const svg = this.#radiusSvg;
13672
+ const d = Math.max(r * 2, 1);
13673
+ svg.style.position = "absolute";
13674
+ svg.style.width = `${d}px`;
13675
+ svg.style.height = `${d}px`;
13676
+ svg.style.left = `${cx - r}px`;
13677
+ svg.style.top = `${cy - r}px`;
13678
+ svg.setAttribute("viewBox", `0 0 ${d} ${d}`);
13679
+ const circles = svg.querySelectorAll("circle");
13680
+ for (const c of circles) {
13681
+ c.setAttribute("cx", String(r));
13682
+ c.setAttribute("cy", String(r));
13683
+ c.setAttribute("r", String(Math.max(r - 1, 0)));
13684
+ }
13685
+ if (this.#radiusTooltip) {
13686
+ this.#radiusTooltip.setAttribute("text", this.#formatRadius());
13687
+ }
13688
+ }
13689
+
13690
+ if (this.#angleSvg && this.#hasAngle) {
13691
+ const cx = (this.#x / 100) * rect.width;
13692
+ const cy = (this.#y / 100) * rect.height;
13693
+ const r = this.#resolveRadius(rect.width);
13694
+ const angleRad = (this.#angle * Math.PI) / 180;
13695
+ const ax = cx + r * Math.cos(angleRad);
13696
+ const ay = cy + r * Math.sin(angleRad);
13697
+
13698
+ const svg = this.#angleSvg;
13699
+ svg.style.width = `${rect.width}px`;
13700
+ svg.style.height = `${rect.height}px`;
13701
+ svg.style.left = "0";
13702
+ svg.style.top = "0";
13703
+ svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
13704
+ const line = svg.querySelector("line");
13705
+ if (line) {
13706
+ line.setAttribute("x1", String(cx));
13707
+ line.setAttribute("y1", String(cy));
13708
+ line.setAttribute("x2", String(ax));
13709
+ line.setAttribute("y2", String(ay));
13710
+ }
13711
+ }
13712
+
13713
+ if (this.#angleHandle && this.#hasAngle) {
13714
+ const cx = (this.#x / 100) * rect.width;
13715
+ const cy = (this.#y / 100) * rect.height;
13716
+ const r = this.#resolveRadius(rect.width);
13717
+ const angleRad = (this.#angle * Math.PI) / 180;
13718
+ const ax = cx + r * Math.cos(angleRad);
13719
+ const ay = cy + r * Math.sin(angleRad);
13720
+ const pxPct = rect.width > 0 ? (ax / rect.width) * 100 : 0;
13721
+ const pyPct = rect.height > 0 ? (ay / rect.height) * 100 : 0;
13722
+ this.#angleHandle.setAttribute("value", `${pxPct}% ${pyPct}%`);
13723
+
13724
+ if (this.#angleTooltip) {
13725
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13726
+ }
13727
+ }
13728
+
13729
+ this.#syncAngleCursor();
13730
+ }
13731
+
13732
+ #emitInput() {
13733
+ this.dispatchEvent(new CustomEvent("input", { bubbles: true, detail: this.value }));
13734
+ }
13735
+
13736
+ #emitChange() {
13737
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: this.value }));
13738
+ }
13739
+
13740
+ #syncValueAttribute() {
13741
+ this.setAttribute("value", JSON.stringify(this.value));
13742
+ }
13743
+
13744
+ #setupEventListeners() {
13745
+ if (!this.#pointHandle) return;
13746
+
13747
+ this.#pointHandle.addEventListener("input", (e) => {
13748
+ e.stopPropagation();
13749
+ this.#isDragging = true;
13750
+ const px = e.detail?.px ?? this.#x / 100;
13751
+ const py = e.detail?.py ?? this.#y / 100;
13752
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
13753
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
13754
+ if (this.#pointTooltip) {
13755
+ this.#pointTooltip.setAttribute("text", this.#pointTipText);
13756
+ this.#pointTooltip.setAttribute("show", "true");
13757
+ this.#pointTooltip.showPopup?.();
13758
+ }
13759
+ this.#syncPositions();
13760
+ this.#emitInput();
13761
+ });
13762
+
13763
+ this.#pointHandle.addEventListener("change", (e) => {
13764
+ e.stopPropagation();
13765
+ const px = e.detail?.px ?? this.#x / 100;
13766
+ const py = e.detail?.py ?? this.#y / 100;
13767
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
13768
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
13769
+ if (this.#pointTooltip) this.#pointTooltip.removeAttribute("show");
13770
+ this.#syncPositions();
13771
+ this.#syncValueAttribute();
13772
+ this.#emitChange();
13773
+ requestAnimationFrame(() => { this.#isDragging = false; });
13774
+ });
13775
+
13776
+ if (this.#angleHandle) {
13777
+ this.#angleHandle.addEventListener("input", (e) => {
13778
+ e.stopPropagation();
13779
+ this.#isAngleDragging = true;
13780
+ this.classList.add("fig-canvas-point-ring-active");
13781
+ const container = this.#container;
13782
+ if (!container) return;
13783
+ const rect = container.getBoundingClientRect();
13784
+ const cx = (this.#x / 100) * rect.width;
13785
+ const cy = (this.#y / 100) * rect.height;
13786
+ const hx = e.detail?.x ?? 0;
13787
+ const hy = e.detail?.y ?? 0;
13788
+ const hw = this.#angleHandle.offsetWidth / 2;
13789
+ const hh = this.#angleHandle.offsetHeight / 2;
13790
+ const dx = (hx + hw) - cx;
13791
+ const dy = (hy + hh) - cy;
13792
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
13793
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
13794
+ angle = Math.round(angle / 15) * 15;
13795
+ }
13796
+ this.#angle = angle;
13797
+
13798
+ let dist = Math.sqrt(dx * dx + dy * dy);
13799
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
13800
+ const step = this.#radiusIsPercent ? 5 : 10;
13801
+ if (this.#radiusIsPercent) {
13802
+ let pct = (dist / rect.width) * 100;
13803
+ pct = Math.round(pct / step) * step;
13804
+ dist = (pct / 100) * rect.width;
13805
+ } else {
13806
+ dist = Math.round(dist / step) * step;
13807
+ }
13808
+ }
13809
+ if (this.#radiusIsPercent) {
13810
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
13811
+ } else {
13812
+ this.#radius = Math.max(0, dist);
13813
+ }
13814
+
13815
+ this.#syncPositions();
13816
+ if (this.#angleTooltip) {
13817
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13818
+ this.#angleTooltip.setAttribute("show", "true");
13819
+ this.#angleTooltip.showPopup?.();
13820
+ }
13821
+ if (this.#radiusTooltip) {
13822
+ this.#radiusTooltip.setAttribute("text", this.#formatRadius());
13823
+ }
13824
+ this.#emitInput();
13825
+ });
13826
+
13827
+ this.#angleHandle.addEventListener("change", (e) => {
13828
+ e.stopPropagation();
13829
+ this.classList.remove("fig-canvas-point-ring-active");
13830
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
13831
+ this.#syncPositions();
13832
+ this.#syncValueAttribute();
13833
+ this.#emitChange();
13834
+ requestAnimationFrame(() => { this.#isAngleDragging = false; });
13835
+ });
13836
+
13837
+ this.#angleHandle.addEventListener("hitareadown", (e) => {
13838
+ e.stopPropagation();
13839
+ const origEvent = e.detail?.originalEvent;
13840
+ if (!origEvent) return;
13841
+ origEvent.preventDefault();
13842
+ this.#isAngleDragging = true;
13843
+ this.classList.add("fig-canvas-point-ring-active");
13844
+ const container = this.#container;
13845
+ if (!container) return;
13846
+
13847
+ if (this.#angleTooltip) {
13848
+ this.#angleTooltip.setAttribute("show", "true");
13849
+ this.#angleTooltip.showPopup?.();
13850
+ }
13851
+
13852
+ const prevBodyCursor = document.body.style.cursor;
13853
+ document.body.style.cursor = this.#rotateCursorSvg(this.#angle);
13854
+
13855
+ const onMove = (ev) => {
13856
+ const rect = container.getBoundingClientRect();
13857
+ const cx = (this.#x / 100) * rect.width;
13858
+ const cy = (this.#y / 100) * rect.height;
13859
+ const dx = ev.clientX - rect.left - cx;
13860
+ const dy = ev.clientY - rect.top - cy;
13861
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
13862
+ if (this.#shouldSnap(ev.shiftKey)) {
13863
+ angle = Math.round(angle / 15) * 15;
13864
+ }
13865
+ this.#angle = angle;
13866
+ this.#syncPositions();
13867
+ document.body.style.cursor = this.#rotateCursorSvg(this.#angle);
13868
+ if (this.#angleTooltip) {
13869
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13870
+ }
13871
+ this.#emitInput();
13872
+ };
13873
+
13874
+ const onUp = () => {
13875
+ this.#isAngleDragging = false;
13876
+ this.classList.remove("fig-canvas-point-ring-active");
13877
+ document.body.style.cursor = prevBodyCursor;
13878
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
13879
+ this.#syncValueAttribute();
13880
+ this.#emitChange();
13881
+ window.removeEventListener("pointermove", onMove);
13882
+ window.removeEventListener("pointerup", onUp);
13883
+ };
13884
+
13885
+ window.addEventListener("pointermove", onMove);
13886
+ window.addEventListener("pointerup", onUp);
13887
+ });
13888
+ }
13889
+ }
13890
+
13891
+ #setupRadiusDrag(circle) {
13892
+ if (!circle) return;
13893
+ circle.addEventListener("pointermove", (e) => {
13894
+ if (this.#isRadiusDragging) return;
13895
+ const container = this.#container;
13896
+ if (!container) return;
13897
+ const rect = container.getBoundingClientRect();
13898
+ const cx = (this.#x / 100) * rect.width;
13899
+ const cy = (this.#y / 100) * rect.height;
13900
+ const deg = (Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx) * 180) / Math.PI;
13901
+ circle.style.cursor = this.#resizeCursorSvg(deg);
13902
+ });
13903
+ const onDown = (e) => {
13904
+ if (this.hasAttribute("disabled")) return;
13905
+ e.preventDefault();
13906
+ e.stopPropagation();
13907
+ this.#isRadiusDragging = true;
13908
+ this.classList.add("fig-canvas-point-ring-active");
13909
+ const container = this.#container;
13910
+ if (!container) return;
13911
+
13912
+ if (this.#radiusTooltip) {
13913
+ this.#radiusTooltip.setAttribute("show", "true");
13914
+ this.#radiusTooltip.showPopup?.();
13915
+ }
13916
+
13917
+ const prevBodyCursor = document.body.style.cursor;
13918
+ circle.style.pointerEvents = "none";
13919
+ const rect0 = container.getBoundingClientRect();
13920
+ const cx0 = (this.#x / 100) * rect0.width;
13921
+ const cy0 = (this.#y / 100) * rect0.height;
13922
+ const initDeg = (Math.atan2(e.clientY - rect0.top - cy0, e.clientX - rect0.left - cx0) * 180) / Math.PI;
13923
+ document.body.style.cursor = this.#resizeCursorSvg(initDeg);
13924
+
13925
+ const onMove = (ev) => {
13926
+ const rect = container.getBoundingClientRect();
13927
+ const cx = (this.#x / 100) * rect.width;
13928
+ const cy = (this.#y / 100) * rect.height;
13929
+ const dx = ev.clientX - rect.left - cx;
13930
+ const dy = ev.clientY - rect.top - cy;
13931
+ document.body.style.cursor = this.#resizeCursorSvg((Math.atan2(dy, dx) * 180) / Math.PI);
13932
+ let dist = Math.sqrt(dx * dx + dy * dy);
13933
+ if (this.#shouldSnap(ev.shiftKey)) {
13934
+ const step = this.#radiusIsPercent ? 5 : 10;
13935
+ if (this.#radiusIsPercent) {
13936
+ let pct = (dist / rect.width) * 100;
13937
+ pct = Math.round(pct / step) * step;
13938
+ dist = (pct / 100) * rect.width;
13939
+ } else {
13940
+ dist = Math.round(dist / step) * step;
13941
+ }
13942
+ }
13943
+ if (this.#radiusIsPercent) {
13944
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
13945
+ } else {
13946
+ this.#radius = Math.max(0, dist);
13947
+ }
13948
+ this.#syncPositions();
13949
+ this.#emitInput();
13950
+ };
13951
+
13952
+ const onUp = () => {
13953
+ this.#isRadiusDragging = false;
13954
+ this.classList.remove("fig-canvas-point-ring-active");
13955
+ circle.style.pointerEvents = "";
13956
+ document.body.style.cursor = prevBodyCursor;
13957
+ if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
13958
+ this.#syncValueAttribute();
13959
+ this.#emitChange();
13960
+ window.removeEventListener("pointermove", onMove);
13961
+ window.removeEventListener("pointerup", onUp);
13962
+ };
13963
+
13964
+ window.addEventListener("pointermove", onMove);
13965
+ window.addEventListener("pointerup", onUp);
13966
+ };
13967
+ circle.addEventListener("pointerdown", onDown);
13968
+ this._radiusDragCleanup = () => circle.removeEventListener("pointerdown", onDown);
13969
+ }
13970
+
13971
+ #teardownRadiusDrag() {
13972
+ if (this._radiusDragCleanup) {
13973
+ this._radiusDragCleanup();
13974
+ this._radiusDragCleanup = null;
13975
+ }
13976
+ }
13977
+ }
13978
+ customElements.define("fig-canvas-point", FigCanvasPoint);
13979
+
13289
13980
  /* Handle */
13290
13981
  class FigHandle extends HTMLElement {
13291
13982
  static observedAttributes = [
@@ -13299,6 +13990,8 @@ class FigHandle extends HTMLElement {
13299
13990
  "value",
13300
13991
  "type",
13301
13992
  "control",
13993
+ "hit-area",
13994
+ "hit-area-mode",
13302
13995
  ];
13303
13996
 
13304
13997
  #isDragging = false;
@@ -13306,6 +13999,7 @@ class FigHandle extends HTMLElement {
13306
13999
  #boundPointerDown = null;
13307
14000
  #applyingValue = false;
13308
14001
  #colorTip = null;
14002
+ #hitAreaEl = null;
13309
14003
 
13310
14004
  get #controlMode() {
13311
14005
  return this.getAttribute("control") || null;
@@ -13443,8 +14137,70 @@ class FigHandle extends HTMLElement {
13443
14137
  this.#applyingValue = false;
13444
14138
  }
13445
14139
 
14140
+ get #hitAreaMode() {
14141
+ return this.getAttribute("hit-area-mode") || "handle";
14142
+ }
14143
+
14144
+ #parseHitArea() {
14145
+ const raw = this.getAttribute("hit-area");
14146
+ if (!raw) return null;
14147
+ const tokens = raw.trim().split(/\s+/);
14148
+ let vPad = 0, hPad = 0, circle = false;
14149
+ const nums = [];
14150
+ for (const t of tokens) {
14151
+ if (t === "circle") { circle = true; continue; }
14152
+ const n = parseFloat(t);
14153
+ if (Number.isFinite(n)) nums.push(n);
14154
+ }
14155
+ if (nums.length >= 2) { vPad = nums[0]; hPad = nums[1]; }
14156
+ else if (nums.length === 1) { vPad = nums[0]; hPad = nums[0]; }
14157
+ else return null;
14158
+ return { vPad, hPad, circle };
14159
+ }
14160
+
14161
+ #syncHitArea() {
14162
+ const parsed = this.#parseHitArea();
14163
+ if (!parsed) {
14164
+ if (this.#hitAreaEl) {
14165
+ this.#hitAreaEl.remove();
14166
+ this.#hitAreaEl = null;
14167
+ }
14168
+ this.style.removeProperty("--fig-handle-hit-area-size");
14169
+ return;
14170
+ }
14171
+ if (!this.#hitAreaEl) {
14172
+ const el = document.createElement("div");
14173
+ el.classList.add("fig-handle-hit-area");
14174
+ el.addEventListener("pointerdown", (e) => this.#onHitAreaPointerDown(e));
14175
+ this.prepend(el);
14176
+ this.#hitAreaEl = el;
14177
+ }
14178
+ this.style.setProperty("--fig-handle-hit-area-size", String(parsed.hPad * 2));
14179
+ if (parsed.circle) {
14180
+ this.#hitAreaEl.style.borderRadius = "50%";
14181
+ } else {
14182
+ this.#hitAreaEl.style.borderRadius = "inherit";
14183
+ }
14184
+ }
14185
+
14186
+ #onHitAreaPointerDown(e) {
14187
+ if (this.hasAttribute("disabled")) return;
14188
+ if (e.target !== this.#hitAreaEl) return;
14189
+ if (this.#hitAreaMode === "delegate") {
14190
+ e.preventDefault();
14191
+ e.stopPropagation();
14192
+ this.dispatchEvent(new CustomEvent("hitareadown", {
14193
+ bubbles: true,
14194
+ detail: { originalEvent: e },
14195
+ }));
14196
+ } else {
14197
+ this.#onPointerDown(e);
14198
+ }
14199
+ }
14200
+
13446
14201
  connectedCallback() {
13447
14202
  this.#syncDrag();
14203
+ this.#syncHitArea();
13448
14204
  this.addEventListener("click", this.#handleSelect);
13449
14205
  document.addEventListener("pointerdown", this.#handleDeselect);
13450
14206
  document.addEventListener("keydown", this.#handleKeyDown);
@@ -13456,6 +14212,7 @@ class FigHandle extends HTMLElement {
13456
14212
  disconnectedCallback() {
13457
14213
  this.#teardownDrag();
13458
14214
  this.#hideColorTip();
14215
+ if (this.#hitAreaEl) { this.#hitAreaEl.remove(); this.#hitAreaEl = null; }
13459
14216
  this.removeEventListener("click", this.#handleSelect);
13460
14217
  document.removeEventListener("pointerdown", this.#handleDeselect);
13461
14218
  document.removeEventListener("keydown", this.#handleKeyDown);
@@ -13509,6 +14266,7 @@ class FigHandle extends HTMLElement {
13509
14266
  }
13510
14267
  }
13511
14268
  if (name === "drag") this.#syncDrag();
14269
+ if (name === "hit-area") this.#syncHitArea();
13512
14270
  if (name === "value" && !this.#applyingValue && !this.#isDragging) {
13513
14271
  this.#applyValue(value);
13514
14272
  }
@@ -13547,16 +14305,23 @@ class FigHandle extends HTMLElement {
13547
14305
 
13548
14306
  this.#isDragging = true;
13549
14307
  const axes = this.#axes;
13550
- const containerRect = container.getBoundingClientRect();
13551
14308
  const handleW = this.offsetWidth;
13552
14309
  const handleH = this.offsetHeight;
14310
+ let lastRect = null;
14311
+
14312
+ const handleRect = this.getBoundingClientRect();
14313
+ const handleCenterX = handleRect.left + handleRect.width / 2;
14314
+ const handleCenterY = handleRect.top + handleRect.height / 2;
14315
+ const offsetX = e.clientX - handleCenterX;
14316
+ const offsetY = e.clientY - handleCenterY;
13553
14317
 
13554
14318
  const clampAndApply = (clientX, clientY, shiftKey = false) => {
13555
14319
  const rect = container.getBoundingClientRect();
14320
+ lastRect = rect;
13556
14321
  const currentLeft = parseFloat(this.style.left) || 0;
13557
14322
  const currentTop = parseFloat(this.style.top) || 0;
13558
- const rawX = clientX - rect.left - handleW / 2;
13559
- const rawY = clientY - rect.top - handleH / 2;
14323
+ const rawX = (clientX - offsetX) - rect.left - handleW / 2;
14324
+ const rawY = (clientY - offsetY) - rect.top - handleH / 2;
13560
14325
 
13561
14326
  const clampedX = Math.max(
13562
14327
  -handleW / 2,
@@ -13609,7 +14374,7 @@ class FigHandle extends HTMLElement {
13609
14374
  new CustomEvent("input", {
13610
14375
  bubbles: true,
13611
14376
  detail: {
13612
- ...this.#positionDetail(container.getBoundingClientRect()),
14377
+ ...this.#positionDetail(lastRect),
13613
14378
  shiftKey: e.shiftKey,
13614
14379
  },
13615
14380
  }),
@@ -13628,7 +14393,7 @@ class FigHandle extends HTMLElement {
13628
14393
  this.dispatchEvent(
13629
14394
  new CustomEvent("change", {
13630
14395
  bubbles: true,
13631
- detail: this.#positionDetail(container.getBoundingClientRect()),
14396
+ detail: this.#positionDetail(lastRect),
13632
14397
  }),
13633
14398
  );
13634
14399
  const swallowClick = (evt) => {