@rogieking/figui3 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -632,6 +632,9 @@ customElements.define("fig-popover", FigPopover);
632
632
  * A custom dialog element for modal and non-modal dialogs.
633
633
  * @attr {boolean} open - Whether the dialog is visible
634
634
  * @attr {boolean} modal - Whether the dialog should be modal
635
+ * @attr {boolean} drag - Whether the dialog is draggable
636
+ * @attr {string} handle - CSS selector for the drag handle element (e.g., "fig-header"). If not specified, the entire dialog is draggable when drag is enabled.
637
+ * @attr {string} position - Position of the dialog (e.g., "bottom right", "top left", "center center")
635
638
  */
636
639
  class FigDialog extends HTMLDialogElement {
637
640
  #isDragging = false;
@@ -734,10 +737,13 @@ class FigDialog extends HTMLDialogElement {
734
737
  #setupDragListeners() {
735
738
  if (this.drag) {
736
739
  this.addEventListener("pointerdown", this.#boundPointerDown);
737
- // Set move cursor only on fig-header elements
738
- const header = this.querySelector("fig-header, header");
739
- if (header) {
740
- header.style.cursor = "move";
740
+ // Set move cursor on handle element (or fig-header by default)
741
+ const handleSelector = this.getAttribute("handle");
742
+ const handleEl = handleSelector
743
+ ? this.querySelector(handleSelector)
744
+ : this.querySelector("fig-header, header");
745
+ if (handleEl) {
746
+ handleEl.style.cursor = "move";
741
747
  }
742
748
  }
743
749
  }
@@ -767,6 +773,13 @@ class FigDialog extends HTMLDialogElement {
767
773
  "FIG-DIALOG",
768
774
  "FIG-FIELD",
769
775
  "FIG-TOOLTIP",
776
+ "FIG-CONTENT",
777
+ "FIG-TABS",
778
+ "FIG-TAB",
779
+ "FIG-POPOVER",
780
+ "FIG-SHIMMER",
781
+ "FIG-LAYER",
782
+ "FIG-FILL-PICKER",
770
783
  ];
771
784
 
772
785
  const isInteractive = (el) =>
@@ -796,6 +809,17 @@ class FigDialog extends HTMLDialogElement {
796
809
  return;
797
810
  }
798
811
 
812
+ // If handle attribute is specified, only allow drag from within that element
813
+ // Otherwise, allow dragging from anywhere on the dialog (except interactive elements)
814
+ const handleSelector = this.getAttribute("handle");
815
+ if (handleSelector && handleSelector.trim()) {
816
+ const handleEl = this.querySelector(handleSelector);
817
+ if (!handleEl || !handleEl.contains(e.target)) {
818
+ return;
819
+ }
820
+ }
821
+ // No handle specified = drag from anywhere (original behavior)
822
+
799
823
  this.#isDragging = true;
800
824
  this.setPointerCapture(e.pointerId);
801
825
 
@@ -847,7 +871,7 @@ class FigDialog extends HTMLDialogElement {
847
871
  }
848
872
 
849
873
  static get observedAttributes() {
850
- return ["modal", "drag", "position"];
874
+ return ["modal", "drag", "position", "handle"];
851
875
  }
852
876
 
853
877
  attributeChangedCallback(name, oldValue, newValue) {
@@ -2061,22 +2085,33 @@ class FigInputColor extends HTMLElement {
2061
2085
  hex;
2062
2086
  alpha = 100;
2063
2087
  #swatch;
2088
+ #fillPicker;
2064
2089
  #textInput;
2065
2090
  #alphaInput;
2066
2091
  constructor() {
2067
2092
  super();
2068
2093
  }
2094
+
2095
+ get picker() {
2096
+ return this.getAttribute("picker") || "native";
2097
+ }
2098
+
2069
2099
  connectedCallback() {
2070
2100
  this.#setValues(this.getAttribute("value"));
2071
2101
 
2102
+ const useFigmaPicker = this.picker === "figma";
2103
+ const hidePicker = this.picker === "false";
2104
+ const showAlpha = this.getAttribute("alpha") === "true";
2105
+
2072
2106
  let html = ``;
2073
2107
  if (this.getAttribute("text")) {
2108
+ // Display without # prefix
2074
2109
  let label = `<fig-input-text
2075
2110
  type="text"
2076
- placeholder="#000000"
2077
- value="${this.value}">
2111
+ placeholder="000000"
2112
+ value="${this.hexOpaque.slice(1).toUpperCase()}">
2078
2113
  </fig-input-text>`;
2079
- if (this.getAttribute("alpha") === "true") {
2114
+ if (showAlpha) {
2080
2115
  label += `<fig-tooltip text="Opacity">
2081
2116
  <fig-input-number
2082
2117
  placeholder="##"
@@ -2087,28 +2122,58 @@ class FigInputColor extends HTMLElement {
2087
2122
  </fig-input-number>
2088
2123
  </fig-tooltip>`;
2089
2124
  }
2125
+
2126
+ let swatchElement = "";
2127
+ if (!hidePicker) {
2128
+ swatchElement = useFigmaPicker
2129
+ ? `<fig-fill-picker mode="solid" ${showAlpha ? "" : 'alpha="false"'} value='{"type":"solid","color":"${this.hexOpaque}"}'></fig-fill-picker>`
2130
+ : `<fig-chit background="${this.hexOpaque}"></fig-chit>`;
2131
+ }
2132
+
2090
2133
  html = `<div class="input-combo">
2091
- <fig-chit type="color" disabled="false" value="${this.hexOpaque}"></fig-chit>
2134
+ ${swatchElement}
2092
2135
  ${label}
2093
2136
  </div>`;
2094
2137
  } else {
2095
- html = `<fig-chit type="color" disabled="false" value="${this.hexOpaque}"></fig-chit>`;
2138
+ // Without text, if picker is hidden, show nothing
2139
+ if (hidePicker) {
2140
+ html = ``;
2141
+ } else {
2142
+ html = useFigmaPicker
2143
+ ? `<fig-fill-picker mode="solid" ${showAlpha ? "" : 'alpha="false"'} value='{"type":"solid","color":"${this.hexOpaque}"}'></fig-fill-picker>`
2144
+ : `<fig-chit background="${this.hexOpaque}"></fig-chit>`;
2145
+ }
2096
2146
  }
2097
2147
  this.innerHTML = html;
2098
2148
 
2099
2149
  requestAnimationFrame(() => {
2100
- this.#swatch = this.querySelector("fig-chit[type=color]");
2150
+ this.#swatch = this.querySelector("fig-chit");
2151
+ this.#fillPicker = this.querySelector("fig-fill-picker");
2101
2152
  this.#textInput = this.querySelector("fig-input-text:not([type=number])");
2102
2153
  this.#alphaInput = this.querySelector("fig-input-number");
2103
2154
 
2104
- this.#swatch.disabled = this.hasAttribute("disabled");
2105
- this.#swatch.addEventListener("input", this.#handleInput.bind(this));
2155
+ // Setup swatch (native picker)
2156
+ if (this.#swatch) {
2157
+ this.#swatch.disabled = this.hasAttribute("disabled");
2158
+ this.#swatch.addEventListener("input", this.#handleInput.bind(this));
2159
+ }
2160
+
2161
+ // Setup fill picker (figma picker)
2162
+ if (this.#fillPicker) {
2163
+ if (this.hasAttribute("disabled")) {
2164
+ this.#fillPicker.setAttribute("disabled", "");
2165
+ }
2166
+ this.#fillPicker.addEventListener("input", this.#handleFillPickerInput.bind(this));
2167
+ this.#fillPicker.addEventListener("change", this.#handleChange.bind(this));
2168
+ }
2106
2169
 
2107
2170
  if (this.#textInput) {
2108
- this.#textInput.value = this.#swatch.value = this.rgbAlphaToHex(
2109
- this.rgba,
2110
- 1
2111
- );
2171
+ const hex = this.rgbAlphaToHex(this.rgba, 1);
2172
+ // Display without # prefix
2173
+ this.#textInput.value = hex.slice(1).toUpperCase();
2174
+ if (this.#swatch) {
2175
+ this.#swatch.background = hex;
2176
+ }
2112
2177
  this.#textInput.addEventListener(
2113
2178
  "input",
2114
2179
  this.#handleTextInput.bind(this)
@@ -2148,12 +2213,14 @@ class FigInputColor extends HTMLElement {
2148
2213
  #handleTextInput(event) {
2149
2214
  //do not propagate to onInput handler for web component
2150
2215
  event.stopPropagation();
2151
- this.#setValues(event.target.value);
2216
+ // Add # prefix if not present for internal processing
2217
+ let inputValue = event.target.value.replace("#", "");
2218
+ this.#setValues("#" + inputValue);
2152
2219
  if (this.#alphaInput) {
2153
2220
  this.#alphaInput.setAttribute("value", this.alpha);
2154
2221
  }
2155
2222
  if (this.#swatch) {
2156
- this.#swatch.setAttribute("value", this.hexOpaque);
2223
+ this.#swatch.setAttribute("background", this.hexOpaque);
2157
2224
  }
2158
2225
  this.#emitInputEvent();
2159
2226
  }
@@ -2183,11 +2250,33 @@ class FigInputColor extends HTMLElement {
2183
2250
  event.stopPropagation();
2184
2251
  this.#setValues(event.target.value);
2185
2252
  if (this.#textInput) {
2186
- this.#textInput.setAttribute("value", this.value);
2253
+ // Display without # prefix
2254
+ this.#textInput.setAttribute(
2255
+ "value",
2256
+ this.hexOpaque.slice(1).toUpperCase()
2257
+ );
2187
2258
  }
2188
2259
  this.#emitInputEvent();
2189
2260
  }
2190
2261
 
2262
+ #handleFillPickerInput(event) {
2263
+ event.stopPropagation();
2264
+ const detail = event.detail;
2265
+ if (detail && detail.color) {
2266
+ this.#setValues(detail.color);
2267
+ if (this.#textInput) {
2268
+ this.#textInput.setAttribute(
2269
+ "value",
2270
+ this.hexOpaque.slice(1).toUpperCase()
2271
+ );
2272
+ }
2273
+ if (this.#alphaInput && detail.alpha !== undefined) {
2274
+ this.#alphaInput.setAttribute("value", Math.round(detail.alpha * 100));
2275
+ }
2276
+ this.#emitInputEvent();
2277
+ }
2278
+ }
2279
+
2191
2280
  #emitInputEvent() {
2192
2281
  const e = new CustomEvent("input", {
2193
2282
  bubbles: true,
@@ -2204,7 +2293,11 @@ class FigInputColor extends HTMLElement {
2204
2293
  }
2205
2294
 
2206
2295
  static get observedAttributes() {
2207
- return ["value", "style"];
2296
+ return ["value", "style", "mode", "picker"];
2297
+ }
2298
+
2299
+ get mode() {
2300
+ return this.getAttribute("mode");
2208
2301
  }
2209
2302
 
2210
2303
  attributeChangedCallback(name, oldValue, newValue) {
@@ -2215,13 +2308,25 @@ class FigInputColor extends HTMLElement {
2215
2308
  this.#textInput.setAttribute("value", this.value);
2216
2309
  }
2217
2310
  if (this.#swatch) {
2218
- this.#swatch.setAttribute("value", this.hexOpaque);
2311
+ this.#swatch.setAttribute("background", this.hexOpaque);
2312
+ }
2313
+ if (this.#fillPicker) {
2314
+ this.#fillPicker.setAttribute("value", JSON.stringify({ type: "solid", color: this.hexOpaque }));
2219
2315
  }
2220
2316
  if (this.#alphaInput) {
2221
2317
  this.#alphaInput.setAttribute("value", this.alpha);
2222
2318
  }
2223
2319
  this.#emitInputEvent();
2224
2320
  break;
2321
+ case "mode":
2322
+ // Mode attribute is passed through to fig-fill-picker when used
2323
+ if (this.#fillPicker && newValue) {
2324
+ this.#fillPicker.setAttribute("mode", newValue);
2325
+ }
2326
+ break;
2327
+ case "picker":
2328
+ // Picker type change requires re-render
2329
+ break;
2225
2330
  }
2226
2331
  }
2227
2332
 
@@ -2678,67 +2783,116 @@ window.customElements.define("fig-combo-input", FigComboInput);
2678
2783
 
2679
2784
  /* Chit */
2680
2785
  /**
2681
- * A custom color/image chip element.
2682
- * @attr {string} type - The chip type: "color" or "image"
2683
- * @attr {string} src - Image source URL (for image type)
2684
- * @attr {string} value - Color value (for color type)
2786
+ * A color/gradient/image swatch element.
2787
+ * @attr {string} background - Any CSS background value: color (#FF0000, rgba(...)), gradient (linear-gradient(...)), or image (url(...))
2685
2788
  * @attr {string} size - Size of the chip: "small" (default) or "large"
2789
+ * @attr {boolean} selected - Whether the chip shows a selection ring
2686
2790
  * @attr {boolean} disabled - Whether the chip is disabled
2687
2791
  */
2688
2792
  class FigChit extends HTMLElement {
2689
- #src = null;
2793
+ #type = "color"; // 'color', 'gradient', 'image'
2794
+ #boundHandleInput = null;
2795
+ #internalUpdate = false; // Flag to prevent re-render during internal input
2796
+
2690
2797
  constructor() {
2691
2798
  super();
2799
+ this.#boundHandleInput = this.#handleInput.bind(this);
2800
+ }
2801
+
2802
+ static get observedAttributes() {
2803
+ return ["background", "size", "selected", "disabled"];
2692
2804
  }
2805
+
2693
2806
  connectedCallback() {
2694
- this.type = this.getAttribute("type") || "color";
2695
- this.#src = this.getAttribute("src") || "";
2696
- this.value = this.getAttribute("value") || "#000000";
2697
- this.size = this.getAttribute("size") || "small";
2698
- this.disabled = this.getAttribute("disabled") === "true";
2699
- this.innerHTML = `<input type="color" value="${this.value}" />`;
2700
- this.#updateSrc(this.src);
2807
+ this.#render();
2808
+ }
2701
2809
 
2702
- requestAnimationFrame(() => {
2703
- this.input = this.querySelector("input");
2704
- });
2810
+ #detectType(bg) {
2811
+ if (!bg) return "color";
2812
+ const lower = bg.toLowerCase();
2813
+ if (lower.includes("gradient")) return "gradient";
2814
+ if (lower.includes("url(")) return "image";
2815
+ return "color";
2705
2816
  }
2706
- #updateSrc(src) {
2707
- if (src) {
2708
- this.#src = src;
2709
- this.style.setProperty("--src", `url(${src})`);
2710
- } else {
2711
- this.style.removeProperty("--src");
2712
- this.#src = null;
2817
+
2818
+ #toHex(color) {
2819
+ // Convert color to hex for the native input
2820
+ if (!color) return "#D9D9D9";
2821
+ if (color.startsWith("#")) return color.slice(0, 7);
2822
+ // Use canvas to convert rgba/named colors to hex
2823
+ try {
2824
+ const ctx = document.createElement("canvas").getContext("2d");
2825
+ ctx.fillStyle = color;
2826
+ return ctx.fillStyle;
2827
+ } catch {
2828
+ return "#D9D9D9";
2713
2829
  }
2714
2830
  }
2715
- static get observedAttributes() {
2716
- return ["src", "value", "disabled"];
2831
+
2832
+ #render() {
2833
+ const bg = this.getAttribute("background") || "#D9D9D9";
2834
+ const newType = this.#detectType(bg);
2835
+
2836
+ // Only rebuild DOM if type changes
2837
+ if (newType !== this.#type || !this.input) {
2838
+ this.#type = newType;
2839
+ this.setAttribute("data-type", this.#type);
2840
+
2841
+ // Clean up old input listener if exists
2842
+ if (this.input) {
2843
+ this.input.removeEventListener("input", this.#boundHandleInput);
2844
+ }
2845
+
2846
+ if (this.#type === "color") {
2847
+ const hex = this.#toHex(bg);
2848
+ this.innerHTML = `<input type="color" value="${hex}" />`;
2849
+ this.input = this.querySelector("input");
2850
+ this.input.addEventListener("input", this.#boundHandleInput);
2851
+ } else {
2852
+ this.innerHTML = "";
2853
+ this.input = null;
2854
+ }
2855
+ } else if (this.#type === "color" && this.input) {
2856
+ // Just update input value without rebuilding DOM
2857
+ const hex = this.#toHex(bg);
2858
+ if (this.input.value !== hex) {
2859
+ this.input.value = hex;
2860
+ }
2861
+ }
2862
+
2863
+ // Always update CSS variable
2864
+ this.style.setProperty("--chit-background", bg);
2717
2865
  }
2718
- get src() {
2719
- return this.#src;
2866
+
2867
+ #handleInput(e) {
2868
+ // Update background attribute without triggering full re-render
2869
+ this.#internalUpdate = true;
2870
+ this.setAttribute("background", e.target.value);
2871
+ this.#internalUpdate = false;
2872
+ // The native input/change events bubble naturally
2720
2873
  }
2721
- set src(value) {
2722
- this.#src = value;
2723
- this.setAttribute("src", value);
2874
+
2875
+ get background() {
2876
+ return this.getAttribute("background");
2877
+ }
2878
+
2879
+ set background(value) {
2880
+ this.setAttribute("background", value);
2724
2881
  }
2882
+
2725
2883
  focus() {
2726
2884
  this.input?.focus();
2727
2885
  }
2886
+
2728
2887
  attributeChangedCallback(name, oldValue, newValue) {
2729
- switch (name) {
2730
- case "src":
2731
- this.#updateSrc(newValue);
2732
- break;
2733
- case "disabled":
2734
- this.disabled = newValue.toLowerCase() === "true";
2735
- break;
2736
- default:
2737
- if (this.input) {
2738
- this.input[name] = newValue;
2739
- }
2740
- this.#updateSrc(this.src);
2741
- break;
2888
+ if (oldValue === newValue) return;
2889
+ if (name === "background") {
2890
+ // Skip full re-render if this was triggered by internal input
2891
+ if (this.#internalUpdate) {
2892
+ this.style.setProperty("--chit-background", newValue);
2893
+ return;
2894
+ }
2895
+ this.#render();
2742
2896
  }
2743
2897
  }
2744
2898
  }
@@ -2758,9 +2912,9 @@ class FigImage extends HTMLElement {
2758
2912
  super();
2759
2913
  }
2760
2914
  #getInnerHTML() {
2761
- return `<fig-chit type="image" size="large" ${
2762
- this.src ? `src="${this.src}"` : ""
2763
- } disabled="true"></fig-chit><div>${
2915
+ return `<fig-chit size="large" background="${
2916
+ this.src ? `url(${this.src})` : "url()"
2917
+ }" disabled></fig-chit><div>${
2764
2918
  this.upload
2765
2919
  ? `<fig-button variant="overlay" type="upload">
2766
2920
  ${this.label}
@@ -2923,7 +3077,10 @@ class FigImage extends HTMLElement {
2923
3077
  if (name === "src") {
2924
3078
  this.#src = newValue;
2925
3079
  if (this.chit) {
2926
- this.chit.setAttribute("src", this.#src);
3080
+ this.chit.setAttribute(
3081
+ "background",
3082
+ this.#src ? `url(${this.#src})` : ""
3083
+ );
2927
3084
  }
2928
3085
  if (this.#src) {
2929
3086
  this.#loadImage(this.#src);
@@ -3670,3 +3827,1541 @@ class FigLayer extends HTMLElement {
3670
3827
  }
3671
3828
  }
3672
3829
  customElements.define("fig-layer", FigLayer);
3830
+
3831
+ // FigFillPicker
3832
+ /**
3833
+ * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
3834
+ * Uses display: contents and wraps a trigger element that opens a dialog picker.
3835
+ *
3836
+ * @attr {string} value - JSON-encoded fill value
3837
+ * @attr {boolean} disabled - Whether the picker is disabled
3838
+ * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
3839
+ * @attr {string} dialog-position - Position of the dialog (passed to fig-dialog)
3840
+ */
3841
+ class FigFillPicker extends HTMLElement {
3842
+ #trigger = null;
3843
+ #chit = null;
3844
+ #dialog = null;
3845
+ #activeTab = "solid";
3846
+
3847
+ // Fill state
3848
+ #fillType = "solid";
3849
+ #color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9
3850
+ #gradient = {
3851
+ type: "linear",
3852
+ angle: 0,
3853
+ centerX: 50,
3854
+ centerY: 50,
3855
+ stops: [
3856
+ { position: 0, color: "#D9D9D9", opacity: 100 },
3857
+ { position: 100, color: "#737373", opacity: 100 },
3858
+ ],
3859
+ };
3860
+ #image = { url: null, scaleMode: "fill", scale: 50 };
3861
+ #video = { url: null, scaleMode: "fill", scale: 50 };
3862
+ #webcam = { stream: null, snapshot: null };
3863
+
3864
+ // DOM references for solid tab
3865
+ #colorArea = null;
3866
+ #colorAreaHandle = null;
3867
+ #hueSlider = null;
3868
+ #opacitySlider = null;
3869
+ #isDraggingColor = false;
3870
+
3871
+ constructor() {
3872
+ super();
3873
+ }
3874
+
3875
+ static get observedAttributes() {
3876
+ return ["value", "disabled", "alpha", "mode"];
3877
+ }
3878
+
3879
+ connectedCallback() {
3880
+ // Use display: contents
3881
+ this.style.display = "contents";
3882
+
3883
+ requestAnimationFrame(() => {
3884
+ this.#setupTrigger();
3885
+ this.#parseValue();
3886
+ this.#updateChit();
3887
+ });
3888
+ }
3889
+
3890
+ disconnectedCallback() {
3891
+ if (this.#dialog) {
3892
+ this.#dialog.close();
3893
+ this.#dialog.remove();
3894
+ }
3895
+ }
3896
+
3897
+ #setupTrigger() {
3898
+ const child = this.firstElementChild;
3899
+
3900
+ if (!child) {
3901
+ // Scenario 1: Empty - create fig-chit
3902
+ this.#chit = document.createElement("fig-chit");
3903
+ this.#chit.setAttribute("background", "#D9D9D9");
3904
+ this.appendChild(this.#chit);
3905
+ this.#trigger = this.#chit;
3906
+ } else if (child.tagName === "FIG-CHIT") {
3907
+ // Scenario 2: Has fig-chit - use and populate it
3908
+ this.#chit = child;
3909
+ this.#trigger = child;
3910
+ } else {
3911
+ // Scenario 3: Other element - trigger only, no populate
3912
+ this.#trigger = child;
3913
+ this.#chit = null;
3914
+ }
3915
+
3916
+ this.#trigger.addEventListener("click", (e) => {
3917
+ if (this.hasAttribute("disabled")) return;
3918
+ e.stopPropagation();
3919
+ e.preventDefault();
3920
+ this.#openDialog();
3921
+ });
3922
+
3923
+ // Prevent fig-chit's internal color input from opening system picker
3924
+ if (this.#chit) {
3925
+ requestAnimationFrame(() => {
3926
+ const input = this.#chit.querySelector('input[type="color"]');
3927
+ if (input) {
3928
+ input.style.pointerEvents = "none";
3929
+ }
3930
+ });
3931
+ }
3932
+ }
3933
+
3934
+ #parseValue() {
3935
+ const valueAttr = this.getAttribute("value");
3936
+ if (!valueAttr) return;
3937
+
3938
+ try {
3939
+ const parsed = JSON.parse(valueAttr);
3940
+ if (parsed.type) this.#fillType = parsed.type;
3941
+ if (parsed.color) {
3942
+ // Handle both hex string and HSV object
3943
+ if (typeof parsed.color === "string") {
3944
+ this.#color = this.#hexToHSV(parsed.color);
3945
+ } else if (
3946
+ typeof parsed.color === "object" &&
3947
+ parsed.color.h !== undefined
3948
+ ) {
3949
+ this.#color = parsed.color;
3950
+ }
3951
+ }
3952
+ if (parsed.gradient)
3953
+ this.#gradient = { ...this.#gradient, ...parsed.gradient };
3954
+ if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
3955
+ if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
3956
+ } catch (e) {
3957
+ // If not JSON, treat as hex color
3958
+ if (valueAttr.startsWith("#")) {
3959
+ this.#fillType = "solid";
3960
+ this.#color = this.#hexToHSV(valueAttr);
3961
+ }
3962
+ }
3963
+ }
3964
+
3965
+ #updateChit() {
3966
+ if (!this.#chit) return;
3967
+
3968
+ let bg;
3969
+ let bgSize = "cover";
3970
+ let bgPosition = "center";
3971
+
3972
+ switch (this.#fillType) {
3973
+ case "solid":
3974
+ bg = this.#hsvToHex(this.#color);
3975
+ break;
3976
+ case "gradient":
3977
+ bg = this.#getGradientCSS();
3978
+ break;
3979
+ case "image":
3980
+ if (this.#image.url) {
3981
+ bg = `url(${this.#image.url})`;
3982
+ const sizing = this.#getBackgroundSizing(
3983
+ this.#image.scaleMode,
3984
+ this.#image.scale
3985
+ );
3986
+ bgSize = sizing.size;
3987
+ bgPosition = sizing.position;
3988
+ } else {
3989
+ bg = "";
3990
+ }
3991
+ break;
3992
+ case "video":
3993
+ if (this.#video.url) {
3994
+ bg = `url(${this.#video.url})`;
3995
+ const sizing = this.#getBackgroundSizing(
3996
+ this.#video.scaleMode,
3997
+ this.#video.scale
3998
+ );
3999
+ bgSize = sizing.size;
4000
+ bgPosition = sizing.position;
4001
+ } else {
4002
+ bg = "";
4003
+ }
4004
+ break;
4005
+ default:
4006
+ bg = "#D9D9D9";
4007
+ }
4008
+
4009
+ this.#chit.setAttribute("background", bg);
4010
+ this.#chit.style.setProperty("--chit-bg-size", bgSize);
4011
+ this.#chit.style.setProperty("--chit-bg-position", bgPosition);
4012
+ }
4013
+
4014
+ #getBackgroundSizing(scaleMode, scale) {
4015
+ switch (scaleMode) {
4016
+ case "fill":
4017
+ return { size: "cover", position: "center" };
4018
+ case "fit":
4019
+ return { size: "contain", position: "center" };
4020
+ case "crop":
4021
+ return { size: "cover", position: "center" };
4022
+ case "tile":
4023
+ return { size: `${scale}%`, position: "top left" };
4024
+ default:
4025
+ return { size: "cover", position: "center" };
4026
+ }
4027
+ }
4028
+
4029
+ #openDialog() {
4030
+ if (!this.#dialog) {
4031
+ this.#createDialog();
4032
+ }
4033
+
4034
+ // Position off-screen first to prevent scroll jump
4035
+ this.#dialog.style.position = "fixed";
4036
+ this.#dialog.style.top = "-9999px";
4037
+ this.#dialog.style.left = "-9999px";
4038
+
4039
+ this.#dialog.show();
4040
+ this.#switchTab(this.#fillType);
4041
+
4042
+ // Position after dialog has rendered and has dimensions
4043
+ // Use nested RAF to ensure canvas is fully ready for drawing
4044
+ requestAnimationFrame(() => {
4045
+ this.#positionDialog();
4046
+ this.#dialog.setAttribute("closedby", "any");
4047
+
4048
+ // Second RAF ensures the dialog is visible and canvas is ready
4049
+ requestAnimationFrame(() => {
4050
+ this.#drawColorArea();
4051
+ this.#updateHandlePosition();
4052
+ });
4053
+ });
4054
+ }
4055
+
4056
+ #positionDialog() {
4057
+ const triggerRect = this.#trigger.getBoundingClientRect();
4058
+ const dialogRect = this.#dialog.getBoundingClientRect();
4059
+ const padding = 8; // Gap between trigger and dialog
4060
+ const viewportPadding = 16; // Min distance from viewport edges
4061
+
4062
+ // Calculate available space in each direction
4063
+ const spaceBelow =
4064
+ window.innerHeight - triggerRect.bottom - viewportPadding;
4065
+ const spaceAbove = triggerRect.top - viewportPadding;
4066
+ const spaceRight = window.innerWidth - triggerRect.left - viewportPadding;
4067
+ const spaceLeft = triggerRect.right - viewportPadding;
4068
+
4069
+ let top, left;
4070
+
4071
+ // Vertical positioning: prefer below, fallback to above
4072
+ if (spaceBelow >= dialogRect.height || spaceBelow >= spaceAbove) {
4073
+ // Position below trigger
4074
+ top = triggerRect.bottom + padding;
4075
+ } else {
4076
+ // Position above trigger
4077
+ top = triggerRect.top - dialogRect.height - padding;
4078
+ }
4079
+
4080
+ // Horizontal positioning: align left edge with trigger, adjust if needed
4081
+ left = triggerRect.left;
4082
+
4083
+ // Adjust if dialog would go off right edge
4084
+ if (left + dialogRect.width > window.innerWidth - viewportPadding) {
4085
+ left = window.innerWidth - dialogRect.width - viewportPadding;
4086
+ }
4087
+
4088
+ // Adjust if dialog would go off left edge
4089
+ if (left < viewportPadding) {
4090
+ left = viewportPadding;
4091
+ }
4092
+
4093
+ // Clamp vertical position to viewport
4094
+ if (top < viewportPadding) {
4095
+ top = viewportPadding;
4096
+ }
4097
+ if (top + dialogRect.height > window.innerHeight - viewportPadding) {
4098
+ top = window.innerHeight - dialogRect.height - viewportPadding;
4099
+ }
4100
+
4101
+ // Apply position (override fig-dialog's default positioning)
4102
+ this.#dialog.style.position = "fixed";
4103
+ this.#dialog.style.top = `${top}px`;
4104
+ this.#dialog.style.left = `${left}px`;
4105
+ this.#dialog.style.bottom = "auto";
4106
+ this.#dialog.style.right = "auto";
4107
+ this.#dialog.style.margin = "0";
4108
+ }
4109
+
4110
+ #createDialog() {
4111
+ this.#dialog = document.createElement("dialog", { is: "fig-dialog" });
4112
+ this.#dialog.setAttribute("is", "fig-dialog");
4113
+ this.#dialog.setAttribute("drag", "true");
4114
+ this.#dialog.setAttribute("handle", "fig-header");
4115
+ this.#dialog.classList.add("fig-fill-picker-dialog");
4116
+
4117
+ // Forward dialog attributes
4118
+ const dialogPosition = this.getAttribute("dialog-position");
4119
+ if (dialogPosition) {
4120
+ this.#dialog.setAttribute("position", dialogPosition);
4121
+ }
4122
+
4123
+ // Check for locked mode
4124
+ const mode = this.getAttribute("mode");
4125
+ const validModes = ["solid", "gradient", "image", "video", "webcam"];
4126
+ const lockedMode = validModes.includes(mode) ? mode : null;
4127
+
4128
+ // If locked mode, force fillType
4129
+ if (lockedMode) {
4130
+ this.#fillType = lockedMode;
4131
+ this.#activeTab = lockedMode;
4132
+ }
4133
+
4134
+ // Build header content - dropdown or label
4135
+ const headerContent = lockedMode
4136
+ ? `<span class="fig-fill-picker-type-label">${lockedMode.charAt(0).toUpperCase() + lockedMode.slice(1)}</span>`
4137
+ : `<fig-dropdown class="fig-fill-picker-type" value="${this.#fillType}">
4138
+ <option value="solid">Solid</option>
4139
+ <option value="gradient">Gradient</option>
4140
+ <option value="image">Image</option>
4141
+ <option value="video">Video</option>
4142
+ <option value="webcam">Webcam</option>
4143
+ </fig-dropdown>`;
4144
+
4145
+ this.#dialog.innerHTML = `
4146
+ <fig-header>
4147
+ ${headerContent}
4148
+ <fig-button icon variant="ghost" close-dialog>
4149
+ <span class="fig-mask-icon" style="--icon: var(--icon-close)"></span>
4150
+ </fig-button>
4151
+ </fig-header>
4152
+ <div class="fig-fill-picker-content">
4153
+ <div class="fig-fill-picker-tab" data-tab="solid"></div>
4154
+ <div class="fig-fill-picker-tab" data-tab="gradient"></div>
4155
+ <div class="fig-fill-picker-tab" data-tab="image"></div>
4156
+ <div class="fig-fill-picker-tab" data-tab="video"></div>
4157
+ <div class="fig-fill-picker-tab" data-tab="webcam"></div>
4158
+ </div>
4159
+ `;
4160
+
4161
+ document.body.appendChild(this.#dialog);
4162
+
4163
+ // Setup type dropdown switching (only if not locked)
4164
+ const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
4165
+ if (typeDropdown) {
4166
+ typeDropdown.addEventListener("change", (e) => {
4167
+ this.#switchTab(e.target.value);
4168
+ });
4169
+ }
4170
+
4171
+ // Close button
4172
+ this.#dialog
4173
+ .querySelector("fig-button[close-dialog]")
4174
+ .addEventListener("click", () => {
4175
+ this.#dialog.close();
4176
+ });
4177
+
4178
+ // Emit change on close
4179
+ this.#dialog.addEventListener("close", () => {
4180
+ this.#emitChange();
4181
+ });
4182
+
4183
+ // Initialize tabs
4184
+ this.#initSolidTab();
4185
+ this.#initGradientTab();
4186
+ this.#initImageTab();
4187
+ this.#initVideoTab();
4188
+ this.#initWebcamTab();
4189
+ }
4190
+
4191
+ #switchTab(tabName) {
4192
+ // Check for locked mode - prevent switching if locked
4193
+ const mode = this.getAttribute("mode");
4194
+ const validModes = ["solid", "gradient", "image", "video", "webcam"];
4195
+ const lockedMode = validModes.includes(mode) ? mode : null;
4196
+
4197
+ if (lockedMode && tabName !== lockedMode) {
4198
+ return; // Don't allow switching away from locked mode
4199
+ }
4200
+
4201
+ this.#activeTab = tabName;
4202
+ this.#fillType = tabName;
4203
+
4204
+ // Update dropdown selection (only exists if not locked)
4205
+ const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
4206
+ if (typeDropdown && typeDropdown.value !== tabName) {
4207
+ typeDropdown.value = tabName;
4208
+ }
4209
+
4210
+ // Show/hide tab content
4211
+ const tabContents = this.#dialog.querySelectorAll(".fig-fill-picker-tab");
4212
+ tabContents.forEach((content) => {
4213
+ if (content.dataset.tab === tabName) {
4214
+ content.style.display = "block";
4215
+ } else {
4216
+ content.style.display = "none";
4217
+ }
4218
+ });
4219
+
4220
+ // Update tab-specific UI after visibility change
4221
+ if (tabName === "gradient") {
4222
+ // Use RAF to ensure layout is complete before updating angle input
4223
+ requestAnimationFrame(() => {
4224
+ this.#updateGradientUI();
4225
+ });
4226
+ }
4227
+
4228
+ this.#updateChit();
4229
+ this.#emitInput();
4230
+ }
4231
+
4232
+ // ============ SOLID TAB ============
4233
+ #initSolidTab() {
4234
+ const container = this.#dialog.querySelector('[data-tab="solid"]');
4235
+ const showAlpha = this.getAttribute("alpha") !== "false";
4236
+
4237
+ container.innerHTML = `
4238
+ <div class="fig-fill-picker-color-area">
4239
+ <canvas width="200" height="200"></canvas>
4240
+ <div class="fig-fill-picker-handle"></div>
4241
+ </div>
4242
+ <div class="fig-fill-picker-sliders">
4243
+ <fig-slider type="hue" variant="neue" min="0" max="360" value="${
4244
+ this.#color.h
4245
+ }"></fig-slider>
4246
+ ${
4247
+ showAlpha
4248
+ ? `<fig-slider type="opacity" variant="neue" text="true" units="%" min="0" max="100" value="${
4249
+ this.#color.a * 100
4250
+ }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
4251
+ : ""
4252
+ }
4253
+ </div>
4254
+ <div class="fig-fill-picker-inputs">
4255
+ <fig-button icon variant="ghost" class="fig-fill-picker-eyedropper" title="Pick color from screen"><span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"></span></fig-button>
4256
+ <fig-input-color class="fig-fill-picker-color-input" text="true" picker="false" value="${this.#hsvToHex(
4257
+ this.#color
4258
+ )}"></fig-input-color>
4259
+ </div>
4260
+ `;
4261
+
4262
+ // Setup color area
4263
+ this.#colorArea = container.querySelector("canvas");
4264
+ this.#colorAreaHandle = container.querySelector(".fig-fill-picker-handle");
4265
+ this.#drawColorArea();
4266
+ this.#updateHandlePosition();
4267
+ this.#setupColorAreaEvents();
4268
+
4269
+ // Setup hue slider
4270
+ this.#hueSlider = container.querySelector('fig-slider[type="hue"]');
4271
+ this.#hueSlider.addEventListener("input", (e) => {
4272
+ this.#color.h = parseFloat(e.target.value);
4273
+ this.#drawColorArea();
4274
+ this.#updateColorInputs();
4275
+ this.#emitInput();
4276
+ });
4277
+
4278
+ // Setup opacity slider
4279
+ if (showAlpha) {
4280
+ this.#opacitySlider = container.querySelector(
4281
+ 'fig-slider[type="opacity"]'
4282
+ );
4283
+ this.#opacitySlider.addEventListener("input", (e) => {
4284
+ this.#color.a = parseFloat(e.target.value) / 100;
4285
+ this.#updateColorInputs();
4286
+ this.#emitInput();
4287
+ });
4288
+ }
4289
+
4290
+ // Setup color input
4291
+ const colorInput = container.querySelector(".fig-fill-picker-color-input");
4292
+ colorInput.addEventListener("input", (e) => {
4293
+ // Skip if we're dragging - prevents feedback loop that loses saturation for dark colors
4294
+ if (this.#isDraggingColor) return;
4295
+
4296
+ const hex = e.target.value;
4297
+ this.#color = { ...this.#hexToHSV(hex), a: this.#color.a };
4298
+ this.#drawColorArea();
4299
+ this.#updateHandlePosition();
4300
+ if (this.#hueSlider) {
4301
+ this.#hueSlider.setAttribute("value", this.#color.h);
4302
+ }
4303
+ this.#emitInput();
4304
+ });
4305
+
4306
+ // Setup eyedropper
4307
+ const eyedropper = container.querySelector(".fig-fill-picker-eyedropper");
4308
+ if ("EyeDropper" in window) {
4309
+ eyedropper.addEventListener("click", async () => {
4310
+ try {
4311
+ const dropper = new EyeDropper();
4312
+ const result = await dropper.open();
4313
+ this.#color = { ...this.#hexToHSV(result.sRGBHex), a: this.#color.a };
4314
+ this.#drawColorArea();
4315
+ this.#updateHandlePosition();
4316
+ this.#updateColorInputs();
4317
+ this.#emitInput();
4318
+ } catch (e) {
4319
+ // User cancelled or error
4320
+ }
4321
+ });
4322
+ } else {
4323
+ eyedropper.setAttribute("disabled", "");
4324
+ eyedropper.title = "EyeDropper not supported in this browser";
4325
+ }
4326
+ }
4327
+
4328
+ #drawColorArea() {
4329
+ // Refresh canvas reference in case DOM changed
4330
+ if (!this.#colorArea && this.#dialog) {
4331
+ this.#colorArea = this.#dialog.querySelector('[data-tab="solid"] canvas');
4332
+ }
4333
+ if (!this.#colorArea) return;
4334
+
4335
+ const ctx = this.#colorArea.getContext("2d");
4336
+ if (!ctx) return;
4337
+
4338
+ const width = this.#colorArea.width;
4339
+ const height = this.#colorArea.height;
4340
+
4341
+ // Clear canvas first
4342
+ ctx.clearRect(0, 0, width, height);
4343
+
4344
+ // Draw saturation-value gradient
4345
+ const hue = this.#color.h;
4346
+
4347
+ // Create horizontal gradient (white to hue color)
4348
+ const gradH = ctx.createLinearGradient(0, 0, width, 0);
4349
+ gradH.addColorStop(0, "#FFFFFF");
4350
+ gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
4351
+
4352
+ ctx.fillStyle = gradH;
4353
+ ctx.fillRect(0, 0, width, height);
4354
+
4355
+ // Create vertical gradient (transparent to black)
4356
+ const gradV = ctx.createLinearGradient(0, 0, 0, height);
4357
+ gradV.addColorStop(0, "rgba(0,0,0,0)");
4358
+ gradV.addColorStop(1, "rgba(0,0,0,1)");
4359
+
4360
+ ctx.fillStyle = gradV;
4361
+ ctx.fillRect(0, 0, width, height);
4362
+ }
4363
+
4364
+ #updateHandlePosition() {
4365
+ if (!this.#colorAreaHandle || !this.#colorArea) return;
4366
+
4367
+ const rect = this.#colorArea.getBoundingClientRect();
4368
+ const x = (this.#color.s / 100) * rect.width;
4369
+ const y = ((100 - this.#color.v) / 100) * rect.height;
4370
+
4371
+ this.#colorAreaHandle.style.left = `${x}px`;
4372
+ this.#colorAreaHandle.style.top = `${y}px`;
4373
+ this.#colorAreaHandle.style.setProperty(
4374
+ "--picker-color",
4375
+ this.#hsvToHex({ ...this.#color, a: 1 })
4376
+ );
4377
+ }
4378
+
4379
+ #setupColorAreaEvents() {
4380
+ if (!this.#colorArea || !this.#colorAreaHandle) return;
4381
+
4382
+ const updateFromEvent = (e) => {
4383
+ const rect = this.#colorArea.getBoundingClientRect();
4384
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
4385
+ const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
4386
+
4387
+ this.#color.s = (x / rect.width) * 100;
4388
+ this.#color.v = 100 - (y / rect.height) * 100;
4389
+
4390
+ this.#updateHandlePosition();
4391
+ this.#updateColorInputs();
4392
+ this.#emitInput();
4393
+ };
4394
+
4395
+ // Canvas click/drag
4396
+ this.#colorArea.addEventListener("pointerdown", (e) => {
4397
+ this.#isDraggingColor = true;
4398
+ this.#colorArea.setPointerCapture(e.pointerId);
4399
+ updateFromEvent(e);
4400
+ });
4401
+
4402
+ this.#colorArea.addEventListener("pointermove", (e) => {
4403
+ if (this.#isDraggingColor) {
4404
+ updateFromEvent(e);
4405
+ }
4406
+ });
4407
+
4408
+ this.#colorArea.addEventListener("pointerup", () => {
4409
+ this.#isDraggingColor = false;
4410
+ });
4411
+
4412
+ // Handle drag (for when handle is at corners)
4413
+ this.#colorAreaHandle.addEventListener("pointerdown", (e) => {
4414
+ e.stopPropagation(); // Prevent canvas from also capturing
4415
+ this.#isDraggingColor = true;
4416
+ this.#colorAreaHandle.setPointerCapture(e.pointerId);
4417
+ });
4418
+
4419
+ this.#colorAreaHandle.addEventListener("pointermove", (e) => {
4420
+ if (this.#isDraggingColor) {
4421
+ updateFromEvent(e);
4422
+ }
4423
+ });
4424
+
4425
+ this.#colorAreaHandle.addEventListener("pointerup", () => {
4426
+ this.#isDraggingColor = false;
4427
+ });
4428
+ }
4429
+
4430
+ #updateColorInputs() {
4431
+ if (!this.#dialog) return;
4432
+
4433
+ const hex = this.#hsvToHex(this.#color);
4434
+
4435
+ const colorInput = this.#dialog.querySelector(
4436
+ ".fig-fill-picker-color-input"
4437
+ );
4438
+ if (colorInput) {
4439
+ colorInput.setAttribute("value", hex);
4440
+ }
4441
+
4442
+ if (this.#opacitySlider) {
4443
+ this.#opacitySlider.setAttribute("color", hex);
4444
+ }
4445
+
4446
+ this.#updateChit();
4447
+ }
4448
+
4449
+ // ============ GRADIENT TAB ============
4450
+ #initGradientTab() {
4451
+ const container = this.#dialog.querySelector('[data-tab="gradient"]');
4452
+
4453
+ container.innerHTML = `
4454
+ <div class="fig-fill-picker-gradient-header">
4455
+ <fig-dropdown class="fig-fill-picker-gradient-type" value="${
4456
+ this.#gradient.type
4457
+ }">
4458
+ <option value="linear" selected>Linear</option>
4459
+ <option value="radial">Radial</option>
4460
+ <option value="angular">Angular</option>
4461
+ </fig-dropdown>
4462
+ <fig-tooltip text="Rotate gradient">
4463
+ <fig-input-angle class="fig-fill-picker-gradient-angle" value="${
4464
+ (this.#gradient.angle - 90 + 360) % 360
4465
+ }"></fig-input-angle>
4466
+ </fig-tooltip>
4467
+ <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
4468
+ <fig-input-number min="0" max="100" value="${
4469
+ this.#gradient.centerX
4470
+ }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
4471
+ <fig-input-number min="0" max="100" value="${
4472
+ this.#gradient.centerY
4473
+ }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
4474
+ </div>
4475
+ <fig-tooltip text="Flip gradient">
4476
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
4477
+ <span class="fig-mask-icon" style="--icon: var(--icon-swap)"></span>
4478
+ </fig-button>
4479
+ </fig-tooltip>
4480
+ </div>
4481
+ <div class="fig-fill-picker-gradient-preview">
4482
+ <div class="fig-fill-picker-gradient-bar"></div>
4483
+ <div class="fig-fill-picker-gradient-stops-handles"></div>
4484
+ </div>
4485
+ <div class="fig-fill-picker-gradient-stops">
4486
+ <div class="fig-fill-picker-gradient-stops-header">
4487
+ <span>Stops</span>
4488
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
4489
+ <span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>
4490
+ </fig-button>
4491
+ </div>
4492
+ <div class="fig-fill-picker-gradient-stops-list"></div>
4493
+ </div>
4494
+ `;
4495
+
4496
+ this.#updateGradientUI();
4497
+ this.#setupGradientEvents(container);
4498
+ }
4499
+
4500
+ #setupGradientEvents(container) {
4501
+ // Type dropdown
4502
+ const typeDropdown = container.querySelector(
4503
+ ".fig-fill-picker-gradient-type"
4504
+ );
4505
+ typeDropdown.addEventListener("change", (e) => {
4506
+ this.#gradient.type = e.target.value;
4507
+ this.#updateGradientUI();
4508
+ this.#emitInput();
4509
+ });
4510
+
4511
+ // Angle input
4512
+ // Convert from fig-input-angle coordinates (0° = right) to CSS coordinates (0° = up)
4513
+ const angleInput = container.querySelector(
4514
+ ".fig-fill-picker-gradient-angle"
4515
+ );
4516
+ angleInput.addEventListener("input", (e) => {
4517
+ const pickerAngle = parseFloat(e.target.value) || 0;
4518
+ this.#gradient.angle = (pickerAngle + 90) % 360;
4519
+ this.#updateGradientPreview();
4520
+ this.#emitInput();
4521
+ });
4522
+
4523
+ // Center X/Y inputs
4524
+ const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
4525
+ const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
4526
+ cxInput?.addEventListener("input", (e) => {
4527
+ this.#gradient.centerX = parseFloat(e.target.value) || 50;
4528
+ this.#updateGradientPreview();
4529
+ this.#emitInput();
4530
+ });
4531
+ cyInput?.addEventListener("input", (e) => {
4532
+ this.#gradient.centerY = parseFloat(e.target.value) || 50;
4533
+ this.#updateGradientPreview();
4534
+ this.#emitInput();
4535
+ });
4536
+
4537
+ // Flip button
4538
+ container
4539
+ .querySelector(".fig-fill-picker-gradient-flip")
4540
+ .addEventListener("click", () => {
4541
+ this.#gradient.stops.forEach((stop) => {
4542
+ stop.position = 100 - stop.position;
4543
+ });
4544
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
4545
+ this.#updateGradientUI();
4546
+ this.#emitInput();
4547
+ });
4548
+
4549
+ // Add stop button
4550
+ container
4551
+ .querySelector(".fig-fill-picker-gradient-add")
4552
+ .addEventListener("click", () => {
4553
+ const midPosition = 50;
4554
+ this.#gradient.stops.push({
4555
+ position: midPosition,
4556
+ color: "#888888",
4557
+ opacity: 100,
4558
+ });
4559
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
4560
+ this.#updateGradientUI();
4561
+ this.#emitInput();
4562
+ });
4563
+ }
4564
+
4565
+ #updateGradientUI() {
4566
+ if (!this.#dialog) return;
4567
+
4568
+ const container = this.#dialog.querySelector('[data-tab="gradient"]');
4569
+ if (!container) return;
4570
+
4571
+ // Show/hide angle vs center inputs
4572
+ const angleInput = container.querySelector(
4573
+ ".fig-fill-picker-gradient-angle"
4574
+ );
4575
+ const centerInputs = container.querySelector(
4576
+ ".fig-fill-picker-gradient-center"
4577
+ );
4578
+
4579
+ if (this.#gradient.type === "radial") {
4580
+ angleInput.style.display = "none";
4581
+ centerInputs.style.display = "flex";
4582
+ } else {
4583
+ angleInput.style.display = "block";
4584
+ centerInputs.style.display = "none";
4585
+ // Sync angle input value (convert CSS angle to picker angle)
4586
+ const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
4587
+ angleInput.setAttribute("value", pickerAngle);
4588
+ }
4589
+
4590
+ this.#updateGradientPreview();
4591
+ this.#updateGradientStopsList();
4592
+ }
4593
+
4594
+ #updateGradientPreview() {
4595
+ if (!this.#dialog) return;
4596
+
4597
+ const bar = this.#dialog.querySelector(".fig-fill-picker-gradient-bar");
4598
+ if (bar) {
4599
+ bar.style.background = this.#getGradientCSS();
4600
+ }
4601
+
4602
+ this.#updateChit();
4603
+ }
4604
+
4605
+ #updateGradientStopsList() {
4606
+ if (!this.#dialog) return;
4607
+
4608
+ const list = this.#dialog.querySelector(
4609
+ ".fig-fill-picker-gradient-stops-list"
4610
+ );
4611
+ if (!list) return;
4612
+
4613
+ list.innerHTML = this.#gradient.stops
4614
+ .map(
4615
+ (stop, index) => `
4616
+ <div class="fig-fill-picker-gradient-stop-row" data-index="${index}">
4617
+ <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
4618
+ stop.position
4619
+ }" units="%"></fig-input-number>
4620
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" value="${
4621
+ stop.color
4622
+ }"></fig-input-color>
4623
+ <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
4624
+ this.#gradient.stops.length <= 2 ? "disabled" : ""
4625
+ }>
4626
+ <span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>
4627
+ </fig-button>
4628
+ </div>
4629
+ `
4630
+ )
4631
+ .join("");
4632
+
4633
+ // Setup event listeners for each stop
4634
+ list
4635
+ .querySelectorAll(".fig-fill-picker-gradient-stop-row")
4636
+ .forEach((row) => {
4637
+ const index = parseInt(row.dataset.index);
4638
+
4639
+ row
4640
+ .querySelector(".fig-fill-picker-stop-position")
4641
+ .addEventListener("input", (e) => {
4642
+ this.#gradient.stops[index].position =
4643
+ parseFloat(e.target.value) || 0;
4644
+ this.#updateGradientPreview();
4645
+ this.#emitInput();
4646
+ });
4647
+
4648
+ row
4649
+ .querySelector(".fig-fill-picker-stop-color")
4650
+ .addEventListener("input", (e) => {
4651
+ this.#gradient.stops[index].color =
4652
+ e.target.hexOpaque || e.target.value;
4653
+ this.#gradient.stops[index].opacity =
4654
+ parseFloat(e.target.alpha) || 100;
4655
+ this.#updateGradientPreview();
4656
+ this.#emitInput();
4657
+ });
4658
+
4659
+ row
4660
+ .querySelector(".fig-fill-picker-stop-remove")
4661
+ .addEventListener("click", () => {
4662
+ if (this.#gradient.stops.length > 2) {
4663
+ this.#gradient.stops.splice(index, 1);
4664
+ this.#updateGradientUI();
4665
+ this.#emitInput();
4666
+ }
4667
+ });
4668
+ });
4669
+ }
4670
+
4671
+ #getGradientCSS() {
4672
+ const stops = this.#gradient.stops
4673
+ .map((s) => {
4674
+ const rgba = this.#hexToRGBA(s.color, s.opacity / 100);
4675
+ return `${rgba} ${s.position}%`;
4676
+ })
4677
+ .join(", ");
4678
+
4679
+ switch (this.#gradient.type) {
4680
+ case "linear":
4681
+ return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
4682
+ case "radial":
4683
+ return `radial-gradient(circle at ${this.#gradient.centerX}% ${
4684
+ this.#gradient.centerY
4685
+ }%, ${stops})`;
4686
+ case "angular":
4687
+ // Offset by 90° to align with fig-input-angle (0° = right) vs CSS conic (0° = top)
4688
+ return `conic-gradient(from ${this.#gradient.angle + 90}deg, ${stops})`;
4689
+ default:
4690
+ return `linear-gradient(${this.#gradient.angle}deg, ${stops})`;
4691
+ }
4692
+ }
4693
+
4694
+ // ============ IMAGE TAB ============
4695
+ #initImageTab() {
4696
+ const container = this.#dialog.querySelector('[data-tab="image"]');
4697
+
4698
+ container.innerHTML = `
4699
+ <div class="fig-fill-picker-media-header">
4700
+ <fig-dropdown class="fig-fill-picker-scale-mode" value="${
4701
+ this.#image.scaleMode
4702
+ }">
4703
+ <option value="fill" selected>Fill</option>
4704
+ <option value="fit">Fit</option>
4705
+ <option value="crop">Crop</option>
4706
+ <option value="tile">Tile</option>
4707
+ </fig-dropdown>
4708
+ <fig-input-number class="fig-fill-picker-scale" min="1" max="200" value="${
4709
+ this.#image.scale
4710
+ }" units="%" style="display: none;"></fig-input-number>
4711
+ </div>
4712
+ <div class="fig-fill-picker-media-preview">
4713
+ <div class="fig-fill-picker-checkerboard"></div>
4714
+ <div class="fig-fill-picker-image-preview"></div>
4715
+ <fig-button variant="overlay" class="fig-fill-picker-upload">
4716
+ Upload from computer
4717
+ <input type="file" accept="image/*" style="display: none;" />
4718
+ </fig-button>
4719
+ </div>
4720
+ `;
4721
+
4722
+ this.#setupImageEvents(container);
4723
+ }
4724
+
4725
+ #setupImageEvents(container) {
4726
+ const scaleModeDropdown = container.querySelector(
4727
+ ".fig-fill-picker-scale-mode"
4728
+ );
4729
+ const scaleInput = container.querySelector(".fig-fill-picker-scale");
4730
+ const uploadBtn = container.querySelector(".fig-fill-picker-upload");
4731
+ const fileInput = container.querySelector('input[type="file"]');
4732
+ const preview = container.querySelector(".fig-fill-picker-image-preview");
4733
+
4734
+ scaleModeDropdown.addEventListener("change", (e) => {
4735
+ this.#image.scaleMode = e.target.value;
4736
+ scaleInput.style.display = e.target.value === "tile" ? "block" : "none";
4737
+ this.#updateImagePreview(preview);
4738
+ this.#updateChit();
4739
+ this.#emitInput();
4740
+ });
4741
+
4742
+ scaleInput.addEventListener("input", (e) => {
4743
+ this.#image.scale = parseFloat(e.target.value) || 100;
4744
+ this.#updateImagePreview(preview);
4745
+ this.#updateChit();
4746
+ this.#emitInput();
4747
+ });
4748
+
4749
+ uploadBtn.addEventListener("click", () => {
4750
+ fileInput.click();
4751
+ });
4752
+
4753
+ fileInput.addEventListener("change", (e) => {
4754
+ const file = e.target.files[0];
4755
+ if (file) {
4756
+ const reader = new FileReader();
4757
+ reader.onload = (e) => {
4758
+ this.#image.url = e.target.result;
4759
+ this.#updateImagePreview(preview);
4760
+ this.#updateChit();
4761
+ this.#emitInput();
4762
+ };
4763
+ reader.readAsDataURL(file);
4764
+ }
4765
+ });
4766
+
4767
+ // Drag and drop
4768
+ const previewArea = container.querySelector(
4769
+ ".fig-fill-picker-media-preview"
4770
+ );
4771
+ previewArea.addEventListener("dragover", (e) => {
4772
+ e.preventDefault();
4773
+ previewArea.classList.add("dragover");
4774
+ });
4775
+ previewArea.addEventListener("dragleave", () => {
4776
+ previewArea.classList.remove("dragover");
4777
+ });
4778
+ previewArea.addEventListener("drop", (e) => {
4779
+ e.preventDefault();
4780
+ previewArea.classList.remove("dragover");
4781
+ const file = e.dataTransfer.files[0];
4782
+ if (file && file.type.startsWith("image/")) {
4783
+ const reader = new FileReader();
4784
+ reader.onload = (e) => {
4785
+ this.#image.url = e.target.result;
4786
+ this.#updateImagePreview(preview);
4787
+ this.#updateChit();
4788
+ this.#emitInput();
4789
+ };
4790
+ reader.readAsDataURL(file);
4791
+ }
4792
+ });
4793
+ }
4794
+
4795
+ #updateImagePreview(element) {
4796
+ const container = element.closest(".fig-fill-picker-media-preview");
4797
+ if (!this.#image.url) {
4798
+ element.style.display = "none";
4799
+ container?.classList.remove("has-media");
4800
+ return;
4801
+ }
4802
+
4803
+ element.style.display = "block";
4804
+ container?.classList.add("has-media");
4805
+ element.style.backgroundImage = `url(${this.#image.url})`;
4806
+ element.style.backgroundPosition = "center";
4807
+
4808
+ switch (this.#image.scaleMode) {
4809
+ case "fill":
4810
+ element.style.backgroundSize = "cover";
4811
+ element.style.backgroundRepeat = "no-repeat";
4812
+ break;
4813
+ case "fit":
4814
+ element.style.backgroundSize = "contain";
4815
+ element.style.backgroundRepeat = "no-repeat";
4816
+ break;
4817
+ case "crop":
4818
+ element.style.backgroundSize = "cover";
4819
+ element.style.backgroundRepeat = "no-repeat";
4820
+ break;
4821
+ case "tile":
4822
+ element.style.backgroundSize = `${this.#image.scale}%`;
4823
+ element.style.backgroundRepeat = "repeat";
4824
+ element.style.backgroundPosition = "top left";
4825
+ break;
4826
+ }
4827
+ }
4828
+
4829
+ // For video elements (still uses object-fit)
4830
+ #updateVideoPreviewStyle(element) {
4831
+ element.style.objectPosition = "center";
4832
+ element.style.width = "100%";
4833
+ element.style.height = "100%";
4834
+
4835
+ switch (this.#video.scaleMode) {
4836
+ case "fill":
4837
+ case "crop":
4838
+ element.style.objectFit = "cover";
4839
+ break;
4840
+ case "fit":
4841
+ element.style.objectFit = "contain";
4842
+ break;
4843
+ }
4844
+ }
4845
+
4846
+ // ============ VIDEO TAB ============
4847
+ #initVideoTab() {
4848
+ const container = this.#dialog.querySelector('[data-tab="video"]');
4849
+
4850
+ container.innerHTML = `
4851
+ <div class="fig-fill-picker-media-header">
4852
+ <fig-dropdown class="fig-fill-picker-scale-mode" value="${
4853
+ this.#video.scaleMode
4854
+ }">
4855
+ <option value="fill" selected>Fill</option>
4856
+ <option value="fit">Fit</option>
4857
+ <option value="crop">Crop</option>
4858
+ </fig-dropdown>
4859
+ </div>
4860
+ <div class="fig-fill-picker-media-preview">
4861
+ <div class="fig-fill-picker-checkerboard"></div>
4862
+ <video class="fig-fill-picker-video-preview" style="display: none;" muted loop></video>
4863
+ <fig-button variant="overlay" class="fig-fill-picker-upload">
4864
+ Upload from computer
4865
+ <input type="file" accept="video/*" style="display: none;" />
4866
+ </fig-button>
4867
+ </div>
4868
+ `;
4869
+
4870
+ this.#setupVideoEvents(container);
4871
+ }
4872
+
4873
+ #setupVideoEvents(container) {
4874
+ const scaleModeDropdown = container.querySelector(
4875
+ ".fig-fill-picker-scale-mode"
4876
+ );
4877
+ const uploadBtn = container.querySelector(".fig-fill-picker-upload");
4878
+ const fileInput = container.querySelector('input[type="file"]');
4879
+ const preview = container.querySelector(".fig-fill-picker-video-preview");
4880
+
4881
+ scaleModeDropdown.addEventListener("change", (e) => {
4882
+ this.#video.scaleMode = e.target.value;
4883
+ this.#updateVideoPreviewStyle(preview);
4884
+ this.#updateChit();
4885
+ this.#emitInput();
4886
+ });
4887
+
4888
+ uploadBtn.addEventListener("click", () => {
4889
+ fileInput.click();
4890
+ });
4891
+
4892
+ // Drag and drop
4893
+ const previewArea = container.querySelector(
4894
+ ".fig-fill-picker-media-preview"
4895
+ );
4896
+
4897
+ fileInput.addEventListener("change", (e) => {
4898
+ const file = e.target.files[0];
4899
+ if (file) {
4900
+ this.#video.url = URL.createObjectURL(file);
4901
+ preview.src = this.#video.url;
4902
+ preview.style.display = "block";
4903
+ preview.play();
4904
+ previewArea.classList.add("has-media");
4905
+ this.#updateVideoPreviewStyle(preview);
4906
+ this.#updateChit();
4907
+ this.#emitInput();
4908
+ }
4909
+ });
4910
+
4911
+ previewArea.addEventListener("dragover", (e) => {
4912
+ e.preventDefault();
4913
+ previewArea.classList.add("dragover");
4914
+ });
4915
+ previewArea.addEventListener("dragleave", () => {
4916
+ previewArea.classList.remove("dragover");
4917
+ });
4918
+ previewArea.addEventListener("drop", (e) => {
4919
+ e.preventDefault();
4920
+ previewArea.classList.remove("dragover");
4921
+ const file = e.dataTransfer.files[0];
4922
+ if (file && file.type.startsWith("video/")) {
4923
+ this.#video.url = URL.createObjectURL(file);
4924
+ preview.src = this.#video.url;
4925
+ preview.style.display = "block";
4926
+ preview.play();
4927
+ previewArea.classList.add("has-media");
4928
+ this.#updateVideoPreviewStyle(preview);
4929
+ this.#updateChit();
4930
+ this.#emitInput();
4931
+ }
4932
+ });
4933
+ }
4934
+
4935
+ // ============ WEBCAM TAB ============
4936
+ #initWebcamTab() {
4937
+ const container = this.#dialog.querySelector('[data-tab="webcam"]');
4938
+
4939
+ container.innerHTML = `
4940
+ <div class="fig-fill-picker-webcam-preview">
4941
+ <div class="fig-fill-picker-checkerboard"></div>
4942
+ <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
4943
+ <div class="fig-fill-picker-webcam-status">
4944
+ <span>Camera access required</span>
4945
+ </div>
4946
+ </div>
4947
+ <div class="fig-fill-picker-webcam-controls">
4948
+ <fig-dropdown class="fig-fill-picker-camera-select" style="display: none;">
4949
+ </fig-dropdown>
4950
+ <fig-button class="fig-fill-picker-webcam-capture" variant="primary">
4951
+ Capture
4952
+ </fig-button>
4953
+ </div>
4954
+ `;
4955
+
4956
+ this.#setupWebcamEvents(container);
4957
+ }
4958
+
4959
+ #setupWebcamEvents(container) {
4960
+ const video = container.querySelector(".fig-fill-picker-webcam-video");
4961
+ const status = container.querySelector(".fig-fill-picker-webcam-status");
4962
+ const captureBtn = container.querySelector(
4963
+ ".fig-fill-picker-webcam-capture"
4964
+ );
4965
+ const cameraSelect = container.querySelector(
4966
+ ".fig-fill-picker-camera-select"
4967
+ );
4968
+
4969
+ const startWebcam = async (deviceId = null) => {
4970
+ try {
4971
+ const constraints = {
4972
+ video: deviceId ? { deviceId: { exact: deviceId } } : true,
4973
+ };
4974
+
4975
+ if (this.#webcam.stream) {
4976
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
4977
+ }
4978
+
4979
+ this.#webcam.stream = await navigator.mediaDevices.getUserMedia(
4980
+ constraints
4981
+ );
4982
+ video.srcObject = this.#webcam.stream;
4983
+ video.style.display = "block";
4984
+ status.style.display = "none";
4985
+
4986
+ // Enumerate cameras
4987
+ const devices = await navigator.mediaDevices.enumerateDevices();
4988
+ const cameras = devices.filter((d) => d.kind === "videoinput");
4989
+
4990
+ if (cameras.length > 1) {
4991
+ cameraSelect.style.display = "block";
4992
+ cameraSelect.innerHTML = cameras
4993
+ .map(
4994
+ (cam, i) =>
4995
+ `<option value="${cam.deviceId}">${
4996
+ cam.label || `Camera ${i + 1}`
4997
+ }</option>`
4998
+ )
4999
+ .join("");
5000
+ }
5001
+ } catch (err) {
5002
+ console.error("Webcam error:", err.name, err.message);
5003
+ let message = "Camera access denied";
5004
+ if (err.name === "NotAllowedError") {
5005
+ message = "Camera permission denied";
5006
+ } else if (err.name === "NotFoundError") {
5007
+ message = "No camera found";
5008
+ } else if (err.name === "NotReadableError") {
5009
+ message = "Camera in use by another app";
5010
+ } else if (err.name === "OverconstrainedError") {
5011
+ message = "Camera constraints not supported";
5012
+ } else if (!window.isSecureContext) {
5013
+ message = "Camera requires secure context";
5014
+ }
5015
+ status.innerHTML = `<span>${message}</span>`;
5016
+ status.style.display = "flex";
5017
+ video.style.display = "none";
5018
+ }
5019
+ };
5020
+
5021
+ // Start webcam when tab is shown
5022
+ const observer = new MutationObserver(() => {
5023
+ if (container.style.display !== "none" && !this.#webcam.stream) {
5024
+ startWebcam();
5025
+ }
5026
+ });
5027
+ observer.observe(container, {
5028
+ attributes: true,
5029
+ attributeFilter: ["style"],
5030
+ });
5031
+
5032
+ cameraSelect.addEventListener("change", (e) => {
5033
+ startWebcam(e.target.value);
5034
+ });
5035
+
5036
+ captureBtn.addEventListener("click", () => {
5037
+ if (!this.#webcam.stream) return;
5038
+
5039
+ const canvas = document.createElement("canvas");
5040
+ canvas.width = video.videoWidth;
5041
+ canvas.height = video.videoHeight;
5042
+ canvas.getContext("2d").drawImage(video, 0, 0);
5043
+
5044
+ this.#webcam.snapshot = canvas.toDataURL("image/png");
5045
+ this.#image.url = this.#webcam.snapshot;
5046
+ this.#fillType = "image";
5047
+ this.#updateChit();
5048
+ this.#emitInput();
5049
+
5050
+ // Switch to image tab to show result
5051
+ this.#switchTab("image");
5052
+ const tabs = this.#dialog.querySelector("fig-tabs");
5053
+ tabs.value = "image";
5054
+ });
5055
+ }
5056
+
5057
+ // ============ COLOR CONVERSION UTILITIES ============
5058
+ #hsvToRGB(hsv) {
5059
+ const h = hsv.h / 360;
5060
+ const s = hsv.s / 100;
5061
+ const v = hsv.v / 100;
5062
+
5063
+ let r, g, b;
5064
+ const i = Math.floor(h * 6);
5065
+ const f = h * 6 - i;
5066
+ const p = v * (1 - s);
5067
+ const q = v * (1 - f * s);
5068
+ const t = v * (1 - (1 - f) * s);
5069
+
5070
+ switch (i % 6) {
5071
+ case 0:
5072
+ r = v;
5073
+ g = t;
5074
+ b = p;
5075
+ break;
5076
+ case 1:
5077
+ r = q;
5078
+ g = v;
5079
+ b = p;
5080
+ break;
5081
+ case 2:
5082
+ r = p;
5083
+ g = v;
5084
+ b = t;
5085
+ break;
5086
+ case 3:
5087
+ r = p;
5088
+ g = q;
5089
+ b = v;
5090
+ break;
5091
+ case 4:
5092
+ r = t;
5093
+ g = p;
5094
+ b = v;
5095
+ break;
5096
+ case 5:
5097
+ r = v;
5098
+ g = p;
5099
+ b = q;
5100
+ break;
5101
+ }
5102
+
5103
+ return {
5104
+ r: Math.round(r * 255),
5105
+ g: Math.round(g * 255),
5106
+ b: Math.round(b * 255),
5107
+ };
5108
+ }
5109
+
5110
+ #rgbToHSV(rgb) {
5111
+ const r = rgb.r / 255;
5112
+ const g = rgb.g / 255;
5113
+ const b = rgb.b / 255;
5114
+
5115
+ const max = Math.max(r, g, b);
5116
+ const min = Math.min(r, g, b);
5117
+ const d = max - min;
5118
+
5119
+ let h = 0;
5120
+ const s = max === 0 ? 0 : d / max;
5121
+ const v = max;
5122
+
5123
+ if (max !== min) {
5124
+ switch (max) {
5125
+ case r:
5126
+ h = (g - b) / d + (g < b ? 6 : 0);
5127
+ break;
5128
+ case g:
5129
+ h = (b - r) / d + 2;
5130
+ break;
5131
+ case b:
5132
+ h = (r - g) / d + 4;
5133
+ break;
5134
+ }
5135
+ h /= 6;
5136
+ }
5137
+
5138
+ return {
5139
+ h: h * 360,
5140
+ s: s * 100,
5141
+ v: v * 100,
5142
+ a: 1,
5143
+ };
5144
+ }
5145
+
5146
+ #hsvToHex(hsv) {
5147
+ // Safety check for valid HSV object
5148
+ if (
5149
+ !hsv ||
5150
+ typeof hsv.h !== "number" ||
5151
+ typeof hsv.s !== "number" ||
5152
+ typeof hsv.v !== "number"
5153
+ ) {
5154
+ return "#D9D9D9"; // Default gray
5155
+ }
5156
+ const rgb = this.#hsvToRGB(hsv);
5157
+ const toHex = (n) => {
5158
+ const val = isNaN(n) ? 217 : Math.max(0, Math.min(255, Math.round(n)));
5159
+ return val.toString(16).padStart(2, "0");
5160
+ };
5161
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
5162
+ }
5163
+
5164
+ #hexToHSV(hex) {
5165
+ const r = parseInt(hex.slice(1, 3), 16);
5166
+ const g = parseInt(hex.slice(3, 5), 16);
5167
+ const b = parseInt(hex.slice(5, 7), 16);
5168
+ return this.#rgbToHSV({ r, g, b });
5169
+ }
5170
+
5171
+ #hexToRGBA(hex, alpha = 1) {
5172
+ const r = parseInt(hex.slice(1, 3), 16);
5173
+ const g = parseInt(hex.slice(3, 5), 16);
5174
+ const b = parseInt(hex.slice(5, 7), 16);
5175
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
5176
+ }
5177
+
5178
+ #rgbToHSL(rgb) {
5179
+ const r = rgb.r / 255;
5180
+ const g = rgb.g / 255;
5181
+ const b = rgb.b / 255;
5182
+
5183
+ const max = Math.max(r, g, b);
5184
+ const min = Math.min(r, g, b);
5185
+ let h, s;
5186
+ const l = (max + min) / 2;
5187
+
5188
+ if (max === min) {
5189
+ h = s = 0;
5190
+ } else {
5191
+ const d = max - min;
5192
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
5193
+
5194
+ switch (max) {
5195
+ case r:
5196
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
5197
+ break;
5198
+ case g:
5199
+ h = ((b - r) / d + 2) / 6;
5200
+ break;
5201
+ case b:
5202
+ h = ((r - g) / d + 4) / 6;
5203
+ break;
5204
+ }
5205
+ }
5206
+
5207
+ return { h: h * 360, s: s * 100, l: l * 100 };
5208
+ }
5209
+
5210
+ #hslToRGB(hsl) {
5211
+ const h = hsl.h / 360;
5212
+ const s = hsl.s / 100;
5213
+ const l = hsl.l / 100;
5214
+
5215
+ let r, g, b;
5216
+
5217
+ if (s === 0) {
5218
+ r = g = b = l;
5219
+ } else {
5220
+ const hue2rgb = (p, q, t) => {
5221
+ if (t < 0) t += 1;
5222
+ if (t > 1) t -= 1;
5223
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
5224
+ if (t < 1 / 2) return q;
5225
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
5226
+ return p;
5227
+ };
5228
+
5229
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
5230
+ const p = 2 * l - q;
5231
+
5232
+ r = hue2rgb(p, q, h + 1 / 3);
5233
+ g = hue2rgb(p, q, h);
5234
+ b = hue2rgb(p, q, h - 1 / 3);
5235
+ }
5236
+
5237
+ return {
5238
+ r: Math.round(r * 255),
5239
+ g: Math.round(g * 255),
5240
+ b: Math.round(b * 255),
5241
+ };
5242
+ }
5243
+
5244
+ // OKLAB/OKLCH conversions (simplified)
5245
+ #rgbToOKLAB(rgb) {
5246
+ // Convert to linear sRGB
5247
+ const toLinear = (c) => {
5248
+ c = c / 255;
5249
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
5250
+ };
5251
+
5252
+ const r = toLinear(rgb.r);
5253
+ const g = toLinear(rgb.g);
5254
+ const b = toLinear(rgb.b);
5255
+
5256
+ // Convert to LMS
5257
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
5258
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
5259
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
5260
+
5261
+ // Convert to Oklab
5262
+ const l_ = Math.cbrt(l);
5263
+ const m_ = Math.cbrt(m);
5264
+ const s_ = Math.cbrt(s);
5265
+
5266
+ return {
5267
+ l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
5268
+ a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
5269
+ b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
5270
+ };
5271
+ }
5272
+
5273
+ #rgbToOKLCH(rgb) {
5274
+ const lab = this.#rgbToOKLAB(rgb);
5275
+ return {
5276
+ l: lab.l,
5277
+ c: Math.sqrt(lab.a * lab.a + lab.b * lab.b),
5278
+ h: ((Math.atan2(lab.b, lab.a) * 180) / Math.PI + 360) % 360,
5279
+ };
5280
+ }
5281
+
5282
+ // ============ EVENT EMITTERS ============
5283
+ #emitInput() {
5284
+ this.#updateChit();
5285
+ this.dispatchEvent(
5286
+ new CustomEvent("input", {
5287
+ bubbles: true,
5288
+ detail: this.value,
5289
+ })
5290
+ );
5291
+ }
5292
+
5293
+ #emitChange() {
5294
+ this.dispatchEvent(
5295
+ new CustomEvent("change", {
5296
+ bubbles: true,
5297
+ detail: this.value,
5298
+ })
5299
+ );
5300
+ }
5301
+
5302
+ // ============ PUBLIC API ============
5303
+ get value() {
5304
+ const base = { type: this.#fillType };
5305
+
5306
+ switch (this.#fillType) {
5307
+ case "solid":
5308
+ return {
5309
+ ...base,
5310
+ color: this.#hsvToHex(this.#color),
5311
+ alpha: this.#color.a,
5312
+ hsv: { ...this.#color },
5313
+ };
5314
+ case "gradient":
5315
+ return {
5316
+ ...base,
5317
+ gradient: { ...this.#gradient },
5318
+ css: this.#getGradientCSS(),
5319
+ };
5320
+ case "image":
5321
+ return {
5322
+ ...base,
5323
+ image: { ...this.#image },
5324
+ };
5325
+ case "video":
5326
+ return {
5327
+ ...base,
5328
+ video: { ...this.#video },
5329
+ };
5330
+ case "webcam":
5331
+ return {
5332
+ ...base,
5333
+ image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
5334
+ };
5335
+ default:
5336
+ return base;
5337
+ }
5338
+ }
5339
+
5340
+ set value(val) {
5341
+ if (typeof val === "string") {
5342
+ this.setAttribute("value", val);
5343
+ } else {
5344
+ this.setAttribute("value", JSON.stringify(val));
5345
+ }
5346
+ }
5347
+
5348
+ attributeChangedCallback(name, oldValue, newValue) {
5349
+ if (oldValue === newValue) return;
5350
+
5351
+ switch (name) {
5352
+ case "value":
5353
+ this.#parseValue();
5354
+ this.#updateChit();
5355
+ if (this.#dialog) {
5356
+ // Update dialog UI if open
5357
+ this.#initSolidTab();
5358
+ this.#initGradientTab();
5359
+ }
5360
+ break;
5361
+ case "disabled":
5362
+ // Handled in click listener
5363
+ break;
5364
+ }
5365
+ }
5366
+ }
5367
+ customElements.define("fig-fill-picker", FigFillPicker);