@rogieking/figui3 6.11.0 → 6.12.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-editor.js CHANGED
@@ -1,244 +1,15 @@
1
- import "./fig-layer.js";
2
-
3
- function figEditorIsWebKitOrIOSBrowser() {
4
- if (typeof navigator === "undefined") {
5
- return false;
6
- }
7
- const userAgent = navigator.userAgent || "";
8
- const isIOSBrowser =
9
- /\b(iPad|iPhone|iPod)\b/.test(userAgent) ||
10
- (/\bMacintosh\b/.test(userAgent) && /\bMobile\b/.test(userAgent));
11
- const isDesktopWebKit =
12
- /\bAppleWebKit\b/.test(userAgent) &&
13
- !/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser)\b/.test(userAgent);
14
- return isIOSBrowser || isDesktopWebKit;
1
+ import "./fig.js";
2
+ import "./fig-lab.js";
3
+
4
+ function figEditorEscapeAttribute(value) {
5
+ return String(value ?? "")
6
+ .replace(/&/g, "&")
7
+ .replace(/"/g, """)
8
+ .replace(/'/g, "'")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
15
11
  }
16
12
 
17
- function figEditorSupportsCustomizedBuiltIns() {
18
- if (
19
- typeof window === "undefined" ||
20
- !window.customElements ||
21
- typeof HTMLButtonElement === "undefined"
22
- ) {
23
- return false;
24
- }
25
-
26
- const testName = `fig-editor-builtin-probe-${Math.random().toString(36).slice(2)}`;
27
- class FigEditorCustomizedBuiltInProbe extends HTMLButtonElement {}
28
-
29
- try {
30
- customElements.define(testName, FigEditorCustomizedBuiltInProbe, {
31
- extends: "button",
32
- });
33
- const probe = document.createElement("button", { is: testName });
34
- return probe instanceof FigEditorCustomizedBuiltInProbe;
35
- } catch (_error) {
36
- return false;
37
- }
38
- }
39
-
40
- const figEditorNeedsBuiltInPolyfill =
41
- figEditorIsWebKitOrIOSBrowser() && !figEditorSupportsCustomizedBuiltIns();
42
- const figEditorBuiltInPolyfillReady = (
43
- figEditorNeedsBuiltInPolyfill
44
- ? import("./polyfills/custom-elements-webkit.js")
45
- : Promise.resolve()
46
- )
47
- .then(() => {})
48
- .catch((error) => {
49
- throw error;
50
- });
51
-
52
- function figEditorDefineCustomizedBuiltIn(name, constructor, options) {
53
- const define = () => {
54
- if (!customElements.get(name)) {
55
- customElements.define(name, constructor, options);
56
- }
57
- };
58
-
59
- if (!figEditorNeedsBuiltInPolyfill) {
60
- define();
61
- return;
62
- }
63
-
64
- figEditorBuiltInPolyfillReady.then(define).catch((error) => {
65
- console.error(
66
- `[figui3] Failed to load customized built-in polyfill for "${name}".`,
67
- error,
68
- );
69
- });
70
- }
71
-
72
- /* Toast */
73
- /**
74
- * A toast notification element for non-modal, time-based messages.
75
- * Always positioned at bottom center of the screen.
76
- * @attr {number} duration - Auto-dismiss duration in ms (0 = no auto-dismiss, default: 5000)
77
- * @attr {number} offset - Distance from bottom edge in pixels (default: 16)
78
- * @attr {string} theme - Visual theme: "dark" (default), "light", "danger", "brand"
79
- * @attr {boolean} open - Whether the toast is visible
80
- */
81
- class FigToast extends HTMLDialogElement {
82
- constructor() {
83
- super();
84
- this._figInit();
85
- }
86
-
87
- _figInit() {
88
- if (this._figInitialized) return;
89
- this._figInitialized = true;
90
- this._defaultOffset = 16;
91
- this._autoCloseTimer = null;
92
- this._boundHandleClose = this.handleClose.bind(this);
93
- }
94
-
95
- getOffset() {
96
- return parseInt(this.getAttribute("offset") ?? this._defaultOffset);
97
- }
98
-
99
- connectedCallback() {
100
- this._figInit();
101
-
102
- if (!this.hasAttribute("theme")) {
103
- this.setAttribute("theme", "dark");
104
- }
105
-
106
- this.syncLiveRegion();
107
-
108
- const shouldOpen =
109
- this.getAttribute("open") === "true" || this.getAttribute("open") === "";
110
- if (this.hasAttribute("open") && !shouldOpen) {
111
- this.removeAttribute("open");
112
- }
113
-
114
- if (!shouldOpen) {
115
- this.close();
116
- }
117
-
118
- requestAnimationFrame(() => {
119
- this.addCloseListeners();
120
- this.applyPosition();
121
-
122
- if (shouldOpen) {
123
- this.showToast();
124
- }
125
- });
126
- }
127
-
128
- disconnectedCallback() {
129
- this._figInit();
130
- this.clearAutoClose();
131
- }
132
-
133
- addCloseListeners() {
134
- this.querySelectorAll("[close-toast]").forEach((button) => {
135
- button.removeEventListener("click", this._boundHandleClose);
136
- button.addEventListener("click", this._boundHandleClose);
137
- });
138
- }
139
-
140
- handleClose() {
141
- this.hideToast();
142
- }
143
-
144
- applyPosition() {
145
- this.style.position = "fixed";
146
- this.style.margin = "0";
147
- this.style.top = "auto";
148
- this.style.bottom = `${this.getOffset()}px`;
149
- this.style.left = "50%";
150
- this.style.right = "auto";
151
- this.style.transform = "translateX(-50%)";
152
- }
153
-
154
- startAutoClose() {
155
- this.clearAutoClose();
156
-
157
- const duration = parseInt(this.getAttribute("duration") ?? "5000");
158
- if (duration > 0) {
159
- this._autoCloseTimer = setTimeout(() => {
160
- this.hideToast();
161
- }, duration);
162
- }
163
- }
164
-
165
- syncLiveRegion() {
166
- const assertive =
167
- this.getAttribute("live") === "assertive" ||
168
- this.getAttribute("theme") === "danger";
169
- if (!this.hasAttribute("role")) {
170
- this.setAttribute("role", assertive ? "alert" : "status");
171
- }
172
- if (!this.hasAttribute("aria-live")) {
173
- this.setAttribute("aria-live", assertive ? "assertive" : "polite");
174
- }
175
- if (!this.hasAttribute("aria-atomic")) {
176
- this.setAttribute("aria-atomic", "true");
177
- }
178
- }
179
-
180
- clearAutoClose() {
181
- if (this._autoCloseTimer) {
182
- clearTimeout(this._autoCloseTimer);
183
- this._autoCloseTimer = null;
184
- }
185
- }
186
-
187
- _resolveAutoTheme() {
188
- if (this.getAttribute("theme") !== "auto") return;
189
- const cs = getComputedStyle(document.documentElement).colorScheme || "";
190
- const isDark = cs.includes("dark");
191
- this.style.colorScheme = isDark ? "light" : "dark";
192
- }
193
-
194
- showToast() {
195
- this._resolveAutoTheme();
196
- if (!this.open) this.show();
197
- this.applyPosition();
198
- this.startAutoClose();
199
- this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
200
- }
201
-
202
- hideToast() {
203
- this.clearAutoClose();
204
- this.close();
205
- this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
206
- }
207
-
208
- static get observedAttributes() {
209
- return ["duration", "offset", "open", "theme", "live"];
210
- }
211
-
212
- attributeChangedCallback(name, oldValue, newValue) {
213
- this._figInit();
214
- if (!this.isConnected) return;
215
- if (name === "offset") {
216
- this.applyPosition();
217
- }
218
-
219
- if (name === "open") {
220
- if (newValue !== null && newValue !== "false") {
221
- this.showToast();
222
- } else {
223
- this.hideToast();
224
- }
225
- }
226
-
227
- if (name === "theme") {
228
- if (newValue === "auto") {
229
- this._resolveAutoTheme();
230
- } else {
231
- this.style.removeProperty("color-scheme");
232
- }
233
- }
234
-
235
- if (name === "theme" || name === "live") {
236
- this.syncLiveRegion();
237
- }
238
- }
239
- }
240
- figEditorDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
241
-
242
13
  // FigFillPicker
243
14
  const GRADIENT_INTERPOLATION_SPACES = [
244
15
  "srgb",
@@ -345,9 +116,11 @@ class FigFillPicker extends HTMLElement {
345
116
  #hueSlider = null;
346
117
  #opacitySlider = null;
347
118
  #isDraggingColor = false;
119
+ #syncingGradientBar = false;
348
120
  #teardownColorAreaEvents = null;
349
- #dialogOpenObserver = null;
350
- #webcamTabObserver = null;
121
+ #valueAtOpen = null;
122
+ #webcamStart = null;
123
+ #webcamRequestId = 0;
351
124
  #boundTriggerClick = null;
352
125
  #boundTriggerKeydown = null;
353
126
 
@@ -382,24 +155,10 @@ class FigFillPicker extends HTMLElement {
382
155
  }
383
156
 
384
157
  disconnectedCallback() {
385
- if (this.#teardownColorAreaEvents) {
386
- this.#teardownColorAreaEvents();
387
- this.#teardownColorAreaEvents = null;
388
- }
389
- if (this.#dialogOpenObserver) {
390
- this.#dialogOpenObserver.disconnect();
391
- this.#dialogOpenObserver = null;
392
- }
393
- if (this.#webcamTabObserver) {
394
- this.#webcamTabObserver.disconnect();
395
- this.#webcamTabObserver = null;
396
- }
397
- if (this.#webcam.stream) {
398
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
399
- this.#webcam.stream = null;
400
- }
158
+ this.#discardDialog();
401
159
  if (this.#webcam.snapshot?.startsWith("blob:")) {
402
160
  URL.revokeObjectURL(this.#webcam.snapshot);
161
+ if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
403
162
  this.#webcam.snapshot = null;
404
163
  }
405
164
  if (this.#video.url && this.#video.url.startsWith("blob:")) {
@@ -410,11 +169,6 @@ class FigFillPicker extends HTMLElement {
410
169
  this.#trigger.removeEventListener("click", this.#boundTriggerClick);
411
170
  this.#trigger.removeEventListener("keydown", this.#boundTriggerKeydown);
412
171
  }
413
- if (this.#dialog) {
414
- this.#dialog.close();
415
- this.#dialog.remove();
416
- this.#dialog = null;
417
- }
418
172
  }
419
173
 
420
174
  #setupTrigger() {
@@ -485,7 +239,11 @@ class FigFillPicker extends HTMLElement {
485
239
  }
486
240
 
487
241
  #handleTriggerClick(e) {
488
- if (this.hasAttribute("disabled")) return;
242
+ if (
243
+ this.hasAttribute("disabled") &&
244
+ this.getAttribute("disabled") !== "false"
245
+ )
246
+ return;
489
247
  e.stopPropagation();
490
248
  e.preventDefault();
491
249
  this.#openDialog();
@@ -493,7 +251,11 @@ class FigFillPicker extends HTMLElement {
493
251
 
494
252
  #handleTriggerKeydown(e) {
495
253
  if (e.key !== "Enter" && e.key !== " ") return;
496
- if (this.hasAttribute("disabled")) return;
254
+ if (
255
+ this.hasAttribute("disabled") &&
256
+ this.getAttribute("disabled") !== "false"
257
+ )
258
+ return;
497
259
  e.preventDefault();
498
260
  e.stopPropagation();
499
261
  this.#openDialog();
@@ -625,7 +387,8 @@ class FigFillPicker extends HTMLElement {
625
387
  this.#createDialog();
626
388
  }
627
389
 
628
- this.#switchTab(this.#fillType);
390
+ this.#valueAtOpen = JSON.stringify(this.value);
391
+ this.#switchTab(this.#fillType, { emit: false });
629
392
 
630
393
  const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
631
394
  if (gamutEl) gamutEl.value = this.#gamut;
@@ -650,6 +413,43 @@ class FigFillPicker extends HTMLElement {
650
413
  if (this.#dialog) this.#dialog.open = false;
651
414
  }
652
415
 
416
+ #restoreCustomSlotContent() {
417
+ if (!this.#dialog) return;
418
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
419
+ const container = Array.from(
420
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
421
+ ).find((candidate) => candidate.dataset.tab === modeName);
422
+ if (!container) continue;
423
+ while (container.firstChild) element.appendChild(container.firstChild);
424
+ }
425
+ }
426
+
427
+ #discardDialog() {
428
+ if (this.#teardownColorAreaEvents) {
429
+ this.#teardownColorAreaEvents();
430
+ this.#teardownColorAreaEvents = null;
431
+ }
432
+ this.#stopWebcam();
433
+ if (!this.#dialog) return;
434
+ this.#restoreCustomSlotContent();
435
+ this.#dialog.remove();
436
+ this.#dialog = null;
437
+ this.#webcamStart = null;
438
+ this.#valueAtOpen = null;
439
+ }
440
+
441
+ #stopWebcam() {
442
+ this.#webcamRequestId += 1;
443
+ if (this.#webcam.stream) {
444
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
445
+ this.#webcam.stream = null;
446
+ }
447
+ const video = this.#dialog?.querySelector(
448
+ ".fig-fill-picker-webcam-video",
449
+ );
450
+ if (video) video.srcObject = null;
451
+ }
452
+
653
453
  #createDialog() {
654
454
  // Collect slotted custom mode content before any DOM changes
655
455
  this.#customSlots = {};
@@ -709,23 +509,31 @@ class FigFillPicker extends HTMLElement {
709
509
  }
710
510
 
711
511
  const experimental = this.getAttribute("experimental");
712
- const expAttr = experimental ? `experimental="${experimental}"` : "";
512
+ const expAttr = experimental
513
+ ? `experimental="${figEditorEscapeAttribute(experimental)}"`
514
+ : "";
713
515
 
714
516
  let headerContent;
715
517
  if (allowedModes.length === 1) {
716
- headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
518
+ headerContent = `<h3 class="fig-fill-picker-type-label">${figEditorEscapeAttribute(modeLabels[allowedModes[0]])}</h3>`;
717
519
  } else {
718
520
  const options = allowedModes
719
- .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
521
+ .map(
522
+ (m) =>
523
+ `<option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</option>`,
524
+ )
720
525
  .join("\n ");
721
- headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${this.#fillType}">
526
+ headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${figEditorEscapeAttribute(this.#fillType)}">
722
527
  ${options}
723
528
  </fig-dropdown>`;
724
529
  }
725
530
 
726
531
  // Generate tab containers for all allowed modes
727
532
  const tabDivs = allowedModes
728
- .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
533
+ .map(
534
+ (m) =>
535
+ `<div class="fig-fill-picker-tab" data-tab="${figEditorEscapeAttribute(m)}"></div>`,
536
+ )
729
537
  .join("\n ");
730
538
 
731
539
  const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" label="Color gamut" ${expAttr} value="${this.#gamut}">
@@ -750,7 +558,9 @@ class FigFillPicker extends HTMLElement {
750
558
 
751
559
  // Populate custom tab containers and emit modeready
752
560
  for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
753
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
561
+ const container = Array.from(
562
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
563
+ ).find((candidate) => candidate.dataset.tab === modeName);
754
564
  if (!container) continue;
755
565
 
756
566
  // Move children (not the element itself) for vanilla HTML usage
@@ -785,7 +595,6 @@ class FigFillPicker extends HTMLElement {
785
595
  this.#onGamutChange();
786
596
  }
787
597
  };
788
- gamutEl.addEventListener("input", handleGamutChange);
789
598
  gamutEl.addEventListener("change", handleGamutChange);
790
599
  }
791
600
 
@@ -797,22 +606,18 @@ class FigFillPicker extends HTMLElement {
797
606
 
798
607
  const onDialogClose = () => {
799
608
  if (this.#swatch) this.#swatch.removeAttribute("selected");
800
- this.#emitChange();
609
+ this.#stopWebcam();
610
+ if (
611
+ this.#valueAtOpen !== null &&
612
+ this.#valueAtOpen !== JSON.stringify(this.value)
613
+ ) {
614
+ this.#emitChange();
615
+ }
616
+ this.#valueAtOpen = null;
801
617
  this.dispatchEvent(new CustomEvent("close"));
802
618
  };
803
619
  this.#dialog.addEventListener("close", onDialogClose);
804
620
 
805
- this.#dialogOpenObserver = new MutationObserver(() => {
806
- const isOpen =
807
- this.#dialog.hasAttribute("open") &&
808
- this.#dialog.getAttribute("open") !== "false";
809
- if (!isOpen) onDialogClose();
810
- });
811
- this.#dialogOpenObserver.observe(this.#dialog, {
812
- attributes: true,
813
- attributeFilter: ["open"],
814
- });
815
-
816
621
  // Initialize built-in tabs (skip any overridden by custom slots)
817
622
  const builtinInits = {
818
623
  solid: () => this.#initSolidTab(),
@@ -828,7 +633,9 @@ class FigFillPicker extends HTMLElement {
828
633
  // Listen for input/change from custom tab content
829
634
  for (const modeName of Object.keys(this.#customSlots)) {
830
635
  if (builtinModes.includes(modeName)) continue;
831
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
636
+ const container = Array.from(
637
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
638
+ ).find((candidate) => candidate.dataset.tab === modeName);
832
639
  if (!container) continue;
833
640
  container.addEventListener("input", (e) => {
834
641
  if (e.target === this) return;
@@ -845,15 +652,19 @@ class FigFillPicker extends HTMLElement {
845
652
  }
846
653
  }
847
654
 
848
- #switchTab(tabName) {
655
+ #switchTab(tabName, { emit = true } = {}) {
849
656
  // Only allow switching to modes that have a tab container in the dialog
850
- const tab = this.#dialog?.querySelector(
851
- `.fig-fill-picker-tab[data-tab="${tabName}"]`,
852
- );
657
+ const tab = Array.from(
658
+ this.#dialog?.querySelectorAll(".fig-fill-picker-tab") ?? [],
659
+ ).find((candidate) => candidate.dataset.tab === tabName);
853
660
  if (!tab) return;
854
661
 
662
+ const previousTab = this.#activeTab;
855
663
  this.#activeTab = tabName;
856
664
  this.#fillType = tabName;
665
+ if (previousTab === "webcam" && tabName !== "webcam") {
666
+ this.#stopWebcam();
667
+ }
857
668
 
858
669
  // Update dropdown selection (only exists if not locked)
859
670
  const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
@@ -890,8 +701,10 @@ class FigFillPicker extends HTMLElement {
890
701
  });
891
702
  }
892
703
 
704
+ if (tabName === "webcam") this.#webcamStart?.();
705
+
893
706
  this.#updateSwatch();
894
- this.#emitInput();
707
+ if (emit) this.#emitInput();
895
708
  }
896
709
 
897
710
  // ============ SOLID TAB ============
@@ -978,7 +791,7 @@ class FigFillPicker extends HTMLElement {
978
791
 
979
792
  // Setup color input mode dropdown
980
793
  const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
981
- modeDropdown.addEventListener("input", (e) => {
794
+ modeDropdown.addEventListener("change", (e) => {
982
795
  this.#colorInputMode = e.target.value;
983
796
  this.#rebuildColorInputFields();
984
797
  });
@@ -1446,7 +1259,9 @@ class FigFillPicker extends HTMLElement {
1446
1259
  <fig-icon name="add"></fig-icon>
1447
1260
  </fig-button>
1448
1261
  </fig-header>
1449
- <div class="fig-fill-picker-gradient-stops-list"></div>
1262
+ <div class="fig-fill-picker-gradient-stops-list">
1263
+ <fig-reorder></fig-reorder>
1264
+ </div>
1450
1265
  </div>
1451
1266
  <div class="fig-fill-picker-gradient-interpolation">
1452
1267
  <fig-header class="fig-fill-picker-gradient-interpolation-header" borderless>
@@ -1493,7 +1308,6 @@ class FigFillPicker extends HTMLElement {
1493
1308
  this.#updateGradientUI();
1494
1309
  this.#emitInput();
1495
1310
  };
1496
- typeDropdown.addEventListener("input", handleTypeChange);
1497
1311
  typeDropdown.addEventListener("change", handleTypeChange);
1498
1312
 
1499
1313
  const interpolationDropdown = container.querySelector(
@@ -1515,7 +1329,6 @@ class FigFillPicker extends HTMLElement {
1515
1329
  this.#updateGradientUI();
1516
1330
  this.#emitInput();
1517
1331
  };
1518
- interpolationDropdown?.addEventListener("input", handleInterpolationChange);
1519
1332
  interpolationDropdown?.addEventListener(
1520
1333
  "change",
1521
1334
  handleInterpolationChange,
@@ -1536,12 +1349,14 @@ class FigFillPicker extends HTMLElement {
1536
1349
  const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
1537
1350
  const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
1538
1351
  cxInput?.addEventListener("input", (e) => {
1539
- this.#gradient.centerX = parseFloat(e.target.value) || 50;
1352
+ const value = Number.parseFloat(e.target.value);
1353
+ this.#gradient.centerX = Number.isFinite(value) ? value : 50;
1540
1354
  this.#updateGradientPreview();
1541
1355
  this.#emitInput();
1542
1356
  });
1543
1357
  cyInput?.addEventListener("input", (e) => {
1544
- this.#gradient.centerY = parseFloat(e.target.value) || 50;
1358
+ const value = Number.parseFloat(e.target.value);
1359
+ this.#gradient.centerY = Number.isFinite(value) ? value : 50;
1545
1360
  this.#updateGradientPreview();
1546
1361
  this.#emitInput();
1547
1362
  });
@@ -1580,6 +1395,7 @@ class FigFillPicker extends HTMLElement {
1580
1395
  if (gradientBarInput) {
1581
1396
  const syncFromBarInput = (e) => {
1582
1397
  e.stopPropagation();
1398
+ if (this.#syncingGradientBar) return;
1583
1399
  const detail = e.detail;
1584
1400
  if (!detail?.gradient) return;
1585
1401
  this.#gradient = normalizeGradientConfig({
@@ -1598,6 +1414,152 @@ class FigFillPicker extends HTMLElement {
1598
1414
  this.#emitChange();
1599
1415
  });
1600
1416
  }
1417
+
1418
+ const stopsReorder = container.querySelector(
1419
+ ".fig-fill-picker-gradient-stops-list > fig-reorder",
1420
+ );
1421
+ stopsReorder?.addEventListener("reorder", (event) => {
1422
+ this.#handleGradientStopsReorder(event);
1423
+ });
1424
+ }
1425
+
1426
+ #formatStopColorValue(stop) {
1427
+ const hex = String(stop.color || "#888888")
1428
+ .replace(/^#/, "")
1429
+ .slice(0, 6);
1430
+ const opacity = stop.opacity ?? 100;
1431
+ if (opacity >= 100) return `#${hex}`;
1432
+ const alpha = Math.round((opacity / 100) * 255)
1433
+ .toString(16)
1434
+ .padStart(2, "0");
1435
+ return `#${hex}${alpha}`;
1436
+ }
1437
+
1438
+ #readGradientStopColor(colorInput) {
1439
+ if (!(colorInput instanceof HTMLElement)) return "#888888";
1440
+
1441
+ if (colorInput.hexOpaque) {
1442
+ return this.#normalizeStopHex(colorInput.hexOpaque);
1443
+ }
1444
+
1445
+ const liveValue = colorInput.value;
1446
+ if (typeof liveValue === "string" && liveValue.startsWith("#")) {
1447
+ return this.#normalizeStopHex(
1448
+ liveValue.length > 7 ? liveValue.slice(0, 7) : liveValue,
1449
+ );
1450
+ }
1451
+
1452
+ const textInput = colorInput.querySelector(
1453
+ 'fig-input-text:not([type="number"])',
1454
+ );
1455
+ const textHex = String(
1456
+ textInput?.value ?? textInput?.getAttribute("value") ?? "",
1457
+ )
1458
+ .replace(/#/g, "")
1459
+ .slice(0, 6);
1460
+ if (/^[0-9A-Fa-f]{6}$/.test(textHex)) {
1461
+ return `#${textHex.toUpperCase()}`;
1462
+ }
1463
+
1464
+ const attr = colorInput.getAttribute("value");
1465
+ if (typeof attr === "string" && attr.startsWith("#")) {
1466
+ return this.#normalizeStopHex(
1467
+ attr.length > 7 ? attr.slice(0, 7) : attr,
1468
+ );
1469
+ }
1470
+
1471
+ return "#888888";
1472
+ }
1473
+
1474
+ #normalizeStopHex(color) {
1475
+ const hex = String(color || "#888888")
1476
+ .replace(/^#/, "")
1477
+ .slice(0, 6);
1478
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return "#888888";
1479
+ return `#${hex.toUpperCase()}`;
1480
+ }
1481
+
1482
+ #readGradientStopFromRow(row) {
1483
+ const posInput = row.querySelector(".fig-fill-picker-stop-position");
1484
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1485
+
1486
+ const position =
1487
+ parseFloat(posInput?.value ?? posInput?.getAttribute("value") ?? "0") ||
1488
+ 0;
1489
+
1490
+ const color = this.#readGradientStopColor(colorInput);
1491
+ let opacity = 100;
1492
+
1493
+ if (colorInput instanceof HTMLElement && colorInput.rgba?.a !== undefined) {
1494
+ opacity = Math.round(colorInput.rgba.a * 100);
1495
+ } else {
1496
+ const oldIndex = parseInt(row.dataset.index, 10);
1497
+ if (!isNaN(oldIndex) && this.#gradient.stops[oldIndex]) {
1498
+ opacity = this.#gradient.stops[oldIndex].opacity ?? 100;
1499
+ }
1500
+ }
1501
+
1502
+ return { position, color, opacity };
1503
+ }
1504
+
1505
+ #syncGradientStopRow(row) {
1506
+ const index = parseInt(row.dataset.index, 10);
1507
+ if (isNaN(index) || index < 0 || index >= this.#gradient.stops.length) {
1508
+ return;
1509
+ }
1510
+ const stop = {
1511
+ ...this.#gradient.stops[index],
1512
+ ...this.#readGradientStopFromRow(row),
1513
+ };
1514
+ this.#gradient.stops[index] = stop;
1515
+ // Persist to the input value attributes so that reordering (which detaches
1516
+ // and re-attaches the row's DOM nodes) doesn't reset them to stale markup.
1517
+ this.#persistGradientStopRowAttrs(row, stop);
1518
+ }
1519
+
1520
+ #persistGradientStopRowAttrs(row, stop) {
1521
+ const posInput = row.querySelector(".fig-fill-picker-stop-position");
1522
+ if (posInput) posInput.setAttribute("value", String(stop.position));
1523
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1524
+ if (colorInput) {
1525
+ colorInput.setAttribute("value", this.#formatStopColorValue(stop));
1526
+ }
1527
+ }
1528
+
1529
+ #handleGradientStopsReorder(event) {
1530
+ const { oldIndex, newIndex } = event.detail ?? {};
1531
+ if (
1532
+ oldIndex == null ||
1533
+ newIndex == null ||
1534
+ oldIndex === newIndex ||
1535
+ oldIndex < 0 ||
1536
+ newIndex < 0 ||
1537
+ oldIndex >= this.#gradient.stops.length ||
1538
+ newIndex >= this.#gradient.stops.length
1539
+ ) {
1540
+ return;
1541
+ }
1542
+
1543
+ const reorder = event.currentTarget;
1544
+ const rows = reorder.querySelectorAll(".fig-fill-picker-gradient-stop-row");
1545
+
1546
+ // Rows still carry their pre-drag data-index; flush live inputs first.
1547
+ rows.forEach((row) => this.#syncGradientStopRow(row));
1548
+
1549
+ const [stop] = this.#gradient.stops.splice(oldIndex, 1);
1550
+ this.#gradient.stops.splice(newIndex, 0, stop);
1551
+
1552
+ rows.forEach((row, index) => {
1553
+ row.dataset.index = String(index);
1554
+ });
1555
+
1556
+ this.#syncingGradientBar = true;
1557
+ try {
1558
+ this.#updateGradientPreview();
1559
+ this.#emitInput();
1560
+ } finally {
1561
+ this.#syncingGradientBar = false;
1562
+ }
1601
1563
  }
1602
1564
 
1603
1565
  #updateGradientUI() {
@@ -1647,6 +1609,7 @@ class FigFillPicker extends HTMLElement {
1647
1609
  ".fig-fill-picker-gradient-bar-input",
1648
1610
  );
1649
1611
  if (barInput) {
1612
+ this.#syncingGradientBar = true;
1650
1613
  barInput.setAttribute(
1651
1614
  "value",
1652
1615
  JSON.stringify({
@@ -1654,6 +1617,7 @@ class FigFillPicker extends HTMLElement {
1654
1617
  gradient: gradientToValueShape(this.#gradient),
1655
1618
  }),
1656
1619
  );
1620
+ this.#syncingGradientBar = false;
1657
1621
  }
1658
1622
 
1659
1623
  this.#updateSwatch();
@@ -1665,9 +1629,10 @@ class FigFillPicker extends HTMLElement {
1665
1629
  const list = this.#dialog.querySelector(
1666
1630
  ".fig-fill-picker-gradient-stops-list",
1667
1631
  );
1668
- if (!list) return;
1632
+ const reorder = list?.querySelector("fig-reorder");
1633
+ if (!list || !reorder) return;
1669
1634
 
1670
- const existingRows = list.querySelectorAll(
1635
+ const existingRows = reorder.querySelectorAll(
1671
1636
  ".fig-fill-picker-gradient-stop-row",
1672
1637
  );
1673
1638
 
@@ -1678,7 +1643,9 @@ class FigFillPicker extends HTMLElement {
1678
1643
  const posInput = row.querySelector(".fig-fill-picker-stop-position");
1679
1644
  if (posInput) posInput.setAttribute("value", stop.position);
1680
1645
  const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1681
- if (colorInput) colorInput.setAttribute("value", stop.color);
1646
+ if (colorInput) {
1647
+ colorInput.setAttribute("value", this.#formatStopColorValue(stop));
1648
+ }
1682
1649
  const removeBtn = row.querySelector(".fig-fill-picker-stop-remove");
1683
1650
  if (removeBtn) {
1684
1651
  if (this.#gradient.stops.length <= 2)
@@ -1693,16 +1660,19 @@ class FigFillPicker extends HTMLElement {
1693
1660
  }
1694
1661
 
1695
1662
  #rebuildGradientStopsList(list) {
1696
- list.innerHTML = this.#gradient.stops
1663
+ const reorder = list.querySelector("fig-reorder");
1664
+ if (!reorder) return;
1665
+
1666
+ reorder.innerHTML = this.#gradient.stops
1697
1667
  .map(
1698
1668
  (stop, index) => `
1699
1669
  <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
1700
1670
  <fig-input-number class="fig-fill-picker-stop-position" aria-label="Gradient stop position" min="0" max="100" value="${
1701
1671
  stop.position
1702
1672
  }" units="%"></fig-input-number>
1703
- <fig-input-color class="fig-fill-picker-stop-color" aria-label="Gradient stop color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
1704
- stop.color
1705
- }"></fig-input-color>
1673
+ <fig-input-color class="fig-fill-picker-stop-color" aria-label="Gradient stop color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${this.#formatStopColorValue(
1674
+ stop,
1675
+ )}"></fig-input-color>
1706
1676
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
1707
1677
  this.#gradient.stops.length <= 2 ? "disabled" : ""
1708
1678
  } aria-label="Remove gradient stop">
@@ -1713,16 +1683,13 @@ class FigFillPicker extends HTMLElement {
1713
1683
  )
1714
1684
  .join("");
1715
1685
 
1716
- list
1686
+ reorder
1717
1687
  .querySelectorAll(".fig-fill-picker-gradient-stop-row")
1718
1688
  .forEach((row) => {
1719
- const index = parseInt(row.dataset.index);
1720
-
1721
1689
  row
1722
1690
  .querySelector(".fig-fill-picker-stop-position")
1723
- .addEventListener("input", (e) => {
1724
- this.#gradient.stops[index].position =
1725
- parseFloat(e.target.value) || 0;
1691
+ .addEventListener("input", () => {
1692
+ this.#syncGradientStopRow(row);
1726
1693
  this.#updateGradientPreview();
1727
1694
  this.#emitInput();
1728
1695
  });
@@ -1738,20 +1705,24 @@ class FigFillPicker extends HTMLElement {
1738
1705
  });
1739
1706
  }
1740
1707
 
1741
- stopColor.addEventListener("input", (e) => {
1742
- this.#gradient.stops[index].color =
1743
- e.target.hexOpaque || e.target.value;
1744
- const a = e.detail?.rgba?.a;
1745
- if (a !== undefined) {
1746
- this.#gradient.stops[index].opacity = Math.round(a * 100);
1708
+ const syncStopColor = () => {
1709
+ this.#syncGradientStopRow(row);
1710
+ this.#syncingGradientBar = true;
1711
+ try {
1712
+ this.#updateGradientPreview();
1713
+ this.#emitInput();
1714
+ } finally {
1715
+ this.#syncingGradientBar = false;
1747
1716
  }
1748
- this.#updateGradientPreview();
1749
- this.#emitInput();
1750
- });
1717
+ };
1718
+
1719
+ stopColor.addEventListener("input", syncStopColor);
1720
+ stopColor.addEventListener("change", syncStopColor);
1751
1721
 
1752
1722
  row
1753
1723
  .querySelector(".fig-fill-picker-stop-remove")
1754
1724
  .addEventListener("click", () => {
1725
+ const index = parseInt(row.dataset.index, 10);
1755
1726
  if (this.#gradient.stops.length > 2) {
1756
1727
  this.#gradient.stops.splice(index, 1);
1757
1728
  this.#updateGradientUI();
@@ -2081,23 +2052,31 @@ class FigFillPicker extends HTMLElement {
2081
2052
  );
2082
2053
 
2083
2054
  const startWebcam = async (deviceId = null) => {
2055
+ this.#stopWebcam();
2056
+ const requestId = this.#webcamRequestId;
2084
2057
  try {
2085
2058
  const constraints = {
2086
2059
  video: deviceId ? { deviceId: { exact: deviceId } } : true,
2087
2060
  };
2088
2061
 
2089
- if (this.#webcam.stream) {
2090
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
2062
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
2063
+ if (
2064
+ requestId !== this.#webcamRequestId ||
2065
+ !this.isConnected ||
2066
+ this.#activeTab !== "webcam"
2067
+ ) {
2068
+ stream.getTracks().forEach((track) => track.stop());
2069
+ return;
2091
2070
  }
2092
2071
 
2093
- this.#webcam.stream =
2094
- await navigator.mediaDevices.getUserMedia(constraints);
2095
- video.srcObject = this.#webcam.stream;
2072
+ this.#webcam.stream = stream;
2073
+ video.srcObject = stream;
2096
2074
  video.style.display = "block";
2097
2075
  status.style.display = "none";
2098
2076
 
2099
2077
  // Enumerate cameras
2100
2078
  const devices = await navigator.mediaDevices.enumerateDevices();
2079
+ if (requestId !== this.#webcamRequestId) return;
2101
2080
  const cameras = devices.filter((d) => d.kind === "videoinput");
2102
2081
 
2103
2082
  if (cameras.length > 1) {
@@ -2129,6 +2108,7 @@ class FigFillPicker extends HTMLElement {
2129
2108
  .forEach((option) => option.remove());
2130
2109
  }
2131
2110
  } catch (err) {
2111
+ if (requestId !== this.#webcamRequestId) return;
2132
2112
  console.error("Webcam error:", err.name, err.message);
2133
2113
  let message = "Camera access denied";
2134
2114
  if (err.name === "NotAllowedError") {
@@ -2142,21 +2122,15 @@ class FigFillPicker extends HTMLElement {
2142
2122
  } else if (!window.isSecureContext) {
2143
2123
  message = "Camera requires secure context";
2144
2124
  }
2145
- status.innerHTML = `<span>${message}</span>`;
2125
+ status.replaceChildren();
2126
+ const messageElement = document.createElement("span");
2127
+ messageElement.textContent = message;
2128
+ status.appendChild(messageElement);
2146
2129
  status.style.display = "flex";
2147
2130
  video.style.display = "none";
2148
2131
  }
2149
2132
  };
2150
-
2151
- this.#webcamTabObserver = new MutationObserver(() => {
2152
- if (container.style.display !== "none" && !this.#webcam.stream) {
2153
- startWebcam();
2154
- }
2155
- });
2156
- this.#webcamTabObserver.observe(container, {
2157
- attributes: true,
2158
- attributeFilter: ["style"],
2159
- });
2133
+ this.#webcamStart = startWebcam;
2160
2134
 
2161
2135
  cameraSelect.addEventListener("change", (e) => {
2162
2136
  startWebcam(e.target.value);
@@ -2552,6 +2526,15 @@ class FigFillPicker extends HTMLElement {
2552
2526
  case "disabled":
2553
2527
  this.#syncTriggerA11y();
2554
2528
  break;
2529
+ case "alpha":
2530
+ case "mode":
2531
+ case "experimental": {
2532
+ if (!this.#dialog) break;
2533
+ const wasOpen = this.#dialog.open;
2534
+ this.#discardDialog();
2535
+ if (wasOpen && this.isConnected) this.#openDialog();
2536
+ break;
2537
+ }
2555
2538
  case "aria-label":
2556
2539
  case "aria-labelledby":
2557
2540
  case "aria-describedby":