UNPKG

@rogieking/figui3

Version:

A lightweight web components library for building Figma plugin and widget UIs with native look and feel

1,509 lines (1,354 loc) 72 kB
// FigFillPicker const GRADIENT_INTERPOLATION_SPACES = [ "srgb", "srgb-linear", "display-p3", "oklab", "oklch", ]; const GRADIENT_HUE_INTERPOLATIONS = [ "shorter", "longer", "increasing", "decreasing", ]; function normalizeGradientConfig(gradient) { const next = { ...(gradient ?? {}) }; let interpolationSpace = String( next.interpolationSpace ?? "oklab", ).toLowerCase(); if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) { interpolationSpace = "oklab"; } if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") { interpolationSpace = "oklab"; } next.interpolationSpace = interpolationSpace; const hueInterpolation = String( next.hueInterpolation ?? "shorter", ).toLowerCase(); next.hueInterpolation = GRADIENT_HUE_INTERPOLATIONS.includes(hueInterpolation) ? hueInterpolation : "shorter"; return next; } function gradientToValueShape(gradient) { const normalized = normalizeGradientConfig(gradient); const output = { ...normalized, interpolationSpace: normalized.interpolationSpace, }; if (normalized.interpolationSpace === "oklch") { output.hueInterpolation = normalized.hueInterpolation; } else { delete output.hueInterpolation; } return output; } function gradientInterpolationClause(gradient) { const normalized = normalizeGradientConfig(gradient); if (normalized.interpolationSpace === "oklch") { return `in oklch ${normalized.hueInterpolation} hue`; } return `in ${normalized.interpolationSpace}`; } /** * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam. * Uses display: contents and wraps a trigger element that opens a dialog picker. * * @attr {string} value - JSON-encoded fill value * @attr {boolean} disabled - Whether the picker is disabled * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true) * @attr {string} dialog-position - Position of the popup (default: "left") */ class FigFillPicker extends HTMLElement { #trigger = null; #chit = null; #dialog = null; #activeTab = "solid"; anchorElement = null; // Fill state #fillType = "solid"; #gamut = "srgb"; // "srgb" or "display-p3" #color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9 #colorInputMode = "hex"; #gradient = { type: "linear", angle: 0, centerX: 50, centerY: 50, interpolationSpace: "oklab", hueInterpolation: "shorter", stops: [ { position: 0, color: "#D9D9D9", opacity: 100 }, { position: 100, color: "#737373", opacity: 100 }, ], }; #image = { url: null, scaleMode: "fill", scale: 50 }; #video = { url: null, scaleMode: "fill", scale: 50 }; #webcam = { stream: null, snapshot: null }; // Custom mode slots and data #customSlots = {}; #customData = {}; // DOM references for solid tab #colorArea = null; #colorAreaHandle = null; #hueSlider = null; #opacitySlider = null; #isDraggingColor = false; #teardownColorAreaEvents = null; #dialogOpenObserver = null; #webcamTabObserver = null; constructor() { super(); } static get observedAttributes() { return ["value", "disabled", "alpha", "mode", "experimental"]; } connectedCallback() { // Use display: contents this.style.display = "contents"; requestAnimationFrame(() => { this.#setupTrigger(); this.#parseValue(); this.#updateChit(); }); } disconnectedCallback() { if (this.#teardownColorAreaEvents) { this.#teardownColorAreaEvents(); this.#teardownColorAreaEvents = null; } if (this.#dialogOpenObserver) { this.#dialogOpenObserver.disconnect(); this.#dialogOpenObserver = null; } if (this.#webcamTabObserver) { this.#webcamTabObserver.disconnect(); this.#webcamTabObserver = null; } if (this.#webcam.stream) { this.#webcam.stream.getTracks().forEach((track) => track.stop()); this.#webcam.stream = null; } if (this.#webcam.snapshot?.startsWith("blob:")) { URL.revokeObjectURL(this.#webcam.snapshot); this.#webcam.snapshot = null; } if (this.#video.url && this.#video.url.startsWith("blob:")) { URL.revokeObjectURL(this.#video.url); } if (this.#chit) this.#chit.removeAttribute("selected"); if (this.#dialog) { this.#dialog.close(); this.#dialog.remove(); this.#dialog = null; } } #setupTrigger() { const child = Array.from(this.children).find( (el) => !el.getAttribute("slot")?.startsWith("mode-"), ); if (!child) { // Scenario 1: Empty - create fig-chit this.#chit = document.createElement("fig-chit"); this.#chit.setAttribute("background", "#D9D9D9"); this.appendChild(this.#chit); this.#trigger = this.#chit; } else if (child.tagName === "FIG-CHIT") { // Scenario 2: Has fig-chit - use and populate it this.#chit = child; this.#trigger = child; } else { // Scenario 3: Other element - trigger only, no populate this.#trigger = child; this.#chit = null; } this.#trigger.addEventListener("click", (e) => { if (this.hasAttribute("disabled")) return; e.stopPropagation(); e.preventDefault(); this.#openDialog(); }); // Prevent fig-chit's internal color input from opening system picker if (this.#chit) { requestAnimationFrame(() => { const input = this.#chit.querySelector('input[type="color"]'); if (input) { input.style.pointerEvents = "none"; } }); } } #parseValue() { const valueAttr = this.getAttribute("value"); if (!valueAttr) return; const builtinTypes = ["solid", "gradient", "image", "video", "webcam"]; try { const parsed = JSON.parse(valueAttr); if (parsed.type) this.#fillType = parsed.type; if (parsed.color) { // Handle both hex string and HSV object if (typeof parsed.color === "string") { this.#color = this.#hexToHSV(parsed.color); } else if ( typeof parsed.color === "object" && parsed.color.h !== undefined ) { this.#color = parsed.color; } } // Parse opacity (0-100) and convert to alpha (0-1) if (parsed.opacity !== undefined) { this.#color.a = parsed.opacity / 100; } if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") { this.#gamut = parsed.colorSpace; } if (parsed.gradient) { this.#gradient = normalizeGradientConfig({ ...this.#gradient, ...parsed.gradient, }); } if (parsed.image) this.#image = { ...this.#image, ...parsed.image }; if (parsed.video) this.#video = { ...this.#video, ...parsed.video }; // Store full parsed data for custom (non-built-in) types if (parsed.type && !builtinTypes.includes(parsed.type)) { const { type, ...rest } = parsed; this.#customData[parsed.type] = rest; } } catch (e) { // If not JSON, treat as hex color if (valueAttr.startsWith("#")) { this.#fillType = "solid"; this.#color = this.#hexToHSV(valueAttr); } } } #updateChit() { if (!this.#chit) return; let bg; let bgSize = "cover"; let bgPosition = "center"; switch (this.#fillType) { case "solid": bg = this.#hsvToHex(this.#color); break; case "gradient": bg = this.#getGradientCSS(); break; case "image": if (this.#image.url) { bg = `url(${this.#image.url})`; const sizing = this.#getBackgroundSizing( this.#image.scaleMode, this.#image.scale, ); bgSize = sizing.size; bgPosition = sizing.position; } else { bg = ""; } break; case "video": if (this.#video.url) { bg = `url(${this.#video.url})`; const sizing = this.#getBackgroundSizing( this.#video.scaleMode, this.#video.scale, ); bgSize = sizing.size; bgPosition = sizing.position; } else { bg = ""; } break; default: const slot = this.#customSlots[this.#fillType]; bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9"; } this.#chit.setAttribute("background", bg); this.#chit.style.setProperty("--chit-bg-size", bgSize); this.#chit.style.setProperty("--chit-bg-position", bgPosition); if (this.#fillType === "solid") { this.#chit.setAttribute("alpha", this.#color.a); } else { this.#chit.removeAttribute("alpha"); } } #getBackgroundSizing(scaleMode, scale) { switch (scaleMode) { case "fill": return { size: "cover", position: "center" }; case "fit": return { size: "contain", position: "center" }; case "crop": return { size: "cover", position: "center" }; case "tile": return { size: `${scale}%`, position: "top left" }; default: return { size: "cover", position: "center" }; } } #openDialog() { if (!this.#dialog) { this.#createDialog(); } this.#switchTab(this.#fillType); const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut"); if (gamutEl) gamutEl.value = this.#gamut; if (this.#chit) this.#chit.setAttribute("selected", "true"); this.#dialog.open = true; requestAnimationFrame(() => { requestAnimationFrame(() => { this.#drawColorArea(); this.#updateHandlePosition(); }); }); } open() { this.#openDialog(); } close() { if (this.#dialog) this.#dialog.open = false; } #createDialog() { // Collect slotted custom mode content before any DOM changes this.#customSlots = {}; this.querySelectorAll('[slot^="mode-"]').forEach((el) => { const modeName = el.getAttribute("slot").slice(5); this.#customSlots[modeName] = { element: el, label: el.getAttribute("label") || modeName.charAt(0).toUpperCase() + modeName.slice(1), }; }); this.#dialog = document.createElement("dialog", { is: "fig-popup" }); this.#dialog.setAttribute("is", "fig-popup"); this.#dialog.setAttribute("drag", "true"); this.#dialog.setAttribute("handle", "fig-header"); this.#dialog.setAttribute("autoresize", "false"); this.#dialog.classList.add("fig-fill-picker-dialog"); this.#dialog.anchor = this.anchorElement || this.#trigger; const dialogPosition = this.getAttribute("dialog-position") || "left"; this.#dialog.setAttribute("position", dialogPosition); this.#dialog.setAttribute("offset", this.getAttribute("dialog-offset") || "8 8"); const builtinModes = ["solid", "gradient", "image", "video", "webcam"]; const builtinLabels = { solid: "Solid", gradient: "Gradient", image: "Image", video: "Video", webcam: "Webcam", }; // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists const mode = this.getAttribute("mode"); let allowedModes; if (mode) { const requested = mode.split(",").map((m) => m.trim().toLowerCase()); allowedModes = requested.filter( (m) => builtinModes.includes(m) || this.#customSlots[m], ); if (allowedModes.length === 0) allowedModes = [...builtinModes]; } else { allowedModes = [...builtinModes]; } // Build labels map: built-in labels + custom slot labels const modeLabels = { ...builtinLabels }; for (const [name, { label }] of Object.entries(this.#customSlots)) { modeLabels[name] = label; } if (!allowedModes.includes(this.#fillType)) { this.#fillType = allowedModes[0]; this.#activeTab = allowedModes[0]; } const experimental = this.getAttribute("experimental"); const expAttr = experimental ? `experimental="${experimental}"` : ""; let headerContent; if (allowedModes.length === 1) { headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`; } else { const options = allowedModes .map((m) => `<option value="${m}">${modeLabels[m]}</option>`) .join("\n "); headerContent = `<fig-dropdown class="fig-fill-picker-type" ${expAttr} value="${this.#fillType}"> ${options} </fig-dropdown>`; } // Generate tab containers for all allowed modes const tabDivs = allowedModes .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`) .join("\n "); const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}"> <option value="srgb">sRGB</option> <option value="display-p3">Display P3</option> </fig-dropdown>`; this.#dialog.innerHTML = ` <fig-header> ${headerContent} ${gamutDropdown} <fig-button icon variant="ghost" class="fig-fill-picker-close"> <fig-icon name="close"></fig-icon> </fig-button> </fig-header> <fig-content> ${tabDivs} </fig-content> `; document.body.appendChild(this.#dialog); // Populate custom tab containers and emit modeready for (const [modeName, { element }] of Object.entries(this.#customSlots)) { const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`); if (!container) continue; // Move children (not the element itself) for vanilla HTML usage while (element.firstChild) { container.appendChild(element.firstChild); } // Emit modeready so frameworks can render into the container this.dispatchEvent( new CustomEvent("modeready", { bubbles: true, detail: { mode: modeName, container }, }), ); } // Setup type dropdown switching (only if not locked) const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type"); if (typeDropdown) { typeDropdown.addEventListener("change", (e) => { this.#switchTab(e.target.value); }); } // Setup gamut dropdown const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut"); if (gamutEl) { const handleGamutChange = (e) => { const val = e.currentTarget?.value ?? e.target?.value ?? e.detail; if (val && val !== this.#gamut) { this.#gamut = val; this.#onGamutChange(); } }; gamutEl.addEventListener("input", handleGamutChange); gamutEl.addEventListener("change", handleGamutChange); } this.#dialog .querySelector(".fig-fill-picker-close") .addEventListener("click", () => { this.#dialog.open = false; }); const onDialogClose = () => { if (this.#chit) this.#chit.removeAttribute("selected"); this.#emitChange(); this.dispatchEvent(new CustomEvent("close")); }; this.#dialog.addEventListener("close", onDialogClose); this.#dialogOpenObserver = new MutationObserver(() => { const isOpen = this.#dialog.hasAttribute("open") && this.#dialog.getAttribute("open") !== "false"; if (!isOpen) onDialogClose(); }); this.#dialogOpenObserver.observe(this.#dialog, { attributes: true, attributeFilter: ["open"], }); // Initialize built-in tabs (skip any overridden by custom slots) const builtinInits = { solid: () => this.#initSolidTab(), gradient: () => this.#initGradientTab(), image: () => this.#initImageTab(), video: () => this.#initVideoTab(), webcam: () => this.#initWebcamTab(), }; for (const [name, init] of Object.entries(builtinInits)) { if (!this.#customSlots[name] && allowedModes.includes(name)) init(); } // Listen for input/change from custom tab content for (const modeName of Object.keys(this.#customSlots)) { if (builtinModes.includes(modeName)) continue; const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`); if (!container) continue; container.addEventListener("input", (e) => { if (e.target === this) return; e.stopPropagation(); if (e.detail) this.#customData[modeName] = e.detail; this.#emitInput(); }); container.addEventListener("change", (e) => { if (e.target === this) return; e.stopPropagation(); if (e.detail) this.#customData[modeName] = e.detail; this.#emitChange(); }); } } #switchTab(tabName) { // Only allow switching to modes that have a tab container in the dialog const tab = this.#dialog?.querySelector( `.fig-fill-picker-tab[data-tab="${tabName}"]`, ); if (!tab) return; this.#activeTab = tabName; this.#fillType = tabName; // Update dropdown selection (only exists if not locked) const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type"); if (typeDropdown && typeDropdown.value !== tabName) { typeDropdown.value = tabName; } // Show/hide tab content const tabContents = this.#dialog.querySelectorAll(".fig-fill-picker-tab"); tabContents.forEach((content) => { if (content.dataset.tab === tabName) { content.style.display = "block"; } else { content.style.display = "none"; } }); // Zero out content padding for custom mode tabs const contentEl = this.#dialog.querySelector("fig-content"); if (contentEl) { contentEl.style.padding = this.#customSlots[tabName] ? "0" : ""; } // Update tab-specific UI after visibility change if (tabName === "gradient") { // Use RAF to ensure layout is complete before updating angle input requestAnimationFrame(() => { this.#updateGradientUI(); const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input"); barInput?.refreshLayout?.(); requestAnimationFrame(() => { barInput?.refreshLayout?.(); }); }); } this.#updateChit(); this.#emitInput(); } // ============ SOLID TAB ============ #initSolidTab() { const container = this.#dialog.querySelector('[data-tab="solid"]'); const showAlpha = this.getAttribute("alpha") !== "false"; const experimental = this.getAttribute("experimental"); const expAttr = experimental ? `experimental="${experimental}"` : ""; container.innerHTML = ` <fig-preview class="fig-fill-picker-color-area"> <canvas width="200" height="200"></canvas> <fig-handle type="color" color="${this.#hsvToHex({ ...this.#color, a: 1 })}" data-no-color-picker drag drag-surface=".fig-fill-picker-color-area" drag-axes="x,y" drag-snapping="modifier" ></fig-handle> </fig-preview> <div class="fig-fill-picker-sliders"> <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip> <fig-slider type="hue" text="false" min="0" max="360" value="${ this.#color.h }"></fig-slider> ${ showAlpha ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" value="${ this.#color.a * 100 }" color="${this.#hsvToHex(this.#color)}"></fig-slider>` : "" } </div> <fig-field class="fig-fill-picker-inputs"> <fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}"> <option value="hex">Hex</option> <option value="rgb">RGB</option> <option value="hsl">HSL</option> <option value="hsb">HSB</option> <option value="lab">LAB</option> <option value="lch">LCH</option> </fig-dropdown> <span class="fig-fill-picker-input-fields"></span> </fig-field> `; // Setup color area this.#colorArea = container.querySelector("canvas"); this.#colorAreaHandle = container.querySelector("fig-handle"); this.#drawColorArea(); this.#updateHandlePosition(); this.#setupColorAreaEvents(); // Setup hue slider this.#hueSlider = container.querySelector('fig-slider[type="hue"]'); this.#hueSlider.addEventListener("input", (e) => { this.#color.h = parseFloat(e.target.value); this.#drawColorArea(); this.#updateHandlePosition(); this.#updateColorInputs(); this.#emitInput(); }); this.#hueSlider.addEventListener("change", () => { this.#emitChange(); }); // Setup opacity slider if (showAlpha) { this.#opacitySlider = container.querySelector( 'fig-slider[type="opacity"]', ); this.#opacitySlider.addEventListener("input", (e) => { this.#color.a = parseFloat(e.target.value) / 100; this.#updateColorInputs(); this.#emitInput(); }); this.#opacitySlider.addEventListener("change", () => { this.#emitChange(); }); } // Setup color input mode dropdown const modeDropdown = container.querySelector(".fig-fill-picker-input-mode"); modeDropdown.addEventListener("input", (e) => { this.#colorInputMode = e.target.value; this.#rebuildColorInputFields(); }); // Build initial color input fields this.#rebuildColorInputFields(); // Setup eyedropper const eyedropper = container.querySelector(".fig-fill-picker-eyedropper"); if ("EyeDropper" in window) { eyedropper.addEventListener("click", async () => { try { const dropper = new EyeDropper(); const result = await dropper.open(); this.#color = { ...this.#hexToHSV(result.sRGBHex), a: this.#color.a }; this.#drawColorArea(); this.#updateHandlePosition(); this.#updateColorInputs(); this.#emitInput(); } catch (e) { // User cancelled or error } }); } else { eyedropper.setAttribute("disabled", ""); eyedropper.title = "EyeDropper not supported in this browser"; } } #onGamutChange() { // Recreate the solid canvas with the new color space const solidContainer = this.#dialog?.querySelector('[data-tab="solid"]'); if (solidContainer) { const oldCanvas = solidContainer.querySelector("canvas"); if (oldCanvas) { const newCanvas = document.createElement("canvas"); newCanvas.width = oldCanvas.width; newCanvas.height = oldCanvas.height; oldCanvas.replaceWith(newCanvas); this.#colorArea = newCanvas; this.#setupColorAreaEvents(); } this.#drawColorArea(); this.#updateHandlePosition(); } // Refresh gradient preview if gradient tab exists this.#updateGradientPreview(); this.#emitInput(); } #drawColorArea() { // Refresh canvas reference in case DOM changed if (!this.#colorArea && this.#dialog) { this.#colorArea = this.#dialog.querySelector('[data-tab="solid"] canvas'); } if (!this.#colorArea) return; const colorSpace = this.#gamut === "display-p3" ? "display-p3" : "srgb"; const ctx = this.#colorArea.getContext("2d", { colorSpace }); if (!ctx) return; const width = this.#colorArea.width; const height = this.#colorArea.height; ctx.clearRect(0, 0, width, height); const hue = this.#color.h; const isP3 = this.#gamut === "display-p3"; const gradH = ctx.createLinearGradient(0, 0, width, 0); if (isP3) { gradH.addColorStop(0, "color(display-p3 1 1 1)"); const [r, g, b] = hslToP3(hue, 100, 50); gradH.addColorStop(1, `color(display-p3 ${r} ${g} ${b})`); } else { gradH.addColorStop(0, "#FFFFFF"); gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`); } ctx.fillStyle = gradH; ctx.fillRect(0, 0, width, height); const gradV = ctx.createLinearGradient(0, 0, 0, height); gradV.addColorStop(0, "rgba(0,0,0,0)"); gradV.addColorStop(1, "rgba(0,0,0,1)"); ctx.fillStyle = gradV; ctx.fillRect(0, 0, width, height); } #updateHandlePosition(retryCount = 0) { if (!this.#colorAreaHandle || !this.#colorArea) return; const rect = this.#colorArea.getBoundingClientRect(); // If the canvas isn't visible yet (0 dimensions), schedule a retry (max 5 attempts) if ((rect.width === 0 || rect.height === 0) && retryCount < 5) { requestAnimationFrame(() => this.#updateHandlePosition(retryCount + 1)); return; } const xPct = Math.max(0, Math.min(100, this.#color.s)); const yPct = Math.max(0, Math.min(100, 100 - this.#color.v)); this.#colorAreaHandle.setAttribute("value", `${xPct}% ${yPct}%`); this.#colorAreaHandle.setAttribute( "color", this.#hsvToHex({ ...this.#color, a: 1 }), ); } #updateColorFromAreaPosition(x, y, opts = {}) { const { updateHandle = true, emitInput = true, emitChange = false } = opts; this.#color.s = Math.max(0, Math.min(100, x * 100)); this.#color.v = Math.max(0, Math.min(100, (1 - y) * 100)); if (this.#colorAreaHandle) { this.#colorAreaHandle.setAttribute( "color", this.#hsvToHex({ ...this.#color, a: 1 }), ); } if (updateHandle) this.#updateHandlePosition(); this.#updateColorInputs(); if (emitInput) this.#emitInput(); if (emitChange) this.#emitChange(); } #setupColorAreaEvents() { if (this.#teardownColorAreaEvents) { this.#teardownColorAreaEvents(); this.#teardownColorAreaEvents = null; } if (!this.#colorArea || !this.#colorAreaHandle) return; const colorAreaEl = this.#colorArea.parentElement || this.#colorArea; const colorAreaHandleEl = this.#colorAreaHandle; let isPlaneDragging = false; const updatePlaneFromEvent = (e, opts = {}) => { const rect = colorAreaEl.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); this.#updateColorFromAreaPosition(x / rect.width, y / rect.height, opts); }; const onPlanePointerDown = (e) => { if (e.button !== 0) return; if ( e.target === colorAreaHandleEl || colorAreaHandleEl.contains(e.target) ) return; isPlaneDragging = true; this.#isDraggingColor = true; colorAreaEl.setPointerCapture(e.pointerId); updatePlaneFromEvent(e, { updateHandle: true, emitInput: true }); }; const onPlanePointerMove = (e) => { if (!isPlaneDragging) return; if (e.buttons === 0) { onPlaneDragEnd(); return; } updatePlaneFromEvent(e, { updateHandle: true, emitInput: true }); }; const onPlaneDragEnd = () => { if (!isPlaneDragging) return; isPlaneDragging = false; this.#isDraggingColor = false; this.#emitChange(); }; const onHandleInput = (e) => { this.#isDraggingColor = true; const px = e.detail?.px; const py = e.detail?.py; if (!Number.isFinite(px) || !Number.isFinite(py)) return; colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`); this.#updateColorFromAreaPosition(px, py, { updateHandle: false, emitInput: true, }); }; const onHandleChange = (e) => { const px = e.detail?.px; const py = e.detail?.py; if (Number.isFinite(px) && Number.isFinite(py)) { colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`); this.#updateColorFromAreaPosition(px, py, { updateHandle: false, emitInput: false, }); } this.#isDraggingColor = false; this.#emitChange(); }; colorAreaEl.addEventListener("pointerdown", onPlanePointerDown); colorAreaEl.addEventListener("pointermove", onPlanePointerMove); colorAreaEl.addEventListener("pointerup", onPlaneDragEnd); colorAreaEl.addEventListener("pointercancel", onPlaneDragEnd); colorAreaEl.addEventListener("lostpointercapture", onPlaneDragEnd); colorAreaHandleEl.addEventListener("input", onHandleInput); colorAreaHandleEl.addEventListener("change", onHandleChange); this.#teardownColorAreaEvents = () => { colorAreaEl.removeEventListener("pointerdown", onPlanePointerDown); colorAreaEl.removeEventListener("pointermove", onPlanePointerMove); colorAreaEl.removeEventListener("pointerup", onPlaneDragEnd); colorAreaEl.removeEventListener("pointercancel", onPlaneDragEnd); colorAreaEl.removeEventListener("lostpointercapture", onPlaneDragEnd); colorAreaHandleEl.removeEventListener("input", onHandleInput); colorAreaHandleEl.removeEventListener("change", onHandleChange); this.#isDraggingColor = false; }; } #rebuildColorInputFields() { const container = this.#dialog?.querySelector( ".fig-fill-picker-input-fields", ); if (!container) return; const wrap = (tooltip, html) => `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`; const num = (cls, min, max, step) => `<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`; let html; switch (this.#colorInputMode) { case "rgb": html = `<div class="input-combo"> ${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))} ${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))} ${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))} </div>`; break; case "hsl": html = `<div class="input-combo"> ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))} ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))} ${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))} </div>`; break; case "hsb": html = `<div class="input-combo"> ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))} ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))} ${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))} </div>`; break; case "lab": html = `<div class="input-combo"> ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))} ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))} ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))} </div>`; break; case "lch": html = `<div class="input-combo"> ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))} ${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))} ${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))} </div>`; break; default: // hex html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`; break; } container.innerHTML = html; this.#wireColorInputEvents(); requestAnimationFrame(() => this.#updateColorInputs()); } #wireColorInputEvents() { const container = this.#dialog?.querySelector( ".fig-fill-picker-input-fields", ); if (!container) return; const onInput = () => { if (this.#isDraggingColor) return; const color = this.#readColorFromInputs(); if (!color) return; this.#color = { ...color, a: this.#color.a }; this.#drawColorArea(); this.#updateHandlePosition(); if (this.#hueSlider) { this.#hueSlider.setAttribute("value", this.#color.h); } this.#emitInput(); }; const onChange = () => this.#emitChange(); const inputs = container.querySelectorAll( "fig-input-number, fig-input-text", ); inputs.forEach((el) => { el.addEventListener("input", onInput); el.addEventListener("change", onChange); }); } #readColorFromInputs() { const q = (cls) => this.#dialog?.querySelector(`.${cls}`); const val = (cls) => parseFloat(q(cls)?.value ?? 0); switch (this.#colorInputMode) { case "rgb": return this.#rgbToHSV({ r: val("fig-fill-picker-ci-r"), g: val("fig-fill-picker-ci-g"), b: val("fig-fill-picker-ci-b"), }); case "hsl": { const rgb = this.#hslToRGB({ h: val("fig-fill-picker-ci-h"), s: val("fig-fill-picker-ci-s"), l: val("fig-fill-picker-ci-l"), }); return this.#rgbToHSV(rgb); } case "hsb": return { h: val("fig-fill-picker-ci-h"), s: val("fig-fill-picker-ci-s"), v: val("fig-fill-picker-ci-v"), a: 1, }; case "lab": { const rgb = this.#oklabToRGB({ l: val("fig-fill-picker-ci-okl") / 100, a: val("fig-fill-picker-ci-oka"), b: val("fig-fill-picker-ci-okb"), }); return this.#rgbToHSV(rgb); } case "lch": { const rgb = this.#oklchToRGB({ l: val("fig-fill-picker-ci-okl") / 100, c: val("fig-fill-picker-ci-okc"), h: val("fig-fill-picker-ci-okh"), }); return this.#rgbToHSV(rgb); } default: { // hex const hexEl = q("fig-fill-picker-ci-hex"); if (!hexEl) return null; let hex = hexEl.value.replace(/^#/, ""); if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null; return this.#hexToHSV(`#${hex}`); } } } #updateColorInputs() { if (!this.#dialog) return; const hex = this.#hsvToHex(this.#color); const rgb = this.#hsvToRGB(this.#color); const q = (cls) => this.#dialog.querySelector(`.${cls}`); const set = (cls, v) => { const el = q(cls); if (el) el.setAttribute("value", v); }; switch (this.#colorInputMode) { case "rgb": set("fig-fill-picker-ci-r", rgb.r); set("fig-fill-picker-ci-g", rgb.g); set("fig-fill-picker-ci-b", rgb.b); break; case "hsl": { const hsl = this.#rgbToHSL(rgb); set("fig-fill-picker-ci-h", Math.round(hsl.h)); set("fig-fill-picker-ci-s", Math.round(hsl.s)); set("fig-fill-picker-ci-l", Math.round(hsl.l)); break; } case "hsb": set("fig-fill-picker-ci-h", Math.round(this.#color.h)); set("fig-fill-picker-ci-s", Math.round(this.#color.s)); set("fig-fill-picker-ci-v", Math.round(this.#color.v)); break; case "lab": { const lab = this.#rgbToOKLAB(rgb); set("fig-fill-picker-ci-okl", Math.round(lab.l * 100)); set("fig-fill-picker-ci-oka", +lab.a.toFixed(3)); set("fig-fill-picker-ci-okb", +lab.b.toFixed(3)); break; } case "lch": { const lch = this.#rgbToOKLCH(rgb); set("fig-fill-picker-ci-okl", Math.round(lch.l * 100)); set("fig-fill-picker-ci-okc", +lch.c.toFixed(3)); set("fig-fill-picker-ci-okh", Math.round(lch.h)); break; } default: // hex set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase()); break; } if (this.#opacitySlider) { this.#opacitySlider.setAttribute("color", hex); } this.#updateChit(); } // ============ GRADIENT TAB ============ #initGradientTab() { const container = this.#dialog.querySelector('[data-tab="gradient"]'); const experimental = this.getAttribute("experimental"); const expAttr = experimental ? `experimental="${experimental}"` : ""; container.innerHTML = ` <fig-field class="fig-fill-picker-gradient-header"> <fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${ this.#gradient.type }"> <option value="linear" selected>Linear</option> <option value="radial">Radial</option> <option value="angular">Angular</option> </fig-dropdown> <fig-tooltip text="Rotate gradient"> <fig-input-number class="fig-fill-picker-gradient-angle" value="${ (this.#gradient.angle - 90 + 360) % 360 }" min="0" max="360" units="°" wrap></fig-input-number> </fig-tooltip> <div class="fig-fill-picker-gradient-center input-combo" style="display: none;"> <fig-input-number min="0" max="100" value="${ this.#gradient.centerX }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number> <fig-input-number min="0" max="100" value="${ this.#gradient.centerY }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number> </div> <fig-tooltip text="Flip gradient"> <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip"> <fig-icon name="swap"></fig-icon> </fig-button> </fig-tooltip> </fig-field> <fig-preview class="fig-fill-picker-gradient-preview"> <fig-input-gradient class="fig-fill-picker-gradient-bar-input" edit="true" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient> </fig-preview> <fig-field class="fig-fill-picker-gradient-interpolation"> <label>Mixing</label> <fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${ this.#gradient.interpolationSpace === "oklch" ? `oklch-${this.#gradient.hueInterpolation || "shorter"}` : this.#gradient.interpolationSpace }"> <optgroup label="sRGB"> <option value="srgb-linear">Linear</option> </optgroup> <optgroup label="OKLab"> <option value="oklab">Perceptual</option> </optgroup> <optgroup label="OKLCH"> <option value="oklch-shorter">Shorter hue</option> <option value="oklch-longer">Longer hue</option> <option value="oklch-increasing">Increasing hue</option> <option value="oklch-decreasing">Decreasing hue</option> </optgroup> </fig-dropdown> </fig-field> <div class="fig-fill-picker-gradient-stops"> <fig-header class="fig-fill-picker-gradient-stops-header" borderless> <span>Stops</span> <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop"> <fig-icon name="add"></fig-icon> </fig-button> </fig-header> <div class="fig-fill-picker-gradient-stops-list"></div> </div> `; this.#updateGradientUI(); this.#setupGradientEvents(container); } #setupGradientEvents(container) { // Type dropdown const typeDropdown = container.querySelector( ".fig-fill-picker-gradient-type", ); const getDropdownValue = (event) => event.currentTarget?.value ?? event.target?.value ?? event.detail; const handleTypeChange = (e) => { this.#gradient.type = getDropdownValue(e); this.#updateGradientUI(); this.#emitInput(); }; typeDropdown.addEventListener("input", handleTypeChange); typeDropdown.addEventListener("change", handleTypeChange); const interpolationDropdown = container.querySelector( ".fig-fill-picker-gradient-space", ); const handleInterpolationChange = (e) => { const val = getDropdownValue(e); let space = val; let hue = "shorter"; if (val.startsWith("oklch-")) { space = "oklch"; hue = val.slice(6); } this.#gradient = normalizeGradientConfig({ ...this.#gradient, interpolationSpace: space, hueInterpolation: hue, }); this.#updateGradientUI(); this.#emitInput(); }; interpolationDropdown?.addEventListener("input", handleInterpolationChange); interpolationDropdown?.addEventListener( "change", handleInterpolationChange, ); // Angle input const angleInput = container.querySelector( ".fig-fill-picker-gradient-angle", ); angleInput.addEventListener("input", (e) => { const pickerAngle = parseFloat(e.target.value) || 0; this.#gradient.angle = (pickerAngle + 90) % 360; this.#updateGradientPreview(); this.#emitInput(); }); // Center X/Y inputs const cxInput = container.querySelector(".fig-fill-picker-gradient-cx"); const cyInput = container.querySelector(".fig-fill-picker-gradient-cy"); cxInput?.addEventListener("input", (e) => { this.#gradient.centerX = parseFloat(e.target.value) || 50; this.#updateGradientPreview(); this.#emitInput(); }); cyInput?.addEventListener("input", (e) => { this.#gradient.centerY = parseFloat(e.target.value) || 50; this.#updateGradientPreview(); this.#emitInput(); }); // Flip button container .querySelector(".fig-fill-picker-gradient-flip") .addEventListener("click", () => { this.#gradient.stops.forEach((stop) => { stop.position = 100 - stop.position; }); this.#gradient.stops.sort((a, b) => a.position - b.position); this.#updateGradientUI(); this.#emitInput(); }); // Add stop button container .querySelector(".fig-fill-picker-gradient-add") .addEventListener("click", () => { const midPosition = 50; this.#gradient.stops.push({ position: midPosition, color: "#888888", opacity: 100, }); this.#gradient.stops.sort((a, b) => a.position - b.position); this.#updateGradientUI(); this.#emitInput(); }); // Embedded gradient bar input const gradientBarInput = container.querySelector( ".fig-fill-picker-gradient-bar-input", ); if (gradientBarInput) { const syncFromBarInput = (e) => { e.stopPropagation(); const detail = e.detail; if (!detail?.gradient) return; this.#gradient = normalizeGradientConfig({ ...this.#gradient, ...detail.gradient, }); this.#updateChit(); this.#updateGradientStopsList(); }; gradientBarInput.addEventListener("input", (e) => { syncFromBarInput(e); this.#emitInput(); }); gradientBarInput.addEventListener("change", (e) => { syncFromBarInput(e); this.#emitChange(); }); } } #updateGradientUI() { if (!this.#dialog) return; const container = this.#dialog.querySelector('[data-tab="gradient"]'); if (!container) return; this.#gradient = normalizeGradientConfig(this.#gradient); // Show/hide angle vs center inputs const angleInput = container.querySelector( ".fig-fill-picker-gradient-angle", ); const centerInputs = container.querySelector( ".fig-fill-picker-gradient-center", ); if (this.#gradient.type === "radial") { angleInput.style.display = "none"; centerInputs.style.display = "flex"; } else { angleInput.style.display = "block"; centerInputs.style.display = "none"; // Sync angle input value (convert CSS angle to picker angle) const pickerAngle = (this.#gradient.angle - 90 + 360) % 360; angleInput.setAttribute("value", pickerAngle); } const interpolationDropdown = container.querySelector( ".fig-fill-picker-gradient-space", ); if (interpolationDropdown) { interpolationDropdown.value = this.#gradient.interpolationSpace === "oklch" ? `oklch-${this.#gradient.hueInterpolation || "shorter"}` : this.#gradient.interpolationSpace; } this.#updateGradientPreview(); this.#updateGradientStopsList(); } #updateGradientPreview() { if (!this.#dialog) return; const barInput = this.#dialog.querySelector( ".fig-fill-picker-gradient-bar-input", ); if (barInput) { barInput.setAttribute( "value", JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient), }), ); } this.#updateChit(); } #updateGradientStopsList() { if (!this.#dialog) return; const list = this.#dialog.querySelector( ".fig-fill-picker-gradient-stops-list", ); if (!list) return; const existingRows = list.querySelectorAll( ".fig-fill-picker-gradient-stop-row", ); if (existingRows.length === this.#gradient.stops.length) { this.#gradient.stops.forEach((stop, index) => { const row = existingRows[index]; row.dataset.index = index; const posInput = row.querySelector(".fig-fill-picker-stop-position"); if (posInput) posInput.setAttribute("value", stop.position); const colorInput = row.querySelector(".fig-fill-picker-stop-color"); if (colorInput) colorInput.setAttribute("value", stop.color); const removeBtn = row.querySelector(".fig-fill-picker-stop-remove"); if (removeBtn) { if (this.#gradient.stops.length <= 2) removeBtn.setAttribute("disabled", ""); else removeBtn.removeAttribute("disabled"); } }); return; } this.#rebuildGradientStopsList(list); } #rebuildGradientStopsList(list) { list.innerHTML = this.#gradient.stops .map( (stop, index) => ` <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}"> <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${ stop.position }" units="%"></fig-input-number> <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${ stop.color }"></fig-input-color> <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${ this.#gradient.stops.length <= 2 ? "disabled" : "" }> <fig-icon name="minus"></fig-icon> </fig-button> </fig-field> `, ) .join(""); list .querySelectorAll(".fig-fill-picker-gradient-stop-row") .forEach((row) => { const index = parseInt(row.dataset.index); row .querySelector(".fig-fill-picker-stop-position") .addEventListener("input", (e) => { this.#gradient.stops[index].position = parseFloat(e.target.value) || 0; this.#updateGradientPreview(); this.#emitInput(); }); const stopColor = row.querySelector(".fig-fill-picker-stop-color"); const stopFillPicker = stopColor.querySelector("fig-fill-picker"); if (stopFillPicker) { stopFillPicker.anchorElement = this.#dialog; } else { requestAnimationFrame(() => { const fp = stopColor.querySelector("fig-fill-picker"); if (fp) fp.anchorElement = this.#dialog; }); } stopColor.addEventListener("input", (e) => { this.#gradient.stops[index].color = e.target.hexOpaque || e.target.value; const a = e.detail?.rgba?.a; if (a !== undefined) { this.#gradient.stops[index].opacity = Math.round(a * 100); } this.#updateGradientPreview(); this.#emitInput(); }); row .querySelector(".fig-fill-picker-stop-remove") .addEventListener("click", () => { if (this.#gradient.stops.length > 2) { this.#gradient.stops.splice(index, 1); this.#updateGradientUI(); this.#emitInput(); } }); }); } #buildGradientCSS(interpolationSpaceOverride, includeInterpolation = true) { const gradient = normalizeGradientConfig({ ...this.#gradient, interpolationSpace: interpolationSpaceOverride ?? this.#gradient.interpolationSpace, }); const isP3 = this.#gamut === "display-p3"; const stops = gradient.stops .map((s) => { const alpha = (s.opacity ?? 100) / 100; const color = isP3 ? this.#hexToP3(s.color, alpha) : this.#hexToRGBA(s.color, alpha); return `${color} ${s.position}%`; }) .join(", "); const interpolation = includeInterpolation ? ` ${gradientInterpolationClause(gradient)}` : ""; switch (gradient.type) { case "linear": return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`; case "radial": return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`; case "angular": return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`; default: return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`; } } static #gradientSupportCache = new Map(); #testGradientSupport(css) { const cached = FigFillPicker.#gradientSupportCache.get(css); if (cached !== undefined) return cached; const el = document.createElement("div"); el.style.background = css; const result = !!el.style.background; FigFillPicker.#gradientSupportCache.set(css, result); return result; } #getGradientCSS() { const preferred = this.#buildGradientCSS(undefined, true); if (this.#testGradientSupport(preferred)) return preferred; const oklabFallback = this.#buildGradientCSS("oklab", true); if (this.#testGradientSupport(oklabFallback)) return oklabFallback; return this.#buildGradientCSS("oklab", false); } // ===