@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/components.css +382 -56
- package/example.html +337 -76
- package/fig.js +1763 -68
- package/package.json +1 -1
- package/end-point-line.svg +0 -3
- package/end-points.zip +0 -0
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
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
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="
|
|
2077
|
-
value="${this.
|
|
2111
|
+
placeholder="000000"
|
|
2112
|
+
value="${this.hexOpaque.slice(1).toUpperCase()}">
|
|
2078
2113
|
</fig-input-text>`;
|
|
2079
|
-
if (
|
|
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
|
-
|
|
2134
|
+
${swatchElement}
|
|
2092
2135
|
${label}
|
|
2093
2136
|
</div>`;
|
|
2094
2137
|
} else {
|
|
2095
|
-
|
|
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
|
|
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
|
-
|
|
2105
|
-
this.#swatch
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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
|
|
2682
|
-
* @attr {string}
|
|
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
|
-
#
|
|
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
|
|
2695
|
-
|
|
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
|
-
|
|
2703
|
-
|
|
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
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
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
|
-
|
|
2716
|
-
|
|
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
|
-
|
|
2719
|
-
|
|
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
|
-
|
|
2722
|
-
|
|
2723
|
-
this.
|
|
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
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
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
|
|
2762
|
-
this.src ? `
|
|
2763
|
-
} disabled
|
|
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(
|
|
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);
|