@rogieking/figui3 4.15.10 → 5.1.1

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 ADDED
@@ -0,0 +1,2251 @@
1
+ // FigFillPicker
2
+ const GRADIENT_INTERPOLATION_SPACES = [
3
+ "srgb",
4
+ "srgb-linear",
5
+ "display-p3",
6
+ "oklab",
7
+ "oklch",
8
+ ];
9
+ const GRADIENT_HUE_INTERPOLATIONS = [
10
+ "shorter",
11
+ "longer",
12
+ "increasing",
13
+ "decreasing",
14
+ ];
15
+
16
+ function normalizeGradientConfig(gradient) {
17
+ const next = { ...(gradient ?? {}) };
18
+ let interpolationSpace = String(
19
+ next.interpolationSpace ?? "oklab",
20
+ ).toLowerCase();
21
+ if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) {
22
+ interpolationSpace = "oklab";
23
+ }
24
+ if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") {
25
+ interpolationSpace = "oklab";
26
+ }
27
+ next.interpolationSpace = interpolationSpace;
28
+
29
+ const hueInterpolation = String(
30
+ next.hueInterpolation ?? "shorter",
31
+ ).toLowerCase();
32
+ next.hueInterpolation = GRADIENT_HUE_INTERPOLATIONS.includes(hueInterpolation)
33
+ ? hueInterpolation
34
+ : "shorter";
35
+ return next;
36
+ }
37
+
38
+ function gradientToValueShape(gradient) {
39
+ const normalized = normalizeGradientConfig(gradient);
40
+ const output = {
41
+ ...normalized,
42
+ interpolationSpace: normalized.interpolationSpace,
43
+ };
44
+ if (normalized.interpolationSpace === "oklch") {
45
+ output.hueInterpolation = normalized.hueInterpolation;
46
+ } else {
47
+ delete output.hueInterpolation;
48
+ }
49
+ return output;
50
+ }
51
+
52
+ function gradientInterpolationClause(gradient) {
53
+ const normalized = normalizeGradientConfig(gradient);
54
+ if (normalized.interpolationSpace === "oklch") {
55
+ return `in oklch ${normalized.hueInterpolation} hue`;
56
+ }
57
+ return `in ${normalized.interpolationSpace}`;
58
+ }
59
+
60
+ /**
61
+ * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
62
+ * Uses display: contents and wraps a trigger element that opens a dialog picker.
63
+ *
64
+ * @attr {string} value - JSON-encoded fill value
65
+ * @attr {boolean} disabled - Whether the picker is disabled
66
+ * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
67
+ * @attr {string} dialog-position - Position of the popup (default: "left")
68
+ */
69
+ class FigFillPicker extends HTMLElement {
70
+ #trigger = null;
71
+ #chit = null;
72
+ #dialog = null;
73
+ #activeTab = "solid";
74
+ anchorElement = null;
75
+
76
+ // Fill state
77
+ #fillType = "solid";
78
+ #gamut = "srgb"; // "srgb" or "display-p3"
79
+ #color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9
80
+ #colorInputMode = "hex";
81
+ #gradient = {
82
+ type: "linear",
83
+ angle: 0,
84
+ centerX: 50,
85
+ centerY: 50,
86
+ interpolationSpace: "oklab",
87
+ hueInterpolation: "shorter",
88
+ stops: [
89
+ { position: 0, color: "#D9D9D9", opacity: 100 },
90
+ { position: 100, color: "#737373", opacity: 100 },
91
+ ],
92
+ };
93
+ #image = { url: null, scaleMode: "fill", scale: 50 };
94
+ #video = { url: null, scaleMode: "fill", scale: 50 };
95
+ #webcam = { stream: null, snapshot: null };
96
+
97
+ // Custom mode slots and data
98
+ #customSlots = {};
99
+ #customData = {};
100
+
101
+ // DOM references for solid tab
102
+ #colorArea = null;
103
+ #colorAreaHandle = null;
104
+ #hueSlider = null;
105
+ #opacitySlider = null;
106
+ #isDraggingColor = false;
107
+ #teardownColorAreaEvents = null;
108
+ #dialogOpenObserver = null;
109
+ #webcamTabObserver = null;
110
+
111
+ constructor() {
112
+ super();
113
+ }
114
+
115
+ static get observedAttributes() {
116
+ return ["value", "disabled", "alpha", "mode", "experimental"];
117
+ }
118
+
119
+ connectedCallback() {
120
+ // Use display: contents
121
+ this.style.display = "contents";
122
+
123
+ requestAnimationFrame(() => {
124
+ this.#setupTrigger();
125
+ this.#parseValue();
126
+ this.#updateChit();
127
+ });
128
+ }
129
+
130
+ disconnectedCallback() {
131
+ if (this.#teardownColorAreaEvents) {
132
+ this.#teardownColorAreaEvents();
133
+ this.#teardownColorAreaEvents = null;
134
+ }
135
+ if (this.#dialogOpenObserver) {
136
+ this.#dialogOpenObserver.disconnect();
137
+ this.#dialogOpenObserver = null;
138
+ }
139
+ if (this.#webcamTabObserver) {
140
+ this.#webcamTabObserver.disconnect();
141
+ this.#webcamTabObserver = null;
142
+ }
143
+ if (this.#webcam.stream) {
144
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
145
+ this.#webcam.stream = null;
146
+ }
147
+ if (this.#webcam.snapshot?.startsWith("blob:")) {
148
+ URL.revokeObjectURL(this.#webcam.snapshot);
149
+ this.#webcam.snapshot = null;
150
+ }
151
+ if (this.#video.url && this.#video.url.startsWith("blob:")) {
152
+ URL.revokeObjectURL(this.#video.url);
153
+ }
154
+ if (this.#chit) this.#chit.removeAttribute("selected");
155
+ if (this.#dialog) {
156
+ this.#dialog.close();
157
+ this.#dialog.remove();
158
+ this.#dialog = null;
159
+ }
160
+ }
161
+
162
+ #setupTrigger() {
163
+ const child = Array.from(this.children).find(
164
+ (el) => !el.getAttribute("slot")?.startsWith("mode-"),
165
+ );
166
+
167
+ if (!child) {
168
+ // Scenario 1: Empty - create fig-chit
169
+ this.#chit = document.createElement("fig-chit");
170
+ this.#chit.setAttribute("background", "#D9D9D9");
171
+ this.appendChild(this.#chit);
172
+ this.#trigger = this.#chit;
173
+ } else if (child.tagName === "FIG-CHIT") {
174
+ // Scenario 2: Has fig-chit - use and populate it
175
+ this.#chit = child;
176
+ this.#trigger = child;
177
+ } else {
178
+ // Scenario 3: Other element - trigger only, no populate
179
+ this.#trigger = child;
180
+ this.#chit = null;
181
+ }
182
+
183
+ this.#trigger.addEventListener("click", (e) => {
184
+ if (this.hasAttribute("disabled")) return;
185
+ e.stopPropagation();
186
+ e.preventDefault();
187
+ this.#openDialog();
188
+ });
189
+
190
+ // Prevent fig-chit's internal color input from opening system picker
191
+ if (this.#chit) {
192
+ requestAnimationFrame(() => {
193
+ const input = this.#chit.querySelector('input[type="color"]');
194
+ if (input) {
195
+ input.style.pointerEvents = "none";
196
+ }
197
+ });
198
+ }
199
+ }
200
+
201
+ #parseValue() {
202
+ const valueAttr = this.getAttribute("value");
203
+ if (!valueAttr) return;
204
+
205
+ const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
206
+
207
+ try {
208
+ const parsed = JSON.parse(valueAttr);
209
+ if (parsed.type) this.#fillType = parsed.type;
210
+ if (parsed.color) {
211
+ // Handle both hex string and HSV object
212
+ if (typeof parsed.color === "string") {
213
+ this.#color = this.#hexToHSV(parsed.color);
214
+ } else if (
215
+ typeof parsed.color === "object" &&
216
+ parsed.color.h !== undefined
217
+ ) {
218
+ this.#color = parsed.color;
219
+ }
220
+ }
221
+ // Parse opacity (0-100) and convert to alpha (0-1)
222
+ if (parsed.opacity !== undefined) {
223
+ this.#color.a = parsed.opacity / 100;
224
+ }
225
+ if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
226
+ this.#gamut = parsed.colorSpace;
227
+ }
228
+ if (parsed.gradient) {
229
+ this.#gradient = normalizeGradientConfig({
230
+ ...this.#gradient,
231
+ ...parsed.gradient,
232
+ });
233
+ }
234
+ if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
235
+ if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
236
+
237
+ // Store full parsed data for custom (non-built-in) types
238
+ if (parsed.type && !builtinTypes.includes(parsed.type)) {
239
+ const { type, ...rest } = parsed;
240
+ this.#customData[parsed.type] = rest;
241
+ }
242
+ } catch (e) {
243
+ // If not JSON, treat as hex color
244
+ if (valueAttr.startsWith("#")) {
245
+ this.#fillType = "solid";
246
+ this.#color = this.#hexToHSV(valueAttr);
247
+ }
248
+ }
249
+ }
250
+
251
+ #updateChit() {
252
+ if (!this.#chit) return;
253
+
254
+ let bg;
255
+ let bgSize = "cover";
256
+ let bgPosition = "center";
257
+
258
+ switch (this.#fillType) {
259
+ case "solid":
260
+ bg = this.#hsvToHex(this.#color);
261
+ break;
262
+ case "gradient":
263
+ bg = this.#getGradientCSS();
264
+ break;
265
+ case "image":
266
+ if (this.#image.url) {
267
+ bg = `url(${this.#image.url})`;
268
+ const sizing = this.#getBackgroundSizing(
269
+ this.#image.scaleMode,
270
+ this.#image.scale,
271
+ );
272
+ bgSize = sizing.size;
273
+ bgPosition = sizing.position;
274
+ } else {
275
+ bg = "";
276
+ }
277
+ break;
278
+ case "video":
279
+ if (this.#video.url) {
280
+ bg = `url(${this.#video.url})`;
281
+ const sizing = this.#getBackgroundSizing(
282
+ this.#video.scaleMode,
283
+ this.#video.scale,
284
+ );
285
+ bgSize = sizing.size;
286
+ bgPosition = sizing.position;
287
+ } else {
288
+ bg = "";
289
+ }
290
+ break;
291
+ default:
292
+ const slot = this.#customSlots[this.#fillType];
293
+ bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
294
+ }
295
+
296
+ this.#chit.setAttribute("background", bg);
297
+ this.#chit.style.setProperty("--chit-bg-size", bgSize);
298
+ this.#chit.style.setProperty("--chit-bg-position", bgPosition);
299
+
300
+ if (this.#fillType === "solid") {
301
+ this.#chit.setAttribute("alpha", this.#color.a);
302
+ } else {
303
+ this.#chit.removeAttribute("alpha");
304
+ }
305
+ }
306
+
307
+ #getBackgroundSizing(scaleMode, scale) {
308
+ switch (scaleMode) {
309
+ case "fill":
310
+ return { size: "cover", position: "center" };
311
+ case "fit":
312
+ return { size: "contain", position: "center" };
313
+ case "crop":
314
+ return { size: "cover", position: "center" };
315
+ case "tile":
316
+ return { size: `${scale}%`, position: "top left" };
317
+ default:
318
+ return { size: "cover", position: "center" };
319
+ }
320
+ }
321
+
322
+ #openDialog() {
323
+ if (!this.#dialog) {
324
+ this.#createDialog();
325
+ }
326
+
327
+ this.#switchTab(this.#fillType);
328
+
329
+ const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
330
+ if (gamutEl) gamutEl.value = this.#gamut;
331
+
332
+ if (this.#chit) this.#chit.setAttribute("selected", "true");
333
+
334
+ this.#dialog.open = true;
335
+
336
+ requestAnimationFrame(() => {
337
+ requestAnimationFrame(() => {
338
+ this.#drawColorArea();
339
+ this.#updateHandlePosition();
340
+ });
341
+ });
342
+ }
343
+
344
+ open() {
345
+ this.#openDialog();
346
+ }
347
+
348
+ close() {
349
+ if (this.#dialog) this.#dialog.open = false;
350
+ }
351
+
352
+ #createDialog() {
353
+ // Collect slotted custom mode content before any DOM changes
354
+ this.#customSlots = {};
355
+ this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
356
+ const modeName = el.getAttribute("slot").slice(5);
357
+ this.#customSlots[modeName] = {
358
+ element: el,
359
+ label:
360
+ el.getAttribute("label") ||
361
+ modeName.charAt(0).toUpperCase() + modeName.slice(1),
362
+ };
363
+ });
364
+
365
+ this.#dialog = document.createElement("dialog", { is: "fig-popup" });
366
+ this.#dialog.setAttribute("is", "fig-popup");
367
+ this.#dialog.setAttribute("drag", "true");
368
+ this.#dialog.setAttribute("handle", "fig-header");
369
+ this.#dialog.setAttribute("autoresize", "false");
370
+ this.#dialog.classList.add("fig-fill-picker-dialog");
371
+
372
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
373
+ const dialogPosition = this.getAttribute("dialog-position") || "left";
374
+ this.#dialog.setAttribute("position", dialogPosition);
375
+ this.#dialog.setAttribute("offset", this.getAttribute("dialog-offset") || "8 8");
376
+
377
+ const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
378
+ const builtinLabels = {
379
+ solid: "Solid",
380
+ gradient: "Gradient",
381
+ image: "Image",
382
+ video: "Video",
383
+ webcam: "Webcam",
384
+ };
385
+
386
+ // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
387
+ const mode = this.getAttribute("mode");
388
+ let allowedModes;
389
+ if (mode) {
390
+ const requested = mode.split(",").map((m) => m.trim().toLowerCase());
391
+ allowedModes = requested.filter(
392
+ (m) => builtinModes.includes(m) || this.#customSlots[m],
393
+ );
394
+ if (allowedModes.length === 0) allowedModes = [...builtinModes];
395
+ } else {
396
+ allowedModes = [...builtinModes];
397
+ }
398
+
399
+ // Build labels map: built-in labels + custom slot labels
400
+ const modeLabels = { ...builtinLabels };
401
+ for (const [name, { label }] of Object.entries(this.#customSlots)) {
402
+ modeLabels[name] = label;
403
+ }
404
+
405
+ if (!allowedModes.includes(this.#fillType)) {
406
+ this.#fillType = allowedModes[0];
407
+ this.#activeTab = allowedModes[0];
408
+ }
409
+
410
+ const experimental = this.getAttribute("experimental");
411
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
412
+
413
+ let headerContent;
414
+ if (allowedModes.length === 1) {
415
+ headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
416
+ } else {
417
+ const options = allowedModes
418
+ .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
419
+ .join("\n ");
420
+ headerContent = `<fig-dropdown class="fig-fill-picker-type" ${expAttr} value="${this.#fillType}">
421
+ ${options}
422
+ </fig-dropdown>`;
423
+ }
424
+
425
+ // Generate tab containers for all allowed modes
426
+ const tabDivs = allowedModes
427
+ .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
428
+ .join("\n ");
429
+
430
+ const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}">
431
+ <option value="srgb">sRGB</option>
432
+ <option value="display-p3">Display P3</option>
433
+ </fig-dropdown>`;
434
+
435
+ this.#dialog.innerHTML = `
436
+ <fig-header>
437
+ ${headerContent}
438
+ ${gamutDropdown}
439
+ <fig-button icon variant="ghost" class="fig-fill-picker-close">
440
+ <fig-icon name="close"></fig-icon>
441
+ </fig-button>
442
+ </fig-header>
443
+ <fig-content>
444
+ ${tabDivs}
445
+ </fig-content>
446
+ `;
447
+
448
+ document.body.appendChild(this.#dialog);
449
+
450
+ // Populate custom tab containers and emit modeready
451
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
452
+ const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
453
+ if (!container) continue;
454
+
455
+ // Move children (not the element itself) for vanilla HTML usage
456
+ while (element.firstChild) {
457
+ container.appendChild(element.firstChild);
458
+ }
459
+
460
+ // Emit modeready so frameworks can render into the container
461
+ this.dispatchEvent(
462
+ new CustomEvent("modeready", {
463
+ bubbles: true,
464
+ detail: { mode: modeName, container },
465
+ }),
466
+ );
467
+ }
468
+
469
+ // Setup type dropdown switching (only if not locked)
470
+ const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
471
+ if (typeDropdown) {
472
+ typeDropdown.addEventListener("change", (e) => {
473
+ this.#switchTab(e.target.value);
474
+ });
475
+ }
476
+
477
+ // Setup gamut dropdown
478
+ const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
479
+ if (gamutEl) {
480
+ const handleGamutChange = (e) => {
481
+ const val = e.currentTarget?.value ?? e.target?.value ?? e.detail;
482
+ if (val && val !== this.#gamut) {
483
+ this.#gamut = val;
484
+ this.#onGamutChange();
485
+ }
486
+ };
487
+ gamutEl.addEventListener("input", handleGamutChange);
488
+ gamutEl.addEventListener("change", handleGamutChange);
489
+ }
490
+
491
+ this.#dialog
492
+ .querySelector(".fig-fill-picker-close")
493
+ .addEventListener("click", () => {
494
+ this.#dialog.open = false;
495
+ });
496
+
497
+ const onDialogClose = () => {
498
+ if (this.#chit) this.#chit.removeAttribute("selected");
499
+ this.#emitChange();
500
+ this.dispatchEvent(new CustomEvent("close"));
501
+ };
502
+ this.#dialog.addEventListener("close", onDialogClose);
503
+
504
+ this.#dialogOpenObserver = new MutationObserver(() => {
505
+ const isOpen =
506
+ this.#dialog.hasAttribute("open") &&
507
+ this.#dialog.getAttribute("open") !== "false";
508
+ if (!isOpen) onDialogClose();
509
+ });
510
+ this.#dialogOpenObserver.observe(this.#dialog, {
511
+ attributes: true,
512
+ attributeFilter: ["open"],
513
+ });
514
+
515
+ // Initialize built-in tabs (skip any overridden by custom slots)
516
+ const builtinInits = {
517
+ solid: () => this.#initSolidTab(),
518
+ gradient: () => this.#initGradientTab(),
519
+ image: () => this.#initImageTab(),
520
+ video: () => this.#initVideoTab(),
521
+ webcam: () => this.#initWebcamTab(),
522
+ };
523
+ for (const [name, init] of Object.entries(builtinInits)) {
524
+ if (!this.#customSlots[name] && allowedModes.includes(name)) init();
525
+ }
526
+
527
+ // Listen for input/change from custom tab content
528
+ for (const modeName of Object.keys(this.#customSlots)) {
529
+ if (builtinModes.includes(modeName)) continue;
530
+ const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
531
+ if (!container) continue;
532
+ container.addEventListener("input", (e) => {
533
+ if (e.target === this) return;
534
+ e.stopPropagation();
535
+ if (e.detail) this.#customData[modeName] = e.detail;
536
+ this.#emitInput();
537
+ });
538
+ container.addEventListener("change", (e) => {
539
+ if (e.target === this) return;
540
+ e.stopPropagation();
541
+ if (e.detail) this.#customData[modeName] = e.detail;
542
+ this.#emitChange();
543
+ });
544
+ }
545
+ }
546
+
547
+ #switchTab(tabName) {
548
+ // Only allow switching to modes that have a tab container in the dialog
549
+ const tab = this.#dialog?.querySelector(
550
+ `.fig-fill-picker-tab[data-tab="${tabName}"]`,
551
+ );
552
+ if (!tab) return;
553
+
554
+ this.#activeTab = tabName;
555
+ this.#fillType = tabName;
556
+
557
+ // Update dropdown selection (only exists if not locked)
558
+ const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
559
+ if (typeDropdown && typeDropdown.value !== tabName) {
560
+ typeDropdown.value = tabName;
561
+ }
562
+
563
+ // Show/hide tab content
564
+ const tabContents = this.#dialog.querySelectorAll(".fig-fill-picker-tab");
565
+ tabContents.forEach((content) => {
566
+ if (content.dataset.tab === tabName) {
567
+ content.style.display = "block";
568
+ } else {
569
+ content.style.display = "none";
570
+ }
571
+ });
572
+
573
+ // Zero out content padding for custom mode tabs
574
+ const contentEl = this.#dialog.querySelector("fig-content");
575
+ if (contentEl) {
576
+ contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
577
+ }
578
+
579
+ // Update tab-specific UI after visibility change
580
+ if (tabName === "gradient") {
581
+ // Use RAF to ensure layout is complete before updating angle input
582
+ requestAnimationFrame(() => {
583
+ this.#updateGradientUI();
584
+ const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input");
585
+ barInput?.refreshLayout?.();
586
+ requestAnimationFrame(() => {
587
+ barInput?.refreshLayout?.();
588
+ });
589
+ });
590
+ }
591
+
592
+ this.#updateChit();
593
+ this.#emitInput();
594
+ }
595
+
596
+ // ============ SOLID TAB ============
597
+ #initSolidTab() {
598
+ const container = this.#dialog.querySelector('[data-tab="solid"]');
599
+ const showAlpha = this.getAttribute("alpha") !== "false";
600
+ const experimental = this.getAttribute("experimental");
601
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
602
+
603
+ container.innerHTML = `
604
+ <fig-preview class="fig-fill-picker-color-area">
605
+ <canvas width="200" height="200"></canvas>
606
+ <fig-handle
607
+ type="color"
608
+ color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
609
+ data-no-color-picker
610
+ drag
611
+ drag-surface=".fig-fill-picker-color-area"
612
+ drag-axes="x,y"
613
+ drag-snapping="modifier"
614
+ ></fig-handle>
615
+ </fig-preview>
616
+ <div class="fig-fill-picker-sliders">
617
+ <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
618
+ <fig-slider type="hue" text="false" min="0" max="360" value="${
619
+ this.#color.h
620
+ }"></fig-slider>
621
+ ${
622
+ showAlpha
623
+ ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" value="${
624
+ this.#color.a * 100
625
+ }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
626
+ : ""
627
+ }
628
+ </div>
629
+ <fig-field class="fig-fill-picker-inputs">
630
+ <fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}">
631
+ <option value="hex">Hex</option>
632
+ <option value="rgb">RGB</option>
633
+ <option value="hsl">HSL</option>
634
+ <option value="hsb">HSB</option>
635
+ <option value="lab">LAB</option>
636
+ <option value="lch">LCH</option>
637
+ </fig-dropdown>
638
+ <span class="fig-fill-picker-input-fields"></span>
639
+ </fig-field>
640
+ `;
641
+
642
+ // Setup color area
643
+ this.#colorArea = container.querySelector("canvas");
644
+ this.#colorAreaHandle = container.querySelector("fig-handle");
645
+ this.#drawColorArea();
646
+ this.#updateHandlePosition();
647
+ this.#setupColorAreaEvents();
648
+
649
+ // Setup hue slider
650
+ this.#hueSlider = container.querySelector('fig-slider[type="hue"]');
651
+ this.#hueSlider.addEventListener("input", (e) => {
652
+ this.#color.h = parseFloat(e.target.value);
653
+ this.#drawColorArea();
654
+ this.#updateHandlePosition();
655
+ this.#updateColorInputs();
656
+ this.#emitInput();
657
+ });
658
+ this.#hueSlider.addEventListener("change", () => {
659
+ this.#emitChange();
660
+ });
661
+
662
+ // Setup opacity slider
663
+ if (showAlpha) {
664
+ this.#opacitySlider = container.querySelector(
665
+ 'fig-slider[type="opacity"]',
666
+ );
667
+ this.#opacitySlider.addEventListener("input", (e) => {
668
+ this.#color.a = parseFloat(e.target.value) / 100;
669
+ this.#updateColorInputs();
670
+ this.#emitInput();
671
+ });
672
+ this.#opacitySlider.addEventListener("change", () => {
673
+ this.#emitChange();
674
+ });
675
+ }
676
+
677
+ // Setup color input mode dropdown
678
+ const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
679
+ modeDropdown.addEventListener("input", (e) => {
680
+ this.#colorInputMode = e.target.value;
681
+ this.#rebuildColorInputFields();
682
+ });
683
+
684
+ // Build initial color input fields
685
+ this.#rebuildColorInputFields();
686
+
687
+ // Setup eyedropper
688
+ const eyedropper = container.querySelector(".fig-fill-picker-eyedropper");
689
+ if ("EyeDropper" in window) {
690
+ eyedropper.addEventListener("click", async () => {
691
+ try {
692
+ const dropper = new EyeDropper();
693
+ const result = await dropper.open();
694
+ this.#color = { ...this.#hexToHSV(result.sRGBHex), a: this.#color.a };
695
+ this.#drawColorArea();
696
+ this.#updateHandlePosition();
697
+ this.#updateColorInputs();
698
+ this.#emitInput();
699
+ } catch (e) {
700
+ // User cancelled or error
701
+ }
702
+ });
703
+ } else {
704
+ eyedropper.setAttribute("disabled", "");
705
+ eyedropper.title = "EyeDropper not supported in this browser";
706
+ }
707
+ }
708
+
709
+ #onGamutChange() {
710
+ // Recreate the solid canvas with the new color space
711
+ const solidContainer = this.#dialog?.querySelector('[data-tab="solid"]');
712
+ if (solidContainer) {
713
+ const oldCanvas = solidContainer.querySelector("canvas");
714
+ if (oldCanvas) {
715
+ const newCanvas = document.createElement("canvas");
716
+ newCanvas.width = oldCanvas.width;
717
+ newCanvas.height = oldCanvas.height;
718
+ oldCanvas.replaceWith(newCanvas);
719
+ this.#colorArea = newCanvas;
720
+ this.#setupColorAreaEvents();
721
+ }
722
+ this.#drawColorArea();
723
+ this.#updateHandlePosition();
724
+ }
725
+ // Refresh gradient preview if gradient tab exists
726
+ this.#updateGradientPreview();
727
+ this.#emitInput();
728
+ }
729
+
730
+ #drawColorArea() {
731
+ // Refresh canvas reference in case DOM changed
732
+ if (!this.#colorArea && this.#dialog) {
733
+ this.#colorArea = this.#dialog.querySelector('[data-tab="solid"] canvas');
734
+ }
735
+ if (!this.#colorArea) return;
736
+
737
+ const colorSpace = this.#gamut === "display-p3" ? "display-p3" : "srgb";
738
+ const ctx = this.#colorArea.getContext("2d", { colorSpace });
739
+ if (!ctx) return;
740
+
741
+ const width = this.#colorArea.width;
742
+ const height = this.#colorArea.height;
743
+
744
+ ctx.clearRect(0, 0, width, height);
745
+
746
+ const hue = this.#color.h;
747
+ const isP3 = this.#gamut === "display-p3";
748
+
749
+ const gradH = ctx.createLinearGradient(0, 0, width, 0);
750
+ if (isP3) {
751
+ gradH.addColorStop(0, "color(display-p3 1 1 1)");
752
+ const [r, g, b] = hslToP3(hue, 100, 50);
753
+ gradH.addColorStop(1, `color(display-p3 ${r} ${g} ${b})`);
754
+ } else {
755
+ gradH.addColorStop(0, "#FFFFFF");
756
+ gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
757
+ }
758
+
759
+ ctx.fillStyle = gradH;
760
+ ctx.fillRect(0, 0, width, height);
761
+
762
+ const gradV = ctx.createLinearGradient(0, 0, 0, height);
763
+ gradV.addColorStop(0, "rgba(0,0,0,0)");
764
+ gradV.addColorStop(1, "rgba(0,0,0,1)");
765
+
766
+ ctx.fillStyle = gradV;
767
+ ctx.fillRect(0, 0, width, height);
768
+ }
769
+
770
+ #updateHandlePosition(retryCount = 0) {
771
+ if (!this.#colorAreaHandle || !this.#colorArea) return;
772
+
773
+ const rect = this.#colorArea.getBoundingClientRect();
774
+
775
+ // If the canvas isn't visible yet (0 dimensions), schedule a retry (max 5 attempts)
776
+ if ((rect.width === 0 || rect.height === 0) && retryCount < 5) {
777
+ requestAnimationFrame(() => this.#updateHandlePosition(retryCount + 1));
778
+ return;
779
+ }
780
+
781
+ const xPct = Math.max(0, Math.min(100, this.#color.s));
782
+ const yPct = Math.max(0, Math.min(100, 100 - this.#color.v));
783
+
784
+ this.#colorAreaHandle.setAttribute("value", `${xPct}% ${yPct}%`);
785
+ this.#colorAreaHandle.setAttribute(
786
+ "color",
787
+ this.#hsvToHex({ ...this.#color, a: 1 }),
788
+ );
789
+ }
790
+
791
+ #updateColorFromAreaPosition(x, y, opts = {}) {
792
+ const { updateHandle = true, emitInput = true, emitChange = false } = opts;
793
+ this.#color.s = Math.max(0, Math.min(100, x * 100));
794
+ this.#color.v = Math.max(0, Math.min(100, (1 - y) * 100));
795
+ if (this.#colorAreaHandle) {
796
+ this.#colorAreaHandle.setAttribute(
797
+ "color",
798
+ this.#hsvToHex({ ...this.#color, a: 1 }),
799
+ );
800
+ }
801
+ if (updateHandle) this.#updateHandlePosition();
802
+ this.#updateColorInputs();
803
+ if (emitInput) this.#emitInput();
804
+ if (emitChange) this.#emitChange();
805
+ }
806
+
807
+ #setupColorAreaEvents() {
808
+ if (this.#teardownColorAreaEvents) {
809
+ this.#teardownColorAreaEvents();
810
+ this.#teardownColorAreaEvents = null;
811
+ }
812
+ if (!this.#colorArea || !this.#colorAreaHandle) return;
813
+
814
+ const colorAreaEl = this.#colorArea.parentElement || this.#colorArea;
815
+ const colorAreaHandleEl = this.#colorAreaHandle;
816
+
817
+ let isPlaneDragging = false;
818
+
819
+ const updatePlaneFromEvent = (e, opts = {}) => {
820
+ const rect = colorAreaEl.getBoundingClientRect();
821
+ if (rect.width === 0 || rect.height === 0) return;
822
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
823
+ const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
824
+ this.#updateColorFromAreaPosition(x / rect.width, y / rect.height, opts);
825
+ };
826
+
827
+ const onPlanePointerDown = (e) => {
828
+ if (e.button !== 0) return;
829
+ if (
830
+ e.target === colorAreaHandleEl ||
831
+ colorAreaHandleEl.contains(e.target)
832
+ )
833
+ return;
834
+ isPlaneDragging = true;
835
+ this.#isDraggingColor = true;
836
+ colorAreaEl.setPointerCapture(e.pointerId);
837
+ updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
838
+ };
839
+
840
+ const onPlanePointerMove = (e) => {
841
+ if (!isPlaneDragging) return;
842
+ if (e.buttons === 0) {
843
+ onPlaneDragEnd();
844
+ return;
845
+ }
846
+ updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
847
+ };
848
+
849
+ const onPlaneDragEnd = () => {
850
+ if (!isPlaneDragging) return;
851
+ isPlaneDragging = false;
852
+ this.#isDraggingColor = false;
853
+ this.#emitChange();
854
+ };
855
+
856
+ const onHandleInput = (e) => {
857
+ this.#isDraggingColor = true;
858
+ const px = e.detail?.px;
859
+ const py = e.detail?.py;
860
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return;
861
+ colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
862
+ this.#updateColorFromAreaPosition(px, py, {
863
+ updateHandle: false,
864
+ emitInput: true,
865
+ });
866
+ };
867
+
868
+ const onHandleChange = (e) => {
869
+ const px = e.detail?.px;
870
+ const py = e.detail?.py;
871
+ if (Number.isFinite(px) && Number.isFinite(py)) {
872
+ colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
873
+ this.#updateColorFromAreaPosition(px, py, {
874
+ updateHandle: false,
875
+ emitInput: false,
876
+ });
877
+ }
878
+ this.#isDraggingColor = false;
879
+ this.#emitChange();
880
+ };
881
+
882
+ colorAreaEl.addEventListener("pointerdown", onPlanePointerDown);
883
+ colorAreaEl.addEventListener("pointermove", onPlanePointerMove);
884
+ colorAreaEl.addEventListener("pointerup", onPlaneDragEnd);
885
+ colorAreaEl.addEventListener("pointercancel", onPlaneDragEnd);
886
+ colorAreaEl.addEventListener("lostpointercapture", onPlaneDragEnd);
887
+
888
+ colorAreaHandleEl.addEventListener("input", onHandleInput);
889
+ colorAreaHandleEl.addEventListener("change", onHandleChange);
890
+
891
+ this.#teardownColorAreaEvents = () => {
892
+ colorAreaEl.removeEventListener("pointerdown", onPlanePointerDown);
893
+ colorAreaEl.removeEventListener("pointermove", onPlanePointerMove);
894
+ colorAreaEl.removeEventListener("pointerup", onPlaneDragEnd);
895
+ colorAreaEl.removeEventListener("pointercancel", onPlaneDragEnd);
896
+ colorAreaEl.removeEventListener("lostpointercapture", onPlaneDragEnd);
897
+
898
+ colorAreaHandleEl.removeEventListener("input", onHandleInput);
899
+ colorAreaHandleEl.removeEventListener("change", onHandleChange);
900
+ this.#isDraggingColor = false;
901
+ };
902
+ }
903
+
904
+ #rebuildColorInputFields() {
905
+ const container = this.#dialog?.querySelector(
906
+ ".fig-fill-picker-input-fields",
907
+ );
908
+ if (!container) return;
909
+
910
+ const wrap = (tooltip, html) =>
911
+ `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
912
+
913
+ const num = (cls, min, max, step) =>
914
+ `<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
915
+
916
+ let html;
917
+ switch (this.#colorInputMode) {
918
+ case "rgb":
919
+ html = `<div class="input-combo">
920
+ ${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))}
921
+ ${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))}
922
+ ${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))}
923
+ </div>`;
924
+ break;
925
+ case "hsl":
926
+ html = `<div class="input-combo">
927
+ ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
928
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
929
+ ${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))}
930
+ </div>`;
931
+ break;
932
+ case "hsb":
933
+ html = `<div class="input-combo">
934
+ ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
935
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
936
+ ${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))}
937
+ </div>`;
938
+ break;
939
+ case "lab":
940
+ html = `<div class="input-combo">
941
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
942
+ ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))}
943
+ ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))}
944
+ </div>`;
945
+ break;
946
+ case "lch":
947
+ html = `<div class="input-combo">
948
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
949
+ ${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))}
950
+ ${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))}
951
+ </div>`;
952
+ break;
953
+ default: // hex
954
+ html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`;
955
+ break;
956
+ }
957
+
958
+ container.innerHTML = html;
959
+ this.#wireColorInputEvents();
960
+ requestAnimationFrame(() => this.#updateColorInputs());
961
+ }
962
+
963
+ #wireColorInputEvents() {
964
+ const container = this.#dialog?.querySelector(
965
+ ".fig-fill-picker-input-fields",
966
+ );
967
+ if (!container) return;
968
+
969
+ const onInput = () => {
970
+ if (this.#isDraggingColor) return;
971
+ const color = this.#readColorFromInputs();
972
+ if (!color) return;
973
+ this.#color = { ...color, a: this.#color.a };
974
+ this.#drawColorArea();
975
+ this.#updateHandlePosition();
976
+ if (this.#hueSlider) {
977
+ this.#hueSlider.setAttribute("value", this.#color.h);
978
+ }
979
+ this.#emitInput();
980
+ };
981
+
982
+ const onChange = () => this.#emitChange();
983
+
984
+ const inputs = container.querySelectorAll(
985
+ "fig-input-number, fig-input-text",
986
+ );
987
+ inputs.forEach((el) => {
988
+ el.addEventListener("input", onInput);
989
+ el.addEventListener("change", onChange);
990
+ });
991
+ }
992
+
993
+ #readColorFromInputs() {
994
+ const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
995
+ const val = (cls) => parseFloat(q(cls)?.value ?? 0);
996
+
997
+ switch (this.#colorInputMode) {
998
+ case "rgb":
999
+ return this.#rgbToHSV({
1000
+ r: val("fig-fill-picker-ci-r"),
1001
+ g: val("fig-fill-picker-ci-g"),
1002
+ b: val("fig-fill-picker-ci-b"),
1003
+ });
1004
+ case "hsl": {
1005
+ const rgb = this.#hslToRGB({
1006
+ h: val("fig-fill-picker-ci-h"),
1007
+ s: val("fig-fill-picker-ci-s"),
1008
+ l: val("fig-fill-picker-ci-l"),
1009
+ });
1010
+ return this.#rgbToHSV(rgb);
1011
+ }
1012
+ case "hsb":
1013
+ return {
1014
+ h: val("fig-fill-picker-ci-h"),
1015
+ s: val("fig-fill-picker-ci-s"),
1016
+ v: val("fig-fill-picker-ci-v"),
1017
+ a: 1,
1018
+ };
1019
+ case "lab": {
1020
+ const rgb = this.#oklabToRGB({
1021
+ l: val("fig-fill-picker-ci-okl") / 100,
1022
+ a: val("fig-fill-picker-ci-oka"),
1023
+ b: val("fig-fill-picker-ci-okb"),
1024
+ });
1025
+ return this.#rgbToHSV(rgb);
1026
+ }
1027
+ case "lch": {
1028
+ const rgb = this.#oklchToRGB({
1029
+ l: val("fig-fill-picker-ci-okl") / 100,
1030
+ c: val("fig-fill-picker-ci-okc"),
1031
+ h: val("fig-fill-picker-ci-okh"),
1032
+ });
1033
+ return this.#rgbToHSV(rgb);
1034
+ }
1035
+ default: {
1036
+ // hex
1037
+ const hexEl = q("fig-fill-picker-ci-hex");
1038
+ if (!hexEl) return null;
1039
+ let hex = hexEl.value.replace(/^#/, "");
1040
+ if (hex.length === 3)
1041
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1042
+ if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
1043
+ return this.#hexToHSV(`#${hex}`);
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ #updateColorInputs() {
1049
+ if (!this.#dialog) return;
1050
+
1051
+ const hex = this.#hsvToHex(this.#color);
1052
+ const rgb = this.#hsvToRGB(this.#color);
1053
+ const q = (cls) => this.#dialog.querySelector(`.${cls}`);
1054
+ const set = (cls, v) => {
1055
+ const el = q(cls);
1056
+ if (el) el.setAttribute("value", v);
1057
+ };
1058
+
1059
+ switch (this.#colorInputMode) {
1060
+ case "rgb":
1061
+ set("fig-fill-picker-ci-r", rgb.r);
1062
+ set("fig-fill-picker-ci-g", rgb.g);
1063
+ set("fig-fill-picker-ci-b", rgb.b);
1064
+ break;
1065
+ case "hsl": {
1066
+ const hsl = this.#rgbToHSL(rgb);
1067
+ set("fig-fill-picker-ci-h", Math.round(hsl.h));
1068
+ set("fig-fill-picker-ci-s", Math.round(hsl.s));
1069
+ set("fig-fill-picker-ci-l", Math.round(hsl.l));
1070
+ break;
1071
+ }
1072
+ case "hsb":
1073
+ set("fig-fill-picker-ci-h", Math.round(this.#color.h));
1074
+ set("fig-fill-picker-ci-s", Math.round(this.#color.s));
1075
+ set("fig-fill-picker-ci-v", Math.round(this.#color.v));
1076
+ break;
1077
+ case "lab": {
1078
+ const lab = this.#rgbToOKLAB(rgb);
1079
+ set("fig-fill-picker-ci-okl", Math.round(lab.l * 100));
1080
+ set("fig-fill-picker-ci-oka", +lab.a.toFixed(3));
1081
+ set("fig-fill-picker-ci-okb", +lab.b.toFixed(3));
1082
+ break;
1083
+ }
1084
+ case "lch": {
1085
+ const lch = this.#rgbToOKLCH(rgb);
1086
+ set("fig-fill-picker-ci-okl", Math.round(lch.l * 100));
1087
+ set("fig-fill-picker-ci-okc", +lch.c.toFixed(3));
1088
+ set("fig-fill-picker-ci-okh", Math.round(lch.h));
1089
+ break;
1090
+ }
1091
+ default: // hex
1092
+ set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
1093
+ break;
1094
+ }
1095
+
1096
+ if (this.#opacitySlider) {
1097
+ this.#opacitySlider.setAttribute("color", hex);
1098
+ }
1099
+
1100
+ this.#updateChit();
1101
+ }
1102
+
1103
+ // ============ GRADIENT TAB ============
1104
+ #initGradientTab() {
1105
+ const container = this.#dialog.querySelector('[data-tab="gradient"]');
1106
+ const experimental = this.getAttribute("experimental");
1107
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
1108
+
1109
+ container.innerHTML = `
1110
+ <fig-field class="fig-fill-picker-gradient-header">
1111
+ <fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${
1112
+ this.#gradient.type
1113
+ }">
1114
+ <option value="linear" selected>Linear</option>
1115
+ <option value="radial">Radial</option>
1116
+ <option value="angular">Angular</option>
1117
+ </fig-dropdown>
1118
+ <fig-tooltip text="Rotate gradient">
1119
+ <fig-input-number class="fig-fill-picker-gradient-angle" value="${
1120
+ (this.#gradient.angle - 90 + 360) % 360
1121
+ }" min="0" max="360" units="°" wrap></fig-input-number>
1122
+ </fig-tooltip>
1123
+ <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
1124
+ <fig-input-number min="0" max="100" value="${
1125
+ this.#gradient.centerX
1126
+ }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
1127
+ <fig-input-number min="0" max="100" value="${
1128
+ this.#gradient.centerY
1129
+ }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
1130
+ </div>
1131
+ <fig-tooltip text="Flip gradient">
1132
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
1133
+ <fig-icon name="swap"></fig-icon>
1134
+ </fig-button>
1135
+ </fig-tooltip>
1136
+ </fig-field>
1137
+ <fig-preview class="fig-fill-picker-gradient-preview">
1138
+ <fig-input-gradient class="fig-fill-picker-gradient-bar-input" edit="true" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
1139
+ </fig-preview>
1140
+ <fig-field class="fig-fill-picker-gradient-interpolation">
1141
+ <label>Mixing</label>
1142
+ <fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${
1143
+ this.#gradient.interpolationSpace === "oklch"
1144
+ ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1145
+ : this.#gradient.interpolationSpace
1146
+ }">
1147
+ <optgroup label="sRGB">
1148
+ <option value="srgb-linear">Linear</option>
1149
+ </optgroup>
1150
+ <optgroup label="OKLab">
1151
+ <option value="oklab">Perceptual</option>
1152
+ </optgroup>
1153
+ <optgroup label="OKLCH">
1154
+ <option value="oklch-shorter">Shorter hue</option>
1155
+ <option value="oklch-longer">Longer hue</option>
1156
+ <option value="oklch-increasing">Increasing hue</option>
1157
+ <option value="oklch-decreasing">Decreasing hue</option>
1158
+ </optgroup>
1159
+ </fig-dropdown>
1160
+ </fig-field>
1161
+ <div class="fig-fill-picker-gradient-stops">
1162
+ <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
1163
+ <span>Stops</span>
1164
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
1165
+ <fig-icon name="add"></fig-icon>
1166
+ </fig-button>
1167
+ </fig-header>
1168
+ <div class="fig-fill-picker-gradient-stops-list"></div>
1169
+ </div>
1170
+ `;
1171
+
1172
+ this.#updateGradientUI();
1173
+ this.#setupGradientEvents(container);
1174
+ }
1175
+
1176
+ #setupGradientEvents(container) {
1177
+ // Type dropdown
1178
+ const typeDropdown = container.querySelector(
1179
+ ".fig-fill-picker-gradient-type",
1180
+ );
1181
+ const getDropdownValue = (event) =>
1182
+ event.currentTarget?.value ?? event.target?.value ?? event.detail;
1183
+
1184
+ const handleTypeChange = (e) => {
1185
+ this.#gradient.type = getDropdownValue(e);
1186
+ this.#updateGradientUI();
1187
+ this.#emitInput();
1188
+ };
1189
+ typeDropdown.addEventListener("input", handleTypeChange);
1190
+ typeDropdown.addEventListener("change", handleTypeChange);
1191
+
1192
+ const interpolationDropdown = container.querySelector(
1193
+ ".fig-fill-picker-gradient-space",
1194
+ );
1195
+ const handleInterpolationChange = (e) => {
1196
+ const val = getDropdownValue(e);
1197
+ let space = val;
1198
+ let hue = "shorter";
1199
+ if (val.startsWith("oklch-")) {
1200
+ space = "oklch";
1201
+ hue = val.slice(6);
1202
+ }
1203
+ this.#gradient = normalizeGradientConfig({
1204
+ ...this.#gradient,
1205
+ interpolationSpace: space,
1206
+ hueInterpolation: hue,
1207
+ });
1208
+ this.#updateGradientUI();
1209
+ this.#emitInput();
1210
+ };
1211
+ interpolationDropdown?.addEventListener("input", handleInterpolationChange);
1212
+ interpolationDropdown?.addEventListener(
1213
+ "change",
1214
+ handleInterpolationChange,
1215
+ );
1216
+
1217
+ // Angle input
1218
+ const angleInput = container.querySelector(
1219
+ ".fig-fill-picker-gradient-angle",
1220
+ );
1221
+ angleInput.addEventListener("input", (e) => {
1222
+ const pickerAngle = parseFloat(e.target.value) || 0;
1223
+ this.#gradient.angle = (pickerAngle + 90) % 360;
1224
+ this.#updateGradientPreview();
1225
+ this.#emitInput();
1226
+ });
1227
+
1228
+ // Center X/Y inputs
1229
+ const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
1230
+ const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
1231
+ cxInput?.addEventListener("input", (e) => {
1232
+ this.#gradient.centerX = parseFloat(e.target.value) || 50;
1233
+ this.#updateGradientPreview();
1234
+ this.#emitInput();
1235
+ });
1236
+ cyInput?.addEventListener("input", (e) => {
1237
+ this.#gradient.centerY = parseFloat(e.target.value) || 50;
1238
+ this.#updateGradientPreview();
1239
+ this.#emitInput();
1240
+ });
1241
+
1242
+ // Flip button
1243
+ container
1244
+ .querySelector(".fig-fill-picker-gradient-flip")
1245
+ .addEventListener("click", () => {
1246
+ this.#gradient.stops.forEach((stop) => {
1247
+ stop.position = 100 - stop.position;
1248
+ });
1249
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
1250
+ this.#updateGradientUI();
1251
+ this.#emitInput();
1252
+ });
1253
+
1254
+ // Add stop button
1255
+ container
1256
+ .querySelector(".fig-fill-picker-gradient-add")
1257
+ .addEventListener("click", () => {
1258
+ const midPosition = 50;
1259
+ this.#gradient.stops.push({
1260
+ position: midPosition,
1261
+ color: "#888888",
1262
+ opacity: 100,
1263
+ });
1264
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
1265
+ this.#updateGradientUI();
1266
+ this.#emitInput();
1267
+ });
1268
+
1269
+ // Embedded gradient bar input
1270
+ const gradientBarInput = container.querySelector(
1271
+ ".fig-fill-picker-gradient-bar-input",
1272
+ );
1273
+ if (gradientBarInput) {
1274
+ const syncFromBarInput = (e) => {
1275
+ e.stopPropagation();
1276
+ const detail = e.detail;
1277
+ if (!detail?.gradient) return;
1278
+ this.#gradient = normalizeGradientConfig({
1279
+ ...this.#gradient,
1280
+ ...detail.gradient,
1281
+ });
1282
+ this.#updateChit();
1283
+ this.#updateGradientStopsList();
1284
+ };
1285
+ gradientBarInput.addEventListener("input", (e) => {
1286
+ syncFromBarInput(e);
1287
+ this.#emitInput();
1288
+ });
1289
+ gradientBarInput.addEventListener("change", (e) => {
1290
+ syncFromBarInput(e);
1291
+ this.#emitChange();
1292
+ });
1293
+ }
1294
+ }
1295
+
1296
+ #updateGradientUI() {
1297
+ if (!this.#dialog) return;
1298
+
1299
+ const container = this.#dialog.querySelector('[data-tab="gradient"]');
1300
+ if (!container) return;
1301
+ this.#gradient = normalizeGradientConfig(this.#gradient);
1302
+
1303
+ // Show/hide angle vs center inputs
1304
+ const angleInput = container.querySelector(
1305
+ ".fig-fill-picker-gradient-angle",
1306
+ );
1307
+ const centerInputs = container.querySelector(
1308
+ ".fig-fill-picker-gradient-center",
1309
+ );
1310
+
1311
+ if (this.#gradient.type === "radial") {
1312
+ angleInput.style.display = "none";
1313
+ centerInputs.style.display = "flex";
1314
+ } else {
1315
+ angleInput.style.display = "block";
1316
+ centerInputs.style.display = "none";
1317
+ // Sync angle input value (convert CSS angle to picker angle)
1318
+ const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
1319
+ angleInput.setAttribute("value", pickerAngle);
1320
+ }
1321
+
1322
+ const interpolationDropdown = container.querySelector(
1323
+ ".fig-fill-picker-gradient-space",
1324
+ );
1325
+ if (interpolationDropdown) {
1326
+ interpolationDropdown.value =
1327
+ this.#gradient.interpolationSpace === "oklch"
1328
+ ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1329
+ : this.#gradient.interpolationSpace;
1330
+ }
1331
+
1332
+ this.#updateGradientPreview();
1333
+ this.#updateGradientStopsList();
1334
+ }
1335
+
1336
+ #updateGradientPreview() {
1337
+ if (!this.#dialog) return;
1338
+
1339
+ const barInput = this.#dialog.querySelector(
1340
+ ".fig-fill-picker-gradient-bar-input",
1341
+ );
1342
+ if (barInput) {
1343
+ barInput.setAttribute(
1344
+ "value",
1345
+ JSON.stringify({
1346
+ type: "gradient",
1347
+ gradient: gradientToValueShape(this.#gradient),
1348
+ }),
1349
+ );
1350
+ }
1351
+
1352
+ this.#updateChit();
1353
+ }
1354
+
1355
+ #updateGradientStopsList() {
1356
+ if (!this.#dialog) return;
1357
+
1358
+ const list = this.#dialog.querySelector(
1359
+ ".fig-fill-picker-gradient-stops-list",
1360
+ );
1361
+ if (!list) return;
1362
+
1363
+ const existingRows = list.querySelectorAll(
1364
+ ".fig-fill-picker-gradient-stop-row",
1365
+ );
1366
+
1367
+ if (existingRows.length === this.#gradient.stops.length) {
1368
+ this.#gradient.stops.forEach((stop, index) => {
1369
+ const row = existingRows[index];
1370
+ row.dataset.index = index;
1371
+ const posInput = row.querySelector(".fig-fill-picker-stop-position");
1372
+ if (posInput) posInput.setAttribute("value", stop.position);
1373
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1374
+ if (colorInput) colorInput.setAttribute("value", stop.color);
1375
+ const removeBtn = row.querySelector(".fig-fill-picker-stop-remove");
1376
+ if (removeBtn) {
1377
+ if (this.#gradient.stops.length <= 2)
1378
+ removeBtn.setAttribute("disabled", "");
1379
+ else removeBtn.removeAttribute("disabled");
1380
+ }
1381
+ });
1382
+ return;
1383
+ }
1384
+
1385
+ this.#rebuildGradientStopsList(list);
1386
+ }
1387
+
1388
+ #rebuildGradientStopsList(list) {
1389
+ list.innerHTML = this.#gradient.stops
1390
+ .map(
1391
+ (stop, index) => `
1392
+ <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
1393
+ <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
1394
+ stop.position
1395
+ }" units="%"></fig-input-number>
1396
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
1397
+ stop.color
1398
+ }"></fig-input-color>
1399
+ <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
1400
+ this.#gradient.stops.length <= 2 ? "disabled" : ""
1401
+ }>
1402
+ <fig-icon name="minus"></fig-icon>
1403
+ </fig-button>
1404
+ </fig-field>
1405
+ `,
1406
+ )
1407
+ .join("");
1408
+
1409
+ list
1410
+ .querySelectorAll(".fig-fill-picker-gradient-stop-row")
1411
+ .forEach((row) => {
1412
+ const index = parseInt(row.dataset.index);
1413
+
1414
+ row
1415
+ .querySelector(".fig-fill-picker-stop-position")
1416
+ .addEventListener("input", (e) => {
1417
+ this.#gradient.stops[index].position =
1418
+ parseFloat(e.target.value) || 0;
1419
+ this.#updateGradientPreview();
1420
+ this.#emitInput();
1421
+ });
1422
+
1423
+ const stopColor = row.querySelector(".fig-fill-picker-stop-color");
1424
+ const stopFillPicker = stopColor.querySelector("fig-fill-picker");
1425
+ if (stopFillPicker) {
1426
+ stopFillPicker.anchorElement = this.#dialog;
1427
+ } else {
1428
+ requestAnimationFrame(() => {
1429
+ const fp = stopColor.querySelector("fig-fill-picker");
1430
+ if (fp) fp.anchorElement = this.#dialog;
1431
+ });
1432
+ }
1433
+
1434
+ stopColor.addEventListener("input", (e) => {
1435
+ this.#gradient.stops[index].color =
1436
+ e.target.hexOpaque || e.target.value;
1437
+ const a = e.detail?.rgba?.a;
1438
+ if (a !== undefined) {
1439
+ this.#gradient.stops[index].opacity = Math.round(a * 100);
1440
+ }
1441
+ this.#updateGradientPreview();
1442
+ this.#emitInput();
1443
+ });
1444
+
1445
+ row
1446
+ .querySelector(".fig-fill-picker-stop-remove")
1447
+ .addEventListener("click", () => {
1448
+ if (this.#gradient.stops.length > 2) {
1449
+ this.#gradient.stops.splice(index, 1);
1450
+ this.#updateGradientUI();
1451
+ this.#emitInput();
1452
+ }
1453
+ });
1454
+ });
1455
+ }
1456
+
1457
+ #buildGradientCSS(interpolationSpaceOverride, includeInterpolation = true) {
1458
+ const gradient = normalizeGradientConfig({
1459
+ ...this.#gradient,
1460
+ interpolationSpace:
1461
+ interpolationSpaceOverride ?? this.#gradient.interpolationSpace,
1462
+ });
1463
+ const isP3 = this.#gamut === "display-p3";
1464
+ const stops = gradient.stops
1465
+ .map((s) => {
1466
+ const alpha = (s.opacity ?? 100) / 100;
1467
+ const color = isP3
1468
+ ? this.#hexToP3(s.color, alpha)
1469
+ : this.#hexToRGBA(s.color, alpha);
1470
+ return `${color} ${s.position}%`;
1471
+ })
1472
+ .join(", ");
1473
+ const interpolation = includeInterpolation
1474
+ ? ` ${gradientInterpolationClause(gradient)}`
1475
+ : "";
1476
+ switch (gradient.type) {
1477
+ case "linear":
1478
+ return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
1479
+ case "radial":
1480
+ return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
1481
+ case "angular":
1482
+ return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`;
1483
+ default:
1484
+ return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
1485
+ }
1486
+ }
1487
+
1488
+ static #gradientSupportCache = new Map();
1489
+ #testGradientSupport(css) {
1490
+ const cached = FigFillPicker.#gradientSupportCache.get(css);
1491
+ if (cached !== undefined) return cached;
1492
+ const el = document.createElement("div");
1493
+ el.style.background = css;
1494
+ const result = !!el.style.background;
1495
+ FigFillPicker.#gradientSupportCache.set(css, result);
1496
+ return result;
1497
+ }
1498
+
1499
+ #getGradientCSS() {
1500
+ const preferred = this.#buildGradientCSS(undefined, true);
1501
+ if (this.#testGradientSupport(preferred)) return preferred;
1502
+
1503
+ const oklabFallback = this.#buildGradientCSS("oklab", true);
1504
+ if (this.#testGradientSupport(oklabFallback)) return oklabFallback;
1505
+
1506
+ return this.#buildGradientCSS("oklab", false);
1507
+ }
1508
+
1509
+ // ============ IMAGE TAB ============
1510
+ #initImageTab() {
1511
+ const container = this.#dialog.querySelector('[data-tab="image"]');
1512
+ const experimental = this.getAttribute("experimental");
1513
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
1514
+
1515
+ container.innerHTML = `
1516
+ <fig-field class="fig-fill-picker-media-header">
1517
+ <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
1518
+ this.#image.scaleMode
1519
+ }">
1520
+ <option value="fill" selected>Fill</option>
1521
+ <option value="fit">Fit</option>
1522
+ <option value="crop">Crop</option>
1523
+ <option value="tile">Tile</option>
1524
+ </fig-dropdown>
1525
+ <fig-input-number class="fig-fill-picker-scale" min="1" max="200" value="${
1526
+ this.#image.scale
1527
+ }" units="%" ${
1528
+ this.#image.scaleMode === "tile" ? "" : 'style="display: none;"'
1529
+ }></fig-input-number>
1530
+ <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
1531
+ <fig-icon name="rotate"></fig-icon>
1532
+ </fig-button>
1533
+ </fig-field>
1534
+ <fig-image class="fig-fill-picker-media-preview fig-fill-picker-image-preview" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true"></fig-image>
1535
+ `;
1536
+
1537
+ this.#setupImageEvents(container);
1538
+ }
1539
+
1540
+ #setupImageEvents(container) {
1541
+ const scaleModeDropdown = container.querySelector(
1542
+ ".fig-fill-picker-scale-mode",
1543
+ );
1544
+ const scaleInput = container.querySelector(".fig-fill-picker-scale");
1545
+ const preview = container.querySelector(".fig-fill-picker-image-preview");
1546
+
1547
+ scaleModeDropdown.addEventListener("change", (e) => {
1548
+ this.#image.scaleMode = e.target.value;
1549
+ scaleInput.style.display = e.target.value === "tile" ? "block" : "none";
1550
+ this.#updateImagePreview(preview);
1551
+ this.#updateChit();
1552
+ this.#emitInput();
1553
+ });
1554
+
1555
+ scaleInput.addEventListener("input", (e) => {
1556
+ this.#image.scale = parseFloat(e.target.value) || 100;
1557
+ this.#updateImagePreview(preview);
1558
+ this.#updateChit();
1559
+ this.#emitInput();
1560
+ });
1561
+
1562
+ preview.addEventListener("loaded", (e) => {
1563
+ const src = e.detail?.src || preview.src;
1564
+ if (!src) return;
1565
+ this.#image.url = src;
1566
+ this.#updateImagePreview(preview);
1567
+ this.#updateChit();
1568
+ this.#emitInput();
1569
+ });
1570
+
1571
+ preview.addEventListener("change", () => {
1572
+ if (preview.src) return;
1573
+ this.#image.url = null;
1574
+ this.#updateImagePreview(preview);
1575
+ this.#updateChit();
1576
+ this.#emitInput();
1577
+ });
1578
+
1579
+ this.#updateImagePreview(preview);
1580
+ }
1581
+
1582
+ #updateImagePreview(element) {
1583
+ if (!this.#image.url) {
1584
+ element.removeAttribute("src");
1585
+ element.classList.remove("has-media", "is-tiled");
1586
+ element.style.backgroundImage = "";
1587
+ element.style.backgroundPosition = "";
1588
+ element.style.backgroundRepeat = "";
1589
+ element.style.backgroundSize = "";
1590
+ return;
1591
+ }
1592
+
1593
+ element.setAttribute("src", this.#image.url);
1594
+ element.classList.add("has-media");
1595
+ element.style.backgroundImage = "";
1596
+ element.style.backgroundPosition = "";
1597
+ element.style.backgroundRepeat = "";
1598
+ element.style.backgroundSize = "";
1599
+ element.mediaEl?.style.removeProperty("opacity");
1600
+
1601
+ const fileInput = element.querySelector("fig-input-file[data-generated]");
1602
+ if (fileInput) {
1603
+ fileInput.setAttribute("label", "Replace");
1604
+ fileInput.removeAttribute("url");
1605
+ }
1606
+
1607
+ switch (this.#image.scaleMode) {
1608
+ case "fill":
1609
+ element.classList.remove("is-tiled");
1610
+ element.setAttribute("fit", "cover");
1611
+ break;
1612
+ case "crop":
1613
+ element.classList.remove("is-tiled");
1614
+ element.setAttribute("fit", "cover");
1615
+ break;
1616
+ case "fit":
1617
+ element.classList.remove("is-tiled");
1618
+ element.setAttribute("fit", "contain");
1619
+ break;
1620
+ case "tile":
1621
+ element.classList.add("is-tiled");
1622
+ element.setAttribute("fit", "none");
1623
+ element.style.backgroundImage = `url(${this.#image.url})`;
1624
+ element.style.backgroundPosition = "top left";
1625
+ element.style.backgroundSize = `${this.#image.scale}%`;
1626
+ element.style.backgroundRepeat = "repeat";
1627
+ if (element.mediaEl) element.mediaEl.style.opacity = "0";
1628
+ break;
1629
+ }
1630
+ }
1631
+
1632
+ // For video elements (still uses object-fit)
1633
+ #updateVideoPreviewStyle(element) {
1634
+ if (element.tagName === "FIG-MEDIA") {
1635
+ if (!this.#video.url) {
1636
+ element.removeAttribute("src");
1637
+ element.classList.remove("has-media");
1638
+ return;
1639
+ }
1640
+
1641
+ element.setAttribute("src", this.#video.url);
1642
+ element.classList.add("has-media");
1643
+
1644
+ const fileInput = element.querySelector("fig-input-file[data-generated]");
1645
+ if (fileInput) {
1646
+ fileInput.setAttribute("label", "Replace");
1647
+ fileInput.removeAttribute("url");
1648
+ }
1649
+
1650
+ switch (this.#video.scaleMode) {
1651
+ case "fill":
1652
+ case "crop":
1653
+ element.setAttribute("fit", "cover");
1654
+ break;
1655
+ case "fit":
1656
+ element.setAttribute("fit", "contain");
1657
+ break;
1658
+ }
1659
+ return;
1660
+ }
1661
+
1662
+ element.style.objectPosition = "center";
1663
+ element.style.width = "100%";
1664
+ element.style.height = "100%";
1665
+
1666
+ switch (this.#video.scaleMode) {
1667
+ case "fill":
1668
+ case "crop":
1669
+ element.style.objectFit = "cover";
1670
+ break;
1671
+ case "fit":
1672
+ element.style.objectFit = "contain";
1673
+ break;
1674
+ }
1675
+ }
1676
+
1677
+ // ============ VIDEO TAB ============
1678
+ #initVideoTab() {
1679
+ const container = this.#dialog.querySelector('[data-tab="video"]');
1680
+ const experimental = this.getAttribute("experimental");
1681
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
1682
+
1683
+ container.innerHTML = `
1684
+ <fig-field class="fig-fill-picker-media-header">
1685
+ <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
1686
+ this.#video.scaleMode
1687
+ }">
1688
+ <option value="fill" selected>Fill</option>
1689
+ <option value="fit">Fit</option>
1690
+ <option value="crop">Crop</option>
1691
+ </fig-dropdown>
1692
+ <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
1693
+ <fig-icon name="rotate"></fig-icon>
1694
+ </fig-button>
1695
+ </fig-field>
1696
+ <fig-media class="fig-fill-picker-media-preview fig-fill-picker-video-preview" type="video" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true" loop="true"></fig-media>
1697
+ `;
1698
+
1699
+ this.#setupVideoEvents(container);
1700
+ }
1701
+
1702
+ #setupVideoEvents(container) {
1703
+ const scaleModeDropdown = container.querySelector(
1704
+ ".fig-fill-picker-scale-mode",
1705
+ );
1706
+ const preview = container.querySelector(".fig-fill-picker-video-preview");
1707
+
1708
+ scaleModeDropdown.addEventListener("change", (e) => {
1709
+ this.#video.scaleMode = e.target.value;
1710
+ this.#updateVideoPreviewStyle(preview);
1711
+ this.#updateChit();
1712
+ this.#emitInput();
1713
+ });
1714
+
1715
+ preview.addEventListener("loaded", (e) => {
1716
+ const src = e.detail?.src || preview.src;
1717
+ if (!src) return;
1718
+ this.#video.url = src;
1719
+ this.#updateVideoPreviewStyle(preview);
1720
+ preview.play?.();
1721
+ this.#updateChit();
1722
+ this.#emitInput();
1723
+ });
1724
+
1725
+ preview.addEventListener("change", () => {
1726
+ if (preview.src) return;
1727
+ this.#video.url = null;
1728
+ this.#updateVideoPreviewStyle(preview);
1729
+ this.#updateChit();
1730
+ this.#emitInput();
1731
+ });
1732
+
1733
+ this.#updateVideoPreviewStyle(preview);
1734
+ }
1735
+
1736
+ // ============ WEBCAM TAB ============
1737
+ #initWebcamTab() {
1738
+ const container = this.#dialog.querySelector('[data-tab="webcam"]');
1739
+ const experimental = this.getAttribute("experimental");
1740
+ const expAttr = experimental ? `experimental="${experimental}"` : "";
1741
+
1742
+ container.innerHTML = `
1743
+ <fig-field class="fig-fill-picker-webcam-camera" style="display: none;">
1744
+ <fig-dropdown class="fig-fill-picker-camera-select" full ${expAttr}>
1745
+ </fig-dropdown>
1746
+ </fig-field>
1747
+ <fig-video class="fig-fill-picker-webcam-preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
1748
+ <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
1749
+ <div class="fig-fill-picker-webcam-status">
1750
+ <span>Camera access required</span>
1751
+ </div>
1752
+ </fig-video>
1753
+ <div class="fig-fill-picker-webcam-controls">
1754
+ <fig-button class="fig-fill-picker-webcam-capture" variant="secondary" full>
1755
+ Capture
1756
+ </fig-button>
1757
+ </div>
1758
+ `;
1759
+
1760
+ this.#setupWebcamEvents(container);
1761
+ }
1762
+
1763
+ #setupWebcamEvents(container) {
1764
+ const video = container.querySelector(".fig-fill-picker-webcam-video");
1765
+ const status = container.querySelector(".fig-fill-picker-webcam-status");
1766
+ const captureBtn = container.querySelector(
1767
+ ".fig-fill-picker-webcam-capture",
1768
+ );
1769
+ const cameraField = container.querySelector(
1770
+ ".fig-fill-picker-webcam-camera",
1771
+ );
1772
+ const cameraSelect = container.querySelector(
1773
+ ".fig-fill-picker-camera-select",
1774
+ );
1775
+
1776
+ const startWebcam = async (deviceId = null) => {
1777
+ try {
1778
+ const constraints = {
1779
+ video: deviceId ? { deviceId: { exact: deviceId } } : true,
1780
+ };
1781
+
1782
+ if (this.#webcam.stream) {
1783
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
1784
+ }
1785
+
1786
+ this.#webcam.stream =
1787
+ await navigator.mediaDevices.getUserMedia(constraints);
1788
+ video.srcObject = this.#webcam.stream;
1789
+ video.style.display = "block";
1790
+ status.style.display = "none";
1791
+
1792
+ // Enumerate cameras
1793
+ const devices = await navigator.mediaDevices.enumerateDevices();
1794
+ const cameras = devices.filter((d) => d.kind === "videoinput");
1795
+
1796
+ if (cameras.length > 1) {
1797
+ cameraField.style.display = "";
1798
+ cameraSelect
1799
+ .querySelectorAll(":scope > option, :scope > optgroup")
1800
+ .forEach((option) => option.remove());
1801
+ cameras.forEach((cam, i) => {
1802
+ const option = document.createElement("option");
1803
+ option.value = cam.deviceId;
1804
+ const label =
1805
+ cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
1806
+ option.textContent = label.replace(
1807
+ /\s*\((?:[0-9a-f]{4}:)*([0-9a-f]{4})\)$/i,
1808
+ (_, id) => {
1809
+ const displayId = /^\d+$/.test(id)
1810
+ ? Number.parseInt(id, 10).toString()
1811
+ : id.replace(/^0+/, "") || "0";
1812
+ return ` ${displayId}`;
1813
+ },
1814
+ );
1815
+ cameraSelect.append(option);
1816
+ });
1817
+ if (deviceId) cameraSelect.value = deviceId;
1818
+ } else {
1819
+ cameraField.style.display = "none";
1820
+ cameraSelect
1821
+ .querySelectorAll(":scope > option, :scope > optgroup")
1822
+ .forEach((option) => option.remove());
1823
+ }
1824
+ } catch (err) {
1825
+ console.error("Webcam error:", err.name, err.message);
1826
+ let message = "Camera access denied";
1827
+ if (err.name === "NotAllowedError") {
1828
+ message = "Camera permission denied";
1829
+ } else if (err.name === "NotFoundError") {
1830
+ message = "No camera found";
1831
+ } else if (err.name === "NotReadableError") {
1832
+ message = "Camera in use by another app";
1833
+ } else if (err.name === "OverconstrainedError") {
1834
+ message = "Camera constraints not supported";
1835
+ } else if (!window.isSecureContext) {
1836
+ message = "Camera requires secure context";
1837
+ }
1838
+ status.innerHTML = `<span>${message}</span>`;
1839
+ status.style.display = "flex";
1840
+ video.style.display = "none";
1841
+ }
1842
+ };
1843
+
1844
+ this.#webcamTabObserver = new MutationObserver(() => {
1845
+ if (container.style.display !== "none" && !this.#webcam.stream) {
1846
+ startWebcam();
1847
+ }
1848
+ });
1849
+ this.#webcamTabObserver.observe(container, {
1850
+ attributes: true,
1851
+ attributeFilter: ["style"],
1852
+ });
1853
+
1854
+ cameraSelect.addEventListener("change", (e) => {
1855
+ startWebcam(e.target.value);
1856
+ });
1857
+
1858
+ captureBtn.addEventListener("click", async () => {
1859
+ if (!this.#webcam.stream) return;
1860
+ if (!video.videoWidth || !video.videoHeight) return;
1861
+
1862
+ const canvas = document.createElement("canvas");
1863
+ canvas.width = video.videoWidth;
1864
+ canvas.height = video.videoHeight;
1865
+ canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
1866
+
1867
+ const blob = await new Promise((resolve) =>
1868
+ canvas.toBlob(resolve, "image/png"),
1869
+ );
1870
+ if (!blob) return;
1871
+
1872
+ if (this.#webcam.snapshot?.startsWith("blob:")) {
1873
+ URL.revokeObjectURL(this.#webcam.snapshot);
1874
+ }
1875
+ this.#webcam.snapshot = URL.createObjectURL(blob);
1876
+ this.#image.url = this.#webcam.snapshot;
1877
+
1878
+ const imagePreview = this.#dialog.querySelector(
1879
+ ".fig-fill-picker-image-preview",
1880
+ );
1881
+ if (imagePreview) this.#updateImagePreview(imagePreview);
1882
+
1883
+ // Switch to image tab to show result
1884
+ this.#switchTab("image");
1885
+ });
1886
+ }
1887
+
1888
+ // ============ COLOR CONVERSION UTILITIES ============
1889
+ #hsvToRGB(hsv) {
1890
+ const h = hsv.h / 360;
1891
+ const s = hsv.s / 100;
1892
+ const v = hsv.v / 100;
1893
+
1894
+ let r, g, b;
1895
+ const i = Math.floor(h * 6);
1896
+ const f = h * 6 - i;
1897
+ const p = v * (1 - s);
1898
+ const q = v * (1 - f * s);
1899
+ const t = v * (1 - (1 - f) * s);
1900
+
1901
+ switch (i % 6) {
1902
+ case 0:
1903
+ r = v;
1904
+ g = t;
1905
+ b = p;
1906
+ break;
1907
+ case 1:
1908
+ r = q;
1909
+ g = v;
1910
+ b = p;
1911
+ break;
1912
+ case 2:
1913
+ r = p;
1914
+ g = v;
1915
+ b = t;
1916
+ break;
1917
+ case 3:
1918
+ r = p;
1919
+ g = q;
1920
+ b = v;
1921
+ break;
1922
+ case 4:
1923
+ r = t;
1924
+ g = p;
1925
+ b = v;
1926
+ break;
1927
+ case 5:
1928
+ r = v;
1929
+ g = p;
1930
+ b = q;
1931
+ break;
1932
+ }
1933
+
1934
+ return {
1935
+ r: Math.round(r * 255),
1936
+ g: Math.round(g * 255),
1937
+ b: Math.round(b * 255),
1938
+ };
1939
+ }
1940
+
1941
+ #rgbToHSV(rgb) {
1942
+ const r = rgb.r / 255;
1943
+ const g = rgb.g / 255;
1944
+ const b = rgb.b / 255;
1945
+
1946
+ const max = Math.max(r, g, b);
1947
+ const min = Math.min(r, g, b);
1948
+ const d = max - min;
1949
+
1950
+ let h = 0;
1951
+ const s = max === 0 ? 0 : d / max;
1952
+ const v = max;
1953
+
1954
+ if (max !== min) {
1955
+ switch (max) {
1956
+ case r:
1957
+ h = (g - b) / d + (g < b ? 6 : 0);
1958
+ break;
1959
+ case g:
1960
+ h = (b - r) / d + 2;
1961
+ break;
1962
+ case b:
1963
+ h = (r - g) / d + 4;
1964
+ break;
1965
+ }
1966
+ h /= 6;
1967
+ }
1968
+
1969
+ return {
1970
+ h: h * 360,
1971
+ s: s * 100,
1972
+ v: v * 100,
1973
+ a: 1,
1974
+ };
1975
+ }
1976
+
1977
+ #hsvToHex(hsv) {
1978
+ // Safety check for valid HSV object
1979
+ if (
1980
+ !hsv ||
1981
+ typeof hsv.h !== "number" ||
1982
+ typeof hsv.s !== "number" ||
1983
+ typeof hsv.v !== "number"
1984
+ ) {
1985
+ return "#D9D9D9"; // Default gray
1986
+ }
1987
+ const rgb = this.#hsvToRGB(hsv);
1988
+ const toHex = (n) => {
1989
+ const val = isNaN(n) ? 217 : Math.max(0, Math.min(255, Math.round(n)));
1990
+ return val.toString(16).padStart(2, "0");
1991
+ };
1992
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
1993
+ }
1994
+
1995
+ #hexToHSV(hex) {
1996
+ const r = parseInt(hex.slice(1, 3), 16);
1997
+ const g = parseInt(hex.slice(3, 5), 16);
1998
+ const b = parseInt(hex.slice(5, 7), 16);
1999
+ return this.#rgbToHSV({ r, g, b });
2000
+ }
2001
+
2002
+ #hexToRGBA(hex, alpha = 1) {
2003
+ const r = parseInt(hex.slice(1, 3), 16);
2004
+ const g = parseInt(hex.slice(3, 5), 16);
2005
+ const b = parseInt(hex.slice(5, 7), 16);
2006
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
2007
+ }
2008
+
2009
+ #hexToP3(hex, alpha = 1) {
2010
+ const r = +(parseInt(hex.slice(1, 3), 16) / 255).toFixed(4);
2011
+ const g = +(parseInt(hex.slice(3, 5), 16) / 255).toFixed(4);
2012
+ const b = +(parseInt(hex.slice(5, 7), 16) / 255).toFixed(4);
2013
+ return `color(display-p3 ${r} ${g} ${b} / ${alpha})`;
2014
+ }
2015
+
2016
+ #rgbToHSL(rgb) {
2017
+ const r = rgb.r / 255;
2018
+ const g = rgb.g / 255;
2019
+ const b = rgb.b / 255;
2020
+
2021
+ const max = Math.max(r, g, b);
2022
+ const min = Math.min(r, g, b);
2023
+ let h, s;
2024
+ const l = (max + min) / 2;
2025
+
2026
+ if (max === min) {
2027
+ h = s = 0;
2028
+ } else {
2029
+ const d = max - min;
2030
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2031
+
2032
+ switch (max) {
2033
+ case r:
2034
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
2035
+ break;
2036
+ case g:
2037
+ h = ((b - r) / d + 2) / 6;
2038
+ break;
2039
+ case b:
2040
+ h = ((r - g) / d + 4) / 6;
2041
+ break;
2042
+ }
2043
+ }
2044
+
2045
+ return { h: h * 360, s: s * 100, l: l * 100 };
2046
+ }
2047
+
2048
+ #hslToRGB(hsl) {
2049
+ const h = hsl.h / 360;
2050
+ const s = hsl.s / 100;
2051
+ const l = hsl.l / 100;
2052
+
2053
+ let r, g, b;
2054
+
2055
+ if (s === 0) {
2056
+ r = g = b = l;
2057
+ } else {
2058
+ const hue2rgb = (p, q, t) => {
2059
+ if (t < 0) t += 1;
2060
+ if (t > 1) t -= 1;
2061
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
2062
+ if (t < 1 / 2) return q;
2063
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
2064
+ return p;
2065
+ };
2066
+
2067
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
2068
+ const p = 2 * l - q;
2069
+
2070
+ r = hue2rgb(p, q, h + 1 / 3);
2071
+ g = hue2rgb(p, q, h);
2072
+ b = hue2rgb(p, q, h - 1 / 3);
2073
+ }
2074
+
2075
+ return {
2076
+ r: Math.round(r * 255),
2077
+ g: Math.round(g * 255),
2078
+ b: Math.round(b * 255),
2079
+ };
2080
+ }
2081
+
2082
+ // OKLAB/OKLCH conversions (simplified)
2083
+ #rgbToOKLAB(rgb) {
2084
+ // Convert to linear sRGB
2085
+ const toLinear = (c) => {
2086
+ c = c / 255;
2087
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
2088
+ };
2089
+
2090
+ const r = toLinear(rgb.r);
2091
+ const g = toLinear(rgb.g);
2092
+ const b = toLinear(rgb.b);
2093
+
2094
+ // Convert to LMS
2095
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
2096
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
2097
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
2098
+
2099
+ // Convert to Oklab
2100
+ const l_ = Math.cbrt(l);
2101
+ const m_ = Math.cbrt(m);
2102
+ const s_ = Math.cbrt(s);
2103
+
2104
+ return {
2105
+ l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
2106
+ a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
2107
+ b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
2108
+ };
2109
+ }
2110
+
2111
+ #rgbToOKLCH(rgb) {
2112
+ const lab = this.#rgbToOKLAB(rgb);
2113
+ return {
2114
+ l: lab.l,
2115
+ c: Math.sqrt(lab.a * lab.a + lab.b * lab.b),
2116
+ h: ((Math.atan2(lab.b, lab.a) * 180) / Math.PI + 360) % 360,
2117
+ };
2118
+ }
2119
+
2120
+ #oklabToRGB(lab) {
2121
+ const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
2122
+ const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
2123
+ const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b;
2124
+
2125
+ const l = l_ * l_ * l_;
2126
+ const m = m_ * m_ * m_;
2127
+ const s = s_ * s_ * s_;
2128
+
2129
+ const toSRGB = (c) => {
2130
+ const v =
2131
+ c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
2132
+ return Math.round(Math.max(0, Math.min(1, v)) * 255);
2133
+ };
2134
+
2135
+ return {
2136
+ r: toSRGB(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
2137
+ g: toSRGB(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
2138
+ b: toSRGB(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
2139
+ };
2140
+ }
2141
+
2142
+ #oklchToRGB(lch) {
2143
+ const hRad = (lch.h * Math.PI) / 180;
2144
+ return this.#oklabToRGB({
2145
+ l: lch.l,
2146
+ a: lch.c * Math.cos(hRad),
2147
+ b: lch.c * Math.sin(hRad),
2148
+ });
2149
+ }
2150
+
2151
+ // ============ EVENT EMITTERS ============
2152
+ #emitInput() {
2153
+ this.#updateChit();
2154
+ this.dispatchEvent(
2155
+ new CustomEvent("input", {
2156
+ bubbles: true,
2157
+ detail: this.value,
2158
+ }),
2159
+ );
2160
+ }
2161
+
2162
+ #emitChange() {
2163
+ this.dispatchEvent(
2164
+ new CustomEvent("change", {
2165
+ bubbles: true,
2166
+ detail: this.value,
2167
+ }),
2168
+ );
2169
+ }
2170
+
2171
+ // ============ PUBLIC API ============
2172
+ get value() {
2173
+ const base = { type: this.#fillType, colorSpace: this.#gamut };
2174
+
2175
+ switch (this.#fillType) {
2176
+ case "solid":
2177
+ return {
2178
+ ...base,
2179
+ color: this.#hsvToHex(this.#color),
2180
+ alpha: this.#color.a,
2181
+ hsv: { ...this.#color },
2182
+ };
2183
+ case "gradient":
2184
+ return {
2185
+ ...base,
2186
+ gradient: gradientToValueShape(this.#gradient),
2187
+ css: this.#getGradientCSS(),
2188
+ };
2189
+ case "image":
2190
+ return {
2191
+ ...base,
2192
+ image: { ...this.#image },
2193
+ };
2194
+ case "video":
2195
+ return {
2196
+ ...base,
2197
+ video: { ...this.#video },
2198
+ };
2199
+ case "webcam":
2200
+ return {
2201
+ ...base,
2202
+ image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
2203
+ };
2204
+ default:
2205
+ return { ...base, ...this.#customData[this.#fillType] };
2206
+ }
2207
+ }
2208
+
2209
+ set value(val) {
2210
+ if (typeof val === "string") {
2211
+ this.setAttribute("value", val);
2212
+ } else {
2213
+ this.setAttribute("value", JSON.stringify(val));
2214
+ }
2215
+ }
2216
+
2217
+ attributeChangedCallback(name, oldValue, newValue) {
2218
+ if (oldValue === newValue) return;
2219
+
2220
+ switch (name) {
2221
+ case "value":
2222
+ this.#parseValue();
2223
+ this.#updateChit();
2224
+ if (this.#dialog) {
2225
+ // Update dialog UI if open - but don't rebuild if user is dragging
2226
+ if (!this.#isDraggingColor) {
2227
+ // Just update the handle position and color inputs without rebuilding
2228
+ this.#updateHandlePosition();
2229
+ this.#updateColorInputs();
2230
+ // Update hue slider
2231
+ if (this.#hueSlider) {
2232
+ this.#hueSlider.setAttribute("value", this.#color.h);
2233
+ }
2234
+ // Update opacity slider
2235
+ if (this.#opacitySlider) {
2236
+ this.#opacitySlider.setAttribute("value", this.#color.a * 100);
2237
+ this.#opacitySlider.setAttribute(
2238
+ "color",
2239
+ this.#hsvToHex(this.#color),
2240
+ );
2241
+ }
2242
+ }
2243
+ }
2244
+ break;
2245
+ case "disabled":
2246
+ // Handled in click listener
2247
+ break;
2248
+ }
2249
+ }
2250
+ }
2251
+ customElements.define("fig-fill-picker", FigFillPicker);