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