@rogieking/figui3 8.9.16 → 8.9.18
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/.cursor/skills/fig-editor/SKILL.md +9 -5
- package/.cursor/skills/figui3/SKILL.md +1 -1
- package/README.md +15 -5
- package/components.css +17 -2
- package/dist/components.css +1 -1
- package/dist/fig-editor.css +1 -1
- package/dist/fig-editor.js +3 -3
- package/dist/fig.css +1 -1
- package/dist/fig.js +7 -7
- package/fig-editor.css +10 -5
- package/fig-editor.js +363 -153
- package/fig.js +127 -28
- package/package.json +1 -1
package/fig-editor.js
CHANGED
|
@@ -24,6 +24,11 @@ function figEditorUniqueId() {
|
|
|
24
24
|
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function figEditorCssUrl(url) {
|
|
28
|
+
if (!url) return "";
|
|
29
|
+
return `url("${String(url).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
function figEditorCreateIcon(name, options = {}) {
|
|
28
33
|
const icon = document.createElement("fig-icon");
|
|
29
34
|
if (name) icon.setAttribute("name", name);
|
|
@@ -1494,8 +1499,16 @@ function normalizeGradientConfig(gradient) {
|
|
|
1494
1499
|
return next;
|
|
1495
1500
|
}
|
|
1496
1501
|
|
|
1502
|
+
function lockFillPickerGradientInterpolation(gradient) {
|
|
1503
|
+
const next = normalizeGradientConfig(gradient);
|
|
1504
|
+
// Interpolation UI hidden for now — lock to sRGB.
|
|
1505
|
+
next.interpolationSpace = "srgb";
|
|
1506
|
+
delete next.hueInterpolation;
|
|
1507
|
+
return next;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1497
1510
|
function gradientToValueShape(gradient) {
|
|
1498
|
-
const normalized =
|
|
1511
|
+
const normalized = lockFillPickerGradientInterpolation(gradient);
|
|
1499
1512
|
const output = {
|
|
1500
1513
|
...normalized,
|
|
1501
1514
|
interpolationSpace: normalized.interpolationSpace,
|
|
@@ -1552,6 +1565,9 @@ function parseGradientInterpolationSelectValue(val) {
|
|
|
1552
1565
|
* @attr {boolean} disabled - Whether the picker is disabled
|
|
1553
1566
|
* @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
|
|
1554
1567
|
* @attr {string} dialog-position - Position of the popup (default: "left")
|
|
1568
|
+
* @attr {string} webcam-mode - `live` (default) keeps the camera after close; Capture always writes an image still
|
|
1569
|
+
* @attr {string} default-video - Sample clip URL when Video is selected with no file
|
|
1570
|
+
* @fires webcamstream - `{ stream, deviceId }` when the live camera starts, switches, or is released
|
|
1555
1571
|
*/
|
|
1556
1572
|
let figFillPickerDialogId = 0;
|
|
1557
1573
|
|
|
@@ -1634,8 +1650,24 @@ class FigFillPicker extends HTMLElement {
|
|
|
1634
1650
|
],
|
|
1635
1651
|
};
|
|
1636
1652
|
#image = { url: null, scaleMode: "fill", scale: 50 };
|
|
1637
|
-
#video = {
|
|
1638
|
-
|
|
1653
|
+
#video = {
|
|
1654
|
+
url: null,
|
|
1655
|
+
poster: null,
|
|
1656
|
+
scaleMode: "fill",
|
|
1657
|
+
scale: 50,
|
|
1658
|
+
opacity: 1,
|
|
1659
|
+
missing: true,
|
|
1660
|
+
};
|
|
1661
|
+
#webcam = {
|
|
1662
|
+
stream: null,
|
|
1663
|
+
live: true,
|
|
1664
|
+
snapshot: null,
|
|
1665
|
+
deviceId: null,
|
|
1666
|
+
scaleMode: "fill",
|
|
1667
|
+
scale: 50,
|
|
1668
|
+
opacity: 1,
|
|
1669
|
+
};
|
|
1670
|
+
#webcamPosterStream = null;
|
|
1639
1671
|
|
|
1640
1672
|
// Custom mode slots and data
|
|
1641
1673
|
#customSlots = {};
|
|
@@ -1671,6 +1703,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
1671
1703
|
"disabled",
|
|
1672
1704
|
"alpha",
|
|
1673
1705
|
"mode",
|
|
1706
|
+
"webcam-mode",
|
|
1707
|
+
"default-video",
|
|
1674
1708
|
"aria-label",
|
|
1675
1709
|
"aria-labelledby",
|
|
1676
1710
|
"aria-describedby",
|
|
@@ -1689,7 +1723,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
1689
1723
|
}
|
|
1690
1724
|
|
|
1691
1725
|
disconnectedCallback() {
|
|
1692
|
-
this.#discardDialog();
|
|
1726
|
+
this.#discardDialog({ stopWebcam: true });
|
|
1693
1727
|
this.#cancelFrames();
|
|
1694
1728
|
this.#revokeOwnedBlobUrls();
|
|
1695
1729
|
if (this.#swatch) this.#swatch.removeAttribute("selected");
|
|
@@ -1708,6 +1742,116 @@ class FigFillPicker extends HTMLElement {
|
|
|
1708
1742
|
);
|
|
1709
1743
|
}
|
|
1710
1744
|
|
|
1745
|
+
#webcamMode() {
|
|
1746
|
+
return this.getAttribute("webcam-mode") === "snapshot" ? "snapshot" : "live";
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
#shouldKeepWebcamLive() {
|
|
1750
|
+
return (
|
|
1751
|
+
this.#fillType === "webcam" &&
|
|
1752
|
+
this.#webcamMode() === "live" &&
|
|
1753
|
+
this.#webcam.live !== false
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
#webcamValue() {
|
|
1758
|
+
return {
|
|
1759
|
+
live: this.#webcamMode() === "live" && this.#webcam.live !== false,
|
|
1760
|
+
snapshot: this.#webcam.snapshot ?? null,
|
|
1761
|
+
deviceId: this.#webcam.deviceId ?? null,
|
|
1762
|
+
scaleMode: this.#webcam.scaleMode || "fill",
|
|
1763
|
+
scale: this.#webcam.scale ?? 50,
|
|
1764
|
+
opacity: this.#webcam.opacity ?? 1,
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
#videoValue() {
|
|
1769
|
+
const url = this.#video.url ?? null;
|
|
1770
|
+
return {
|
|
1771
|
+
url,
|
|
1772
|
+
poster: this.#video.poster ?? null,
|
|
1773
|
+
scaleMode: this.#video.scaleMode || "fill",
|
|
1774
|
+
scale: this.#video.scale ?? 50,
|
|
1775
|
+
opacity: this.#video.opacity ?? 1,
|
|
1776
|
+
...(url ? {} : { missing: true }),
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
#applyParsedWebcam(parsed) {
|
|
1781
|
+
if (parsed.webcam && typeof parsed.webcam === "object") {
|
|
1782
|
+
const { stream: _ignored, ...rest } = parsed.webcam;
|
|
1783
|
+
Object.assign(this.#webcam, rest);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (parsed.type === "webcam" && parsed.image) {
|
|
1787
|
+
if (parsed.image.url != null) this.#webcam.snapshot = parsed.image.url;
|
|
1788
|
+
if (parsed.image.scaleMode) this.#webcam.scaleMode = parsed.image.scaleMode;
|
|
1789
|
+
if (parsed.image.scale != null) this.#webcam.scale = parsed.image.scale;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
#emitWebcamStream() {
|
|
1794
|
+
this.dispatchEvent(
|
|
1795
|
+
new CustomEvent("webcamstream", {
|
|
1796
|
+
bubbles: true,
|
|
1797
|
+
composed: true,
|
|
1798
|
+
detail: {
|
|
1799
|
+
stream: this.#webcam.stream,
|
|
1800
|
+
deviceId: this.#webcam.deviceId ?? null,
|
|
1801
|
+
},
|
|
1802
|
+
}),
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
get webcamStream() {
|
|
1807
|
+
return this.#webcam.stream;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
releaseWebcam() {
|
|
1811
|
+
this.#stopWebcam();
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
#writeWebcamSnapshot(blob) {
|
|
1815
|
+
if (!blob) return null;
|
|
1816
|
+
if (this.#webcam.snapshot?.startsWith("blob:")) {
|
|
1817
|
+
URL.revokeObjectURL(this.#webcam.snapshot);
|
|
1818
|
+
this.#ownedBlobUrls.delete(this.#webcam.snapshot);
|
|
1819
|
+
}
|
|
1820
|
+
this.#webcam.snapshot = URL.createObjectURL(blob);
|
|
1821
|
+
this.#ownedBlobUrls.add(this.#webcam.snapshot);
|
|
1822
|
+
return this.#webcam.snapshot;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
async #snapshotWebcamVideo(video) {
|
|
1826
|
+
if (!video?.videoWidth || !video.videoHeight) return null;
|
|
1827
|
+
const canvas = document.createElement("canvas");
|
|
1828
|
+
canvas.width = video.videoWidth;
|
|
1829
|
+
canvas.height = video.videoHeight;
|
|
1830
|
+
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1831
|
+
const blob = await new Promise((resolve) =>
|
|
1832
|
+
canvas.toBlob(resolve, "image/png"),
|
|
1833
|
+
);
|
|
1834
|
+
return this.#writeWebcamSnapshot(blob);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
#syncLiveWebcamSwatch(video) {
|
|
1838
|
+
if (this.#fillType !== "webcam") return;
|
|
1839
|
+
const stream = this.#webcam.stream;
|
|
1840
|
+
if (!stream) return;
|
|
1841
|
+
if (this.#webcamPosterStream === stream && this.#webcam.snapshot) {
|
|
1842
|
+
this.#updateSwatch();
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
this.#webcamPosterStream = stream;
|
|
1846
|
+
this.#snapshotWebcamVideo(video).then((url) => {
|
|
1847
|
+
if (!url || this.#fillType !== "webcam" || this.#webcam.stream !== stream) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
this.#updateSwatch();
|
|
1851
|
+
this.#emitInput();
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1711
1855
|
#scheduleFrame(callback) {
|
|
1712
1856
|
const id = requestAnimationFrame(() => {
|
|
1713
1857
|
this.#rafIds.delete(id);
|
|
@@ -1723,12 +1867,17 @@ class FigFillPicker extends HTMLElement {
|
|
|
1723
1867
|
}
|
|
1724
1868
|
|
|
1725
1869
|
#revokeOwnedBlobUrls() {
|
|
1726
|
-
|
|
1870
|
+
// Keep blobs still published on value so fig-input-fill / hosts can paint the swatch.
|
|
1871
|
+
const keep = new Set(
|
|
1872
|
+
[this.#webcam.snapshot, this.#video.poster, this.#image.url].filter(
|
|
1873
|
+
(url) => typeof url === "string" && url.startsWith("blob:"),
|
|
1874
|
+
),
|
|
1875
|
+
);
|
|
1876
|
+
this.#ownedBlobUrls.forEach((url) => {
|
|
1877
|
+
if (!keep.has(url)) URL.revokeObjectURL(url);
|
|
1878
|
+
});
|
|
1727
1879
|
this.#ownedBlobUrls.clear();
|
|
1728
|
-
|
|
1729
|
-
if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
|
|
1730
|
-
this.#webcam.snapshot = null;
|
|
1731
|
-
}
|
|
1880
|
+
keep.forEach((url) => this.#ownedBlobUrls.add(url));
|
|
1732
1881
|
}
|
|
1733
1882
|
|
|
1734
1883
|
#setupTrigger() {
|
|
@@ -1850,13 +1999,17 @@ class FigFillPicker extends HTMLElement {
|
|
|
1850
1999
|
// Gamut UI hidden for now — lock to sRGB.
|
|
1851
2000
|
this.#gamut = "srgb";
|
|
1852
2001
|
if (parsed.gradient) {
|
|
1853
|
-
this.#gradient =
|
|
2002
|
+
this.#gradient = lockFillPickerGradientInterpolation({
|
|
1854
2003
|
...this.#gradient,
|
|
1855
2004
|
...parsed.gradient,
|
|
1856
2005
|
});
|
|
1857
2006
|
}
|
|
1858
2007
|
if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
|
|
1859
|
-
if (parsed.video)
|
|
2008
|
+
if (parsed.video) {
|
|
2009
|
+
this.#video = { ...this.#video, ...parsed.video };
|
|
2010
|
+
this.#video.missing = !this.#video.url;
|
|
2011
|
+
}
|
|
2012
|
+
this.#applyParsedWebcam(parsed);
|
|
1860
2013
|
|
|
1861
2014
|
// Store full parsed data for custom (non-built-in) types
|
|
1862
2015
|
if (parsed.type && !builtinTypes.includes(parsed.type)) {
|
|
@@ -1888,7 +2041,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
1888
2041
|
break;
|
|
1889
2042
|
case "image":
|
|
1890
2043
|
if (this.#image.url) {
|
|
1891
|
-
bg =
|
|
2044
|
+
bg = figEditorCssUrl(this.#image.url);
|
|
1892
2045
|
const sizing = this.#getBackgroundSizing(
|
|
1893
2046
|
this.#image.scaleMode,
|
|
1894
2047
|
this.#image.scale,
|
|
@@ -1900,8 +2053,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
1900
2053
|
}
|
|
1901
2054
|
break;
|
|
1902
2055
|
case "video":
|
|
1903
|
-
if (this.#video.
|
|
1904
|
-
bg =
|
|
2056
|
+
if (this.#video.poster) {
|
|
2057
|
+
bg = figEditorCssUrl(this.#video.poster);
|
|
1905
2058
|
const sizing = this.#getBackgroundSizing(
|
|
1906
2059
|
this.#video.scaleMode,
|
|
1907
2060
|
this.#video.scale,
|
|
@@ -1913,7 +2066,17 @@ class FigFillPicker extends HTMLElement {
|
|
|
1913
2066
|
}
|
|
1914
2067
|
break;
|
|
1915
2068
|
case "webcam":
|
|
1916
|
-
|
|
2069
|
+
if (this.#webcam.snapshot) {
|
|
2070
|
+
bg = figEditorCssUrl(this.#webcam.snapshot);
|
|
2071
|
+
const sizing = this.#getBackgroundSizing(
|
|
2072
|
+
this.#webcam.scaleMode,
|
|
2073
|
+
this.#webcam.scale,
|
|
2074
|
+
);
|
|
2075
|
+
bgSize = sizing.size;
|
|
2076
|
+
bgPosition = sizing.position;
|
|
2077
|
+
} else {
|
|
2078
|
+
bg = "";
|
|
2079
|
+
}
|
|
1917
2080
|
break;
|
|
1918
2081
|
default:
|
|
1919
2082
|
const slot = this.#customSlots[this.#fillType];
|
|
@@ -1995,14 +2158,15 @@ class FigFillPicker extends HTMLElement {
|
|
|
1995
2158
|
}
|
|
1996
2159
|
}
|
|
1997
2160
|
|
|
1998
|
-
#discardDialog() {
|
|
2161
|
+
#discardDialog({ stopWebcam = false } = {}) {
|
|
1999
2162
|
if (this.#teardownColorAreaEvents) {
|
|
2000
2163
|
this.#teardownColorAreaEvents();
|
|
2001
2164
|
this.#teardownColorAreaEvents = null;
|
|
2002
2165
|
}
|
|
2003
2166
|
this.#gradientInterpolationOpenObserver?.disconnect();
|
|
2004
2167
|
this.#gradientInterpolationOpenObserver = null;
|
|
2005
|
-
this.#
|
|
2168
|
+
if (!stopWebcam && this.#shouldKeepWebcamLive()) this.#detachWebcamPreview();
|
|
2169
|
+
else this.#stopWebcam();
|
|
2006
2170
|
if (!this.#dialog) return;
|
|
2007
2171
|
this.#restoreCustomSlotContent();
|
|
2008
2172
|
this.#dialog.remove();
|
|
@@ -2018,16 +2182,27 @@ class FigFillPicker extends HTMLElement {
|
|
|
2018
2182
|
this.#syncTriggerA11y();
|
|
2019
2183
|
}
|
|
2020
2184
|
|
|
2021
|
-
#
|
|
2185
|
+
#detachWebcamPreview() {
|
|
2022
2186
|
this.#webcamRequestId += 1;
|
|
2187
|
+
const video = this.#dialog?.querySelector(
|
|
2188
|
+
".fig-fill-picker-webcam-video",
|
|
2189
|
+
);
|
|
2190
|
+
if (video) video.srcObject = null;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
#stopWebcam({ emit = true } = {}) {
|
|
2194
|
+
this.#webcamRequestId += 1;
|
|
2195
|
+
const hadStream = Boolean(this.#webcam.stream);
|
|
2023
2196
|
if (this.#webcam.stream) {
|
|
2024
2197
|
this.#webcam.stream.getTracks().forEach((track) => track.stop());
|
|
2025
2198
|
this.#webcam.stream = null;
|
|
2026
2199
|
}
|
|
2200
|
+
this.#webcamPosterStream = null;
|
|
2027
2201
|
const video = this.#dialog?.querySelector(
|
|
2028
2202
|
".fig-fill-picker-webcam-video",
|
|
2029
2203
|
);
|
|
2030
2204
|
if (video) video.srcObject = null;
|
|
2205
|
+
if (hadStream && emit) this.#emitWebcamStream();
|
|
2031
2206
|
}
|
|
2032
2207
|
|
|
2033
2208
|
#createDialog() {
|
|
@@ -2114,6 +2289,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
2114
2289
|
{
|
|
2115
2290
|
className: "fig-fill-picker-type",
|
|
2116
2291
|
label: "Fill type",
|
|
2292
|
+
variant: "ghost",
|
|
2117
2293
|
value: this.#fillType,
|
|
2118
2294
|
},
|
|
2119
2295
|
options,
|
|
@@ -2186,7 +2362,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
2186
2362
|
|
|
2187
2363
|
const onDialogClose = () => {
|
|
2188
2364
|
if (this.#swatch) this.#swatch.removeAttribute("selected");
|
|
2189
|
-
this.#
|
|
2365
|
+
if (this.#shouldKeepWebcamLive()) this.#detachWebcamPreview();
|
|
2366
|
+
else this.#stopWebcam();
|
|
2190
2367
|
const closingValue = JSON.stringify(this.value);
|
|
2191
2368
|
if (this.#lastChangeValue !== null && this.#lastChangeValue !== closingValue) {
|
|
2192
2369
|
this.#emitChange();
|
|
@@ -2287,7 +2464,11 @@ class FigFillPicker extends HTMLElement {
|
|
|
2287
2464
|
});
|
|
2288
2465
|
}
|
|
2289
2466
|
|
|
2290
|
-
if (tabName === "
|
|
2467
|
+
if (tabName === "video") this.#applyDefaultVideo();
|
|
2468
|
+
if (tabName === "webcam") {
|
|
2469
|
+
this.#webcam.live = this.#webcamMode() === "live";
|
|
2470
|
+
this.#webcamStart?.(this.#webcam.deviceId);
|
|
2471
|
+
}
|
|
2291
2472
|
|
|
2292
2473
|
this.#updateSwatch();
|
|
2293
2474
|
if (emit) this.#emitInput();
|
|
@@ -3040,7 +3221,6 @@ class FigFillPicker extends HTMLElement {
|
|
|
3040
3221
|
// ============ GRADIENT TAB ============
|
|
3041
3222
|
#initGradientTab() {
|
|
3042
3223
|
const container = this.#dialog.querySelector('[data-tab="gradient"]');
|
|
3043
|
-
const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
|
|
3044
3224
|
const gradientType = figEditorCreateElement(
|
|
3045
3225
|
"fig-select",
|
|
3046
3226
|
{
|
|
@@ -3142,70 +3322,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
3142
3322
|
),
|
|
3143
3323
|
],
|
|
3144
3324
|
);
|
|
3145
|
-
|
|
3146
|
-
"fig-segmented-control",
|
|
3147
|
-
{
|
|
3148
|
-
className: "fig-fill-picker-gradient-interpolation-modes",
|
|
3149
|
-
"aria-label": "Color interpolation",
|
|
3150
|
-
value: interpolationValue,
|
|
3151
|
-
},
|
|
3152
|
-
FigFillPicker.#GRADIENT_INTERPOLATION_MODES.filter(
|
|
3153
|
-
(mode) => !mode.advanced,
|
|
3154
|
-
).map(({ value, space, title, subtitle }) =>
|
|
3155
|
-
figEditorCreateElement(
|
|
3156
|
-
"fig-tooltip",
|
|
3157
|
-
{ text: `${title} — ${subtitle}` },
|
|
3158
|
-
figEditorCreateElement(
|
|
3159
|
-
"fig-segment",
|
|
3160
|
-
{
|
|
3161
|
-
value,
|
|
3162
|
-
"data-space": space,
|
|
3163
|
-
"aria-label": `${title} — ${subtitle}`,
|
|
3164
|
-
},
|
|
3165
|
-
figEditorCreateElement("fig-interpolation-swatch", {
|
|
3166
|
-
"aria-hidden": "true",
|
|
3167
|
-
}),
|
|
3168
|
-
),
|
|
3169
|
-
),
|
|
3170
|
-
),
|
|
3171
|
-
);
|
|
3172
|
-
const interpolationSelect = figEditorCreateElement(
|
|
3173
|
-
"fig-select",
|
|
3174
|
-
{
|
|
3175
|
-
className: "fig-fill-picker-gradient-space",
|
|
3176
|
-
label: "Color interpolation",
|
|
3177
|
-
value: interpolationValue,
|
|
3178
|
-
},
|
|
3179
|
-
figEditorCreateElement(
|
|
3180
|
-
"fig-select-options",
|
|
3181
|
-
{},
|
|
3182
|
-
this.#createGradientInterpolationOptions(),
|
|
3183
|
-
),
|
|
3184
|
-
);
|
|
3185
|
-
// The select trigger overlays the icon button so its menu anchors correctly.
|
|
3186
|
-
const interpolationMore = figEditorCreateElement(
|
|
3187
|
-
"div",
|
|
3188
|
-
{ className: "fig-fill-picker-gradient-interpolation-more" },
|
|
3189
|
-
[
|
|
3190
|
-
figEditorCreateElement(
|
|
3191
|
-
"fig-button",
|
|
3192
|
-
{
|
|
3193
|
-
icon: true,
|
|
3194
|
-
variant: "ghost",
|
|
3195
|
-
"aria-hidden": "true",
|
|
3196
|
-
tabindex: "-1",
|
|
3197
|
-
},
|
|
3198
|
-
figEditorCreateIcon("more"),
|
|
3199
|
-
),
|
|
3200
|
-
interpolationSelect,
|
|
3201
|
-
],
|
|
3202
|
-
);
|
|
3203
|
-
const interpolation = figEditorCreateElement(
|
|
3204
|
-
"fig-field",
|
|
3205
|
-
{ className: "fig-fill-picker-gradient-interpolation-field" },
|
|
3206
|
-
[interpolationModes, interpolationMore],
|
|
3207
|
-
);
|
|
3208
|
-
container.replaceChildren(header, preview, interpolation, stops);
|
|
3325
|
+
container.replaceChildren(header, preview, stops);
|
|
3209
3326
|
|
|
3210
3327
|
this.#updateGradientUI();
|
|
3211
3328
|
this.#setupGradientEvents(container);
|
|
@@ -3503,7 +3620,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
3503
3620
|
if (this.#syncingGradientBar) return;
|
|
3504
3621
|
const detail = e.detail;
|
|
3505
3622
|
if (!detail?.gradient) return;
|
|
3506
|
-
this.#gradient =
|
|
3623
|
+
this.#gradient = lockFillPickerGradientInterpolation({
|
|
3507
3624
|
...this.#gradient,
|
|
3508
3625
|
...detail.gradient,
|
|
3509
3626
|
});
|
|
@@ -3674,7 +3791,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
3674
3791
|
|
|
3675
3792
|
const container = this.#dialog.querySelector('[data-tab="gradient"]');
|
|
3676
3793
|
if (!container) return;
|
|
3677
|
-
this.#gradient =
|
|
3794
|
+
this.#gradient = lockFillPickerGradientInterpolation(this.#gradient);
|
|
3678
3795
|
|
|
3679
3796
|
const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
|
|
3680
3797
|
const interpolationSelect = container.querySelector(
|
|
@@ -4114,6 +4231,11 @@ class FigFillPicker extends HTMLElement {
|
|
|
4114
4231
|
}
|
|
4115
4232
|
|
|
4116
4233
|
element.setAttribute("src", this.#video.url);
|
|
4234
|
+
if (this.#video.poster) {
|
|
4235
|
+
element.setAttribute("poster", this.#video.poster);
|
|
4236
|
+
} else {
|
|
4237
|
+
element.removeAttribute("poster");
|
|
4238
|
+
}
|
|
4117
4239
|
element.classList.add("has-media");
|
|
4118
4240
|
|
|
4119
4241
|
const fileInput = element.querySelector("fig-input-file[data-generated]");
|
|
@@ -4196,6 +4318,68 @@ class FigFillPicker extends HTMLElement {
|
|
|
4196
4318
|
container.replaceChildren(header, preview);
|
|
4197
4319
|
|
|
4198
4320
|
this.#setupVideoEvents(container);
|
|
4321
|
+
this.#applyDefaultVideo();
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
#revokeVideoPoster() {
|
|
4325
|
+
if (this.#video.poster?.startsWith("blob:")) {
|
|
4326
|
+
URL.revokeObjectURL(this.#video.poster);
|
|
4327
|
+
this.#ownedBlobUrls.delete(this.#video.poster);
|
|
4328
|
+
}
|
|
4329
|
+
this.#video.poster = null;
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
#applyDefaultVideo({ emit = true } = {}) {
|
|
4333
|
+
if (this.#video.url) {
|
|
4334
|
+
this.#video.missing = false;
|
|
4335
|
+
if (!this.#video.poster) this.#captureVideoPoster(this.#video.url, { emit });
|
|
4336
|
+
return;
|
|
4337
|
+
}
|
|
4338
|
+
const fallback = this.getAttribute("default-video");
|
|
4339
|
+
if (!fallback) {
|
|
4340
|
+
this.#video.missing = true;
|
|
4341
|
+
return;
|
|
4342
|
+
}
|
|
4343
|
+
this.#video.url = fallback;
|
|
4344
|
+
this.#video.missing = false;
|
|
4345
|
+
const preview = this.#dialog?.querySelector(
|
|
4346
|
+
".fig-fill-picker-video-preview",
|
|
4347
|
+
);
|
|
4348
|
+
if (preview) this.#updateVideoPreviewStyle(preview);
|
|
4349
|
+
this.#captureVideoPoster(fallback, { emit });
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
async #captureVideoPoster(src, { emit = true } = {}) {
|
|
4353
|
+
if (!src) return;
|
|
4354
|
+
const video = document.createElement("video");
|
|
4355
|
+
video.muted = true;
|
|
4356
|
+
video.playsInline = true;
|
|
4357
|
+
video.preload = "auto";
|
|
4358
|
+
video.crossOrigin = "anonymous";
|
|
4359
|
+
try {
|
|
4360
|
+
await new Promise((resolve, reject) => {
|
|
4361
|
+
const fail = () => reject(new Error("video poster failed"));
|
|
4362
|
+
video.addEventListener("error", fail, { once: true });
|
|
4363
|
+
video.addEventListener("loadeddata", resolve, { once: true });
|
|
4364
|
+
video.src = src;
|
|
4365
|
+
});
|
|
4366
|
+
if (!video.videoWidth || !video.videoHeight) return;
|
|
4367
|
+
const canvas = document.createElement("canvas");
|
|
4368
|
+
canvas.width = video.videoWidth;
|
|
4369
|
+
canvas.height = video.videoHeight;
|
|
4370
|
+
canvas.getContext("2d").drawImage(video, 0, 0);
|
|
4371
|
+
const blob = await new Promise((resolve) =>
|
|
4372
|
+
canvas.toBlob(resolve, "image/jpeg", 0.85),
|
|
4373
|
+
);
|
|
4374
|
+
if (!blob) return;
|
|
4375
|
+
this.#revokeVideoPoster();
|
|
4376
|
+
this.#video.poster = URL.createObjectURL(blob);
|
|
4377
|
+
this.#ownedBlobUrls.add(this.#video.poster);
|
|
4378
|
+
this.#updateSwatch();
|
|
4379
|
+
if (emit) this.#emitInput();
|
|
4380
|
+
} catch {
|
|
4381
|
+
// Cross-origin or decode failures leave the swatch empty until a poster exists.
|
|
4382
|
+
}
|
|
4199
4383
|
}
|
|
4200
4384
|
|
|
4201
4385
|
#setupVideoEvents(container) {
|
|
@@ -4218,8 +4402,10 @@ class FigFillPicker extends HTMLElement {
|
|
|
4218
4402
|
const src = e.detail?.src || preview.src;
|
|
4219
4403
|
if (!src) return;
|
|
4220
4404
|
this.#video.url = src;
|
|
4405
|
+
this.#video.missing = false;
|
|
4221
4406
|
this.#updateVideoPreviewStyle(preview);
|
|
4222
4407
|
preview.play?.();
|
|
4408
|
+
this.#captureVideoPoster(src);
|
|
4223
4409
|
this.#updateSwatch();
|
|
4224
4410
|
this.#emitInput();
|
|
4225
4411
|
});
|
|
@@ -4227,6 +4413,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
4227
4413
|
preview.addEventListener("change", () => {
|
|
4228
4414
|
if (preview.src) return;
|
|
4229
4415
|
this.#video.url = null;
|
|
4416
|
+
this.#revokeVideoPoster();
|
|
4417
|
+
this.#applyDefaultVideo();
|
|
4230
4418
|
this.#updateVideoPreviewStyle(preview);
|
|
4231
4419
|
this.#updateSwatch();
|
|
4232
4420
|
this.#emitInput();
|
|
@@ -4327,20 +4515,81 @@ class FigFillPicker extends HTMLElement {
|
|
|
4327
4515
|
if (ready) {
|
|
4328
4516
|
status.querySelector("span").textContent = "Camera ready";
|
|
4329
4517
|
status.style.display = "none";
|
|
4518
|
+
this.#syncLiveWebcamSwatch(video);
|
|
4330
4519
|
}
|
|
4331
4520
|
};
|
|
4332
4521
|
video.addEventListener("loadedmetadata", updateFrameReadiness);
|
|
4333
4522
|
video.addEventListener("canplay", updateFrameReadiness);
|
|
4334
4523
|
|
|
4524
|
+
const attachPreview = (stream) => {
|
|
4525
|
+
this.#webcam.stream = stream;
|
|
4526
|
+
video.srcObject = stream;
|
|
4527
|
+
video.style.display = "block";
|
|
4528
|
+
updateFrameReadiness();
|
|
4529
|
+
};
|
|
4530
|
+
|
|
4531
|
+
const populateCameras = async (requestId, selectedId) => {
|
|
4532
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
4533
|
+
if (requestId != null && requestId !== this.#webcamRequestId) return;
|
|
4534
|
+
const cameras = devices.filter((d) => d.kind === "videoinput");
|
|
4535
|
+
|
|
4536
|
+
if (cameras.length > 1) {
|
|
4537
|
+
cameraField.style.display = "";
|
|
4538
|
+
let panel = cameraSelect.querySelector(":scope > fig-select-options");
|
|
4539
|
+
if (!panel) {
|
|
4540
|
+
panel = document.createElement("fig-select-options");
|
|
4541
|
+
cameraSelect.append(panel);
|
|
4542
|
+
}
|
|
4543
|
+
panel.replaceChildren();
|
|
4544
|
+
cameras.forEach((cam, i) => {
|
|
4545
|
+
const option = document.createElement("fig-select-option");
|
|
4546
|
+
option.value = cam.deviceId;
|
|
4547
|
+
const label =
|
|
4548
|
+
cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
|
|
4549
|
+
option.textContent = label.replace(
|
|
4550
|
+
/\s*\((?:[0-9a-f]{4}:)*([0-9a-f]{4})\)$/i,
|
|
4551
|
+
(_, id) => {
|
|
4552
|
+
const displayId = /^\d+$/.test(id)
|
|
4553
|
+
? Number.parseInt(id, 10).toString()
|
|
4554
|
+
: id.replace(/^0+/, "") || "0";
|
|
4555
|
+
return ` ${displayId}`;
|
|
4556
|
+
},
|
|
4557
|
+
);
|
|
4558
|
+
panel.append(option);
|
|
4559
|
+
});
|
|
4560
|
+
if (selectedId) cameraSelect.value = selectedId;
|
|
4561
|
+
} else {
|
|
4562
|
+
cameraField.style.display = "none";
|
|
4563
|
+
cameraSelect
|
|
4564
|
+
.querySelector(":scope > fig-select-options")
|
|
4565
|
+
?.replaceChildren();
|
|
4566
|
+
}
|
|
4567
|
+
};
|
|
4568
|
+
|
|
4335
4569
|
const startWebcam = async (deviceId = null) => {
|
|
4336
|
-
this.#
|
|
4570
|
+
const requested = deviceId || this.#webcam.deviceId || null;
|
|
4571
|
+
const existing = this.#webcam.stream;
|
|
4572
|
+
const liveTracks =
|
|
4573
|
+
existing?.getTracks?.().filter((track) => track.readyState !== "ended") ??
|
|
4574
|
+
[];
|
|
4575
|
+
if (existing && liveTracks.length) {
|
|
4576
|
+
const currentId = this.#webcam.deviceId;
|
|
4577
|
+
if (!requested || !currentId || requested === currentId) {
|
|
4578
|
+
attachPreview(existing);
|
|
4579
|
+
this.#emitWebcamStream();
|
|
4580
|
+
populateCameras(null, requested || currentId);
|
|
4581
|
+
return;
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
this.#stopWebcam({ emit: Boolean(existing) });
|
|
4337
4586
|
const requestId = this.#webcamRequestId;
|
|
4338
4587
|
setCaptureReady(false);
|
|
4339
4588
|
status.querySelector("span").textContent = "Starting camera";
|
|
4340
4589
|
status.style.display = "flex";
|
|
4341
4590
|
try {
|
|
4342
4591
|
const constraints = {
|
|
4343
|
-
video:
|
|
4592
|
+
video: requested ? { deviceId: { exact: requested } } : true,
|
|
4344
4593
|
};
|
|
4345
4594
|
|
|
4346
4595
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
@@ -4353,47 +4602,13 @@ class FigFillPicker extends HTMLElement {
|
|
|
4353
4602
|
return;
|
|
4354
4603
|
}
|
|
4355
4604
|
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
if (requestId !== this.#webcamRequestId) return;
|
|
4364
|
-
const cameras = devices.filter((d) => d.kind === "videoinput");
|
|
4365
|
-
|
|
4366
|
-
if (cameras.length > 1) {
|
|
4367
|
-
cameraField.style.display = "";
|
|
4368
|
-
let panel = cameraSelect.querySelector(":scope > fig-select-options");
|
|
4369
|
-
if (!panel) {
|
|
4370
|
-
panel = document.createElement("fig-select-options");
|
|
4371
|
-
cameraSelect.append(panel);
|
|
4372
|
-
}
|
|
4373
|
-
panel.replaceChildren();
|
|
4374
|
-
cameras.forEach((cam, i) => {
|
|
4375
|
-
const option = document.createElement("fig-select-option");
|
|
4376
|
-
option.value = cam.deviceId;
|
|
4377
|
-
const label =
|
|
4378
|
-
cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
|
|
4379
|
-
option.textContent = label.replace(
|
|
4380
|
-
/\s*\((?:[0-9a-f]{4}:)*([0-9a-f]{4})\)$/i,
|
|
4381
|
-
(_, id) => {
|
|
4382
|
-
const displayId = /^\d+$/.test(id)
|
|
4383
|
-
? Number.parseInt(id, 10).toString()
|
|
4384
|
-
: id.replace(/^0+/, "") || "0";
|
|
4385
|
-
return ` ${displayId}`;
|
|
4386
|
-
},
|
|
4387
|
-
);
|
|
4388
|
-
panel.append(option);
|
|
4389
|
-
});
|
|
4390
|
-
if (deviceId) cameraSelect.value = deviceId;
|
|
4391
|
-
} else {
|
|
4392
|
-
cameraField.style.display = "none";
|
|
4393
|
-
cameraSelect
|
|
4394
|
-
.querySelector(":scope > fig-select-options")
|
|
4395
|
-
?.replaceChildren();
|
|
4396
|
-
}
|
|
4605
|
+
const track = stream.getVideoTracks?.()?.[0];
|
|
4606
|
+
this.#webcam.deviceId =
|
|
4607
|
+
requested || track?.getSettings?.()?.deviceId || null;
|
|
4608
|
+
this.#webcam.live = this.#webcamMode() === "live";
|
|
4609
|
+
attachPreview(stream);
|
|
4610
|
+
this.#emitWebcamStream();
|
|
4611
|
+
await populateCameras(requestId, requested);
|
|
4397
4612
|
} catch (err) {
|
|
4398
4613
|
if (requestId !== this.#webcamRequestId) return;
|
|
4399
4614
|
console.error("Webcam error:", err.name, err.message);
|
|
@@ -4423,30 +4638,18 @@ class FigFillPicker extends HTMLElement {
|
|
|
4423
4638
|
cameraSelect.addEventListener("change", (e) => {
|
|
4424
4639
|
const next =
|
|
4425
4640
|
typeof e.detail === "string" ? e.detail : e.target?.value;
|
|
4426
|
-
if (next)
|
|
4641
|
+
if (!next) return;
|
|
4642
|
+
this.#webcam.deviceId = next;
|
|
4643
|
+
startWebcam(next);
|
|
4427
4644
|
});
|
|
4428
4645
|
|
|
4429
4646
|
captureBtn.addEventListener("click", async () => {
|
|
4430
4647
|
if (!this.#webcam.stream) return;
|
|
4431
|
-
|
|
4648
|
+
const snapshot = await this.#snapshotWebcamVideo(video);
|
|
4649
|
+
if (!snapshot) return;
|
|
4432
4650
|
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
canvas.height = video.videoHeight;
|
|
4436
|
-
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
4437
|
-
|
|
4438
|
-
const blob = await new Promise((resolve) =>
|
|
4439
|
-
canvas.toBlob(resolve, "image/png"),
|
|
4440
|
-
);
|
|
4441
|
-
if (!blob) return;
|
|
4442
|
-
|
|
4443
|
-
if (this.#webcam.snapshot?.startsWith("blob:")) {
|
|
4444
|
-
URL.revokeObjectURL(this.#webcam.snapshot);
|
|
4445
|
-
this.#ownedBlobUrls.delete(this.#webcam.snapshot);
|
|
4446
|
-
}
|
|
4447
|
-
this.#webcam.snapshot = URL.createObjectURL(blob);
|
|
4448
|
-
this.#ownedBlobUrls.add(this.#webcam.snapshot);
|
|
4449
|
-
this.#image.url = this.#webcam.snapshot;
|
|
4651
|
+
this.#image.url = snapshot;
|
|
4652
|
+
this.#webcam.live = false;
|
|
4450
4653
|
|
|
4451
4654
|
const imagePreview = this.#dialog.querySelector(
|
|
4452
4655
|
".fig-fill-picker-image-preview",
|
|
@@ -4458,10 +4661,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
4458
4661
|
).some((candidate) => candidate.dataset.tab === "image");
|
|
4459
4662
|
|
|
4460
4663
|
if (hasImageTab) {
|
|
4461
|
-
// Switch to image tab to show result
|
|
4462
4664
|
this.#switchTab("image");
|
|
4463
4665
|
} else {
|
|
4464
|
-
// Webcam-only pickers keep the webcam type and report the snapshot
|
|
4465
4666
|
this.#updateSwatch();
|
|
4466
4667
|
this.#emitInput();
|
|
4467
4668
|
}
|
|
@@ -4830,12 +5031,12 @@ class FigFillPicker extends HTMLElement {
|
|
|
4830
5031
|
case "video":
|
|
4831
5032
|
return {
|
|
4832
5033
|
...base,
|
|
4833
|
-
video:
|
|
5034
|
+
video: this.#videoValue(),
|
|
4834
5035
|
};
|
|
4835
5036
|
case "webcam":
|
|
4836
5037
|
return {
|
|
4837
5038
|
...base,
|
|
4838
|
-
|
|
5039
|
+
webcam: this.#webcamValue(),
|
|
4839
5040
|
};
|
|
4840
5041
|
default:
|
|
4841
5042
|
return { ...base, ...this.#customData[this.#fillType] };
|
|
@@ -4874,6 +5075,15 @@ class FigFillPicker extends HTMLElement {
|
|
|
4874
5075
|
if (wasOpen && this.isConnected) this.#openDialog();
|
|
4875
5076
|
break;
|
|
4876
5077
|
}
|
|
5078
|
+
case "webcam-mode":
|
|
5079
|
+
if (this.#fillType === "webcam") this.#updateSwatch();
|
|
5080
|
+
break;
|
|
5081
|
+
case "default-video":
|
|
5082
|
+
if (this.#fillType === "video") {
|
|
5083
|
+
this.#applyDefaultVideo({ emit: false });
|
|
5084
|
+
this.#updateSwatch();
|
|
5085
|
+
}
|
|
5086
|
+
break;
|
|
4877
5087
|
case "aria-label":
|
|
4878
5088
|
if (this.#dialog) {
|
|
4879
5089
|
this.#dialog.setAttribute("aria-label", this.#triggerLabel());
|