@rogieking/figui3 8.9.15 → 8.9.17
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 +8 -4
- package/.cursor/skills/figui3/SKILL.md +2 -2
- package/README.md +16 -6
- package/components.css +3 -1
- package/dist/components.css +1 -1
- package/dist/fig-editor.js +3 -3
- package/dist/fig.css +1 -1
- package/dist/fig.js +6 -6
- package/fig-editor.js +344 -84
- package/fig.js +144 -29
- 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);
|
|
@@ -1552,6 +1557,9 @@ function parseGradientInterpolationSelectValue(val) {
|
|
|
1552
1557
|
* @attr {boolean} disabled - Whether the picker is disabled
|
|
1553
1558
|
* @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
|
|
1554
1559
|
* @attr {string} dialog-position - Position of the popup (default: "left")
|
|
1560
|
+
* @attr {string} webcam-mode - `live` (default) keeps the camera after close; Capture always writes an image still
|
|
1561
|
+
* @attr {string} default-video - Sample clip URL when Video is selected with no file
|
|
1562
|
+
* @fires webcamstream - `{ stream, deviceId }` when the live camera starts, switches, or is released
|
|
1555
1563
|
*/
|
|
1556
1564
|
let figFillPickerDialogId = 0;
|
|
1557
1565
|
|
|
@@ -1634,8 +1642,24 @@ class FigFillPicker extends HTMLElement {
|
|
|
1634
1642
|
],
|
|
1635
1643
|
};
|
|
1636
1644
|
#image = { url: null, scaleMode: "fill", scale: 50 };
|
|
1637
|
-
#video = {
|
|
1638
|
-
|
|
1645
|
+
#video = {
|
|
1646
|
+
url: null,
|
|
1647
|
+
poster: null,
|
|
1648
|
+
scaleMode: "fill",
|
|
1649
|
+
scale: 50,
|
|
1650
|
+
opacity: 1,
|
|
1651
|
+
missing: true,
|
|
1652
|
+
};
|
|
1653
|
+
#webcam = {
|
|
1654
|
+
stream: null,
|
|
1655
|
+
live: true,
|
|
1656
|
+
snapshot: null,
|
|
1657
|
+
deviceId: null,
|
|
1658
|
+
scaleMode: "fill",
|
|
1659
|
+
scale: 50,
|
|
1660
|
+
opacity: 1,
|
|
1661
|
+
};
|
|
1662
|
+
#webcamPosterStream = null;
|
|
1639
1663
|
|
|
1640
1664
|
// Custom mode slots and data
|
|
1641
1665
|
#customSlots = {};
|
|
@@ -1671,6 +1695,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
1671
1695
|
"disabled",
|
|
1672
1696
|
"alpha",
|
|
1673
1697
|
"mode",
|
|
1698
|
+
"webcam-mode",
|
|
1699
|
+
"default-video",
|
|
1674
1700
|
"aria-label",
|
|
1675
1701
|
"aria-labelledby",
|
|
1676
1702
|
"aria-describedby",
|
|
@@ -1689,7 +1715,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
1689
1715
|
}
|
|
1690
1716
|
|
|
1691
1717
|
disconnectedCallback() {
|
|
1692
|
-
this.#discardDialog();
|
|
1718
|
+
this.#discardDialog({ stopWebcam: true });
|
|
1693
1719
|
this.#cancelFrames();
|
|
1694
1720
|
this.#revokeOwnedBlobUrls();
|
|
1695
1721
|
if (this.#swatch) this.#swatch.removeAttribute("selected");
|
|
@@ -1708,6 +1734,116 @@ class FigFillPicker extends HTMLElement {
|
|
|
1708
1734
|
);
|
|
1709
1735
|
}
|
|
1710
1736
|
|
|
1737
|
+
#webcamMode() {
|
|
1738
|
+
return this.getAttribute("webcam-mode") === "snapshot" ? "snapshot" : "live";
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
#shouldKeepWebcamLive() {
|
|
1742
|
+
return (
|
|
1743
|
+
this.#fillType === "webcam" &&
|
|
1744
|
+
this.#webcamMode() === "live" &&
|
|
1745
|
+
this.#webcam.live !== false
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
#webcamValue() {
|
|
1750
|
+
return {
|
|
1751
|
+
live: this.#webcamMode() === "live" && this.#webcam.live !== false,
|
|
1752
|
+
snapshot: this.#webcam.snapshot ?? null,
|
|
1753
|
+
deviceId: this.#webcam.deviceId ?? null,
|
|
1754
|
+
scaleMode: this.#webcam.scaleMode || "fill",
|
|
1755
|
+
scale: this.#webcam.scale ?? 50,
|
|
1756
|
+
opacity: this.#webcam.opacity ?? 1,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
#videoValue() {
|
|
1761
|
+
const url = this.#video.url ?? null;
|
|
1762
|
+
return {
|
|
1763
|
+
url,
|
|
1764
|
+
poster: this.#video.poster ?? null,
|
|
1765
|
+
scaleMode: this.#video.scaleMode || "fill",
|
|
1766
|
+
scale: this.#video.scale ?? 50,
|
|
1767
|
+
opacity: this.#video.opacity ?? 1,
|
|
1768
|
+
...(url ? {} : { missing: true }),
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
#applyParsedWebcam(parsed) {
|
|
1773
|
+
if (parsed.webcam && typeof parsed.webcam === "object") {
|
|
1774
|
+
const { stream: _ignored, ...rest } = parsed.webcam;
|
|
1775
|
+
Object.assign(this.#webcam, rest);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (parsed.type === "webcam" && parsed.image) {
|
|
1779
|
+
if (parsed.image.url != null) this.#webcam.snapshot = parsed.image.url;
|
|
1780
|
+
if (parsed.image.scaleMode) this.#webcam.scaleMode = parsed.image.scaleMode;
|
|
1781
|
+
if (parsed.image.scale != null) this.#webcam.scale = parsed.image.scale;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
#emitWebcamStream() {
|
|
1786
|
+
this.dispatchEvent(
|
|
1787
|
+
new CustomEvent("webcamstream", {
|
|
1788
|
+
bubbles: true,
|
|
1789
|
+
composed: true,
|
|
1790
|
+
detail: {
|
|
1791
|
+
stream: this.#webcam.stream,
|
|
1792
|
+
deviceId: this.#webcam.deviceId ?? null,
|
|
1793
|
+
},
|
|
1794
|
+
}),
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
get webcamStream() {
|
|
1799
|
+
return this.#webcam.stream;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
releaseWebcam() {
|
|
1803
|
+
this.#stopWebcam();
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
#writeWebcamSnapshot(blob) {
|
|
1807
|
+
if (!blob) return null;
|
|
1808
|
+
if (this.#webcam.snapshot?.startsWith("blob:")) {
|
|
1809
|
+
URL.revokeObjectURL(this.#webcam.snapshot);
|
|
1810
|
+
this.#ownedBlobUrls.delete(this.#webcam.snapshot);
|
|
1811
|
+
}
|
|
1812
|
+
this.#webcam.snapshot = URL.createObjectURL(blob);
|
|
1813
|
+
this.#ownedBlobUrls.add(this.#webcam.snapshot);
|
|
1814
|
+
return this.#webcam.snapshot;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async #snapshotWebcamVideo(video) {
|
|
1818
|
+
if (!video?.videoWidth || !video.videoHeight) return null;
|
|
1819
|
+
const canvas = document.createElement("canvas");
|
|
1820
|
+
canvas.width = video.videoWidth;
|
|
1821
|
+
canvas.height = video.videoHeight;
|
|
1822
|
+
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1823
|
+
const blob = await new Promise((resolve) =>
|
|
1824
|
+
canvas.toBlob(resolve, "image/png"),
|
|
1825
|
+
);
|
|
1826
|
+
return this.#writeWebcamSnapshot(blob);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
#syncLiveWebcamSwatch(video) {
|
|
1830
|
+
if (this.#fillType !== "webcam") return;
|
|
1831
|
+
const stream = this.#webcam.stream;
|
|
1832
|
+
if (!stream) return;
|
|
1833
|
+
if (this.#webcamPosterStream === stream && this.#webcam.snapshot) {
|
|
1834
|
+
this.#updateSwatch();
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
this.#webcamPosterStream = stream;
|
|
1838
|
+
this.#snapshotWebcamVideo(video).then((url) => {
|
|
1839
|
+
if (!url || this.#fillType !== "webcam" || this.#webcam.stream !== stream) {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
this.#updateSwatch();
|
|
1843
|
+
this.#emitInput();
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1711
1847
|
#scheduleFrame(callback) {
|
|
1712
1848
|
const id = requestAnimationFrame(() => {
|
|
1713
1849
|
this.#rafIds.delete(id);
|
|
@@ -1723,12 +1859,17 @@ class FigFillPicker extends HTMLElement {
|
|
|
1723
1859
|
}
|
|
1724
1860
|
|
|
1725
1861
|
#revokeOwnedBlobUrls() {
|
|
1726
|
-
|
|
1862
|
+
// Keep blobs still published on value so fig-input-fill / hosts can paint the swatch.
|
|
1863
|
+
const keep = new Set(
|
|
1864
|
+
[this.#webcam.snapshot, this.#video.poster, this.#image.url].filter(
|
|
1865
|
+
(url) => typeof url === "string" && url.startsWith("blob:"),
|
|
1866
|
+
),
|
|
1867
|
+
);
|
|
1868
|
+
this.#ownedBlobUrls.forEach((url) => {
|
|
1869
|
+
if (!keep.has(url)) URL.revokeObjectURL(url);
|
|
1870
|
+
});
|
|
1727
1871
|
this.#ownedBlobUrls.clear();
|
|
1728
|
-
|
|
1729
|
-
if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
|
|
1730
|
-
this.#webcam.snapshot = null;
|
|
1731
|
-
}
|
|
1872
|
+
keep.forEach((url) => this.#ownedBlobUrls.add(url));
|
|
1732
1873
|
}
|
|
1733
1874
|
|
|
1734
1875
|
#setupTrigger() {
|
|
@@ -1856,7 +1997,11 @@ class FigFillPicker extends HTMLElement {
|
|
|
1856
1997
|
});
|
|
1857
1998
|
}
|
|
1858
1999
|
if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
|
|
1859
|
-
if (parsed.video)
|
|
2000
|
+
if (parsed.video) {
|
|
2001
|
+
this.#video = { ...this.#video, ...parsed.video };
|
|
2002
|
+
this.#video.missing = !this.#video.url;
|
|
2003
|
+
}
|
|
2004
|
+
this.#applyParsedWebcam(parsed);
|
|
1860
2005
|
|
|
1861
2006
|
// Store full parsed data for custom (non-built-in) types
|
|
1862
2007
|
if (parsed.type && !builtinTypes.includes(parsed.type)) {
|
|
@@ -1888,7 +2033,7 @@ class FigFillPicker extends HTMLElement {
|
|
|
1888
2033
|
break;
|
|
1889
2034
|
case "image":
|
|
1890
2035
|
if (this.#image.url) {
|
|
1891
|
-
bg =
|
|
2036
|
+
bg = figEditorCssUrl(this.#image.url);
|
|
1892
2037
|
const sizing = this.#getBackgroundSizing(
|
|
1893
2038
|
this.#image.scaleMode,
|
|
1894
2039
|
this.#image.scale,
|
|
@@ -1900,8 +2045,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
1900
2045
|
}
|
|
1901
2046
|
break;
|
|
1902
2047
|
case "video":
|
|
1903
|
-
if (this.#video.
|
|
1904
|
-
bg =
|
|
2048
|
+
if (this.#video.poster) {
|
|
2049
|
+
bg = figEditorCssUrl(this.#video.poster);
|
|
1905
2050
|
const sizing = this.#getBackgroundSizing(
|
|
1906
2051
|
this.#video.scaleMode,
|
|
1907
2052
|
this.#video.scale,
|
|
@@ -1913,7 +2058,17 @@ class FigFillPicker extends HTMLElement {
|
|
|
1913
2058
|
}
|
|
1914
2059
|
break;
|
|
1915
2060
|
case "webcam":
|
|
1916
|
-
|
|
2061
|
+
if (this.#webcam.snapshot) {
|
|
2062
|
+
bg = figEditorCssUrl(this.#webcam.snapshot);
|
|
2063
|
+
const sizing = this.#getBackgroundSizing(
|
|
2064
|
+
this.#webcam.scaleMode,
|
|
2065
|
+
this.#webcam.scale,
|
|
2066
|
+
);
|
|
2067
|
+
bgSize = sizing.size;
|
|
2068
|
+
bgPosition = sizing.position;
|
|
2069
|
+
} else {
|
|
2070
|
+
bg = "";
|
|
2071
|
+
}
|
|
1917
2072
|
break;
|
|
1918
2073
|
default:
|
|
1919
2074
|
const slot = this.#customSlots[this.#fillType];
|
|
@@ -1995,14 +2150,15 @@ class FigFillPicker extends HTMLElement {
|
|
|
1995
2150
|
}
|
|
1996
2151
|
}
|
|
1997
2152
|
|
|
1998
|
-
#discardDialog() {
|
|
2153
|
+
#discardDialog({ stopWebcam = false } = {}) {
|
|
1999
2154
|
if (this.#teardownColorAreaEvents) {
|
|
2000
2155
|
this.#teardownColorAreaEvents();
|
|
2001
2156
|
this.#teardownColorAreaEvents = null;
|
|
2002
2157
|
}
|
|
2003
2158
|
this.#gradientInterpolationOpenObserver?.disconnect();
|
|
2004
2159
|
this.#gradientInterpolationOpenObserver = null;
|
|
2005
|
-
this.#
|
|
2160
|
+
if (!stopWebcam && this.#shouldKeepWebcamLive()) this.#detachWebcamPreview();
|
|
2161
|
+
else this.#stopWebcam();
|
|
2006
2162
|
if (!this.#dialog) return;
|
|
2007
2163
|
this.#restoreCustomSlotContent();
|
|
2008
2164
|
this.#dialog.remove();
|
|
@@ -2018,16 +2174,27 @@ class FigFillPicker extends HTMLElement {
|
|
|
2018
2174
|
this.#syncTriggerA11y();
|
|
2019
2175
|
}
|
|
2020
2176
|
|
|
2021
|
-
#
|
|
2177
|
+
#detachWebcamPreview() {
|
|
2178
|
+
this.#webcamRequestId += 1;
|
|
2179
|
+
const video = this.#dialog?.querySelector(
|
|
2180
|
+
".fig-fill-picker-webcam-video",
|
|
2181
|
+
);
|
|
2182
|
+
if (video) video.srcObject = null;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
#stopWebcam({ emit = true } = {}) {
|
|
2022
2186
|
this.#webcamRequestId += 1;
|
|
2187
|
+
const hadStream = Boolean(this.#webcam.stream);
|
|
2023
2188
|
if (this.#webcam.stream) {
|
|
2024
2189
|
this.#webcam.stream.getTracks().forEach((track) => track.stop());
|
|
2025
2190
|
this.#webcam.stream = null;
|
|
2026
2191
|
}
|
|
2192
|
+
this.#webcamPosterStream = null;
|
|
2027
2193
|
const video = this.#dialog?.querySelector(
|
|
2028
2194
|
".fig-fill-picker-webcam-video",
|
|
2029
2195
|
);
|
|
2030
2196
|
if (video) video.srcObject = null;
|
|
2197
|
+
if (hadStream && emit) this.#emitWebcamStream();
|
|
2031
2198
|
}
|
|
2032
2199
|
|
|
2033
2200
|
#createDialog() {
|
|
@@ -2186,7 +2353,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
2186
2353
|
|
|
2187
2354
|
const onDialogClose = () => {
|
|
2188
2355
|
if (this.#swatch) this.#swatch.removeAttribute("selected");
|
|
2189
|
-
this.#
|
|
2356
|
+
if (this.#shouldKeepWebcamLive()) this.#detachWebcamPreview();
|
|
2357
|
+
else this.#stopWebcam();
|
|
2190
2358
|
const closingValue = JSON.stringify(this.value);
|
|
2191
2359
|
if (this.#lastChangeValue !== null && this.#lastChangeValue !== closingValue) {
|
|
2192
2360
|
this.#emitChange();
|
|
@@ -2287,7 +2455,11 @@ class FigFillPicker extends HTMLElement {
|
|
|
2287
2455
|
});
|
|
2288
2456
|
}
|
|
2289
2457
|
|
|
2290
|
-
if (tabName === "
|
|
2458
|
+
if (tabName === "video") this.#applyDefaultVideo();
|
|
2459
|
+
if (tabName === "webcam") {
|
|
2460
|
+
this.#webcam.live = this.#webcamMode() === "live";
|
|
2461
|
+
this.#webcamStart?.(this.#webcam.deviceId);
|
|
2462
|
+
}
|
|
2291
2463
|
|
|
2292
2464
|
this.#updateSwatch();
|
|
2293
2465
|
if (emit) this.#emitInput();
|
|
@@ -4196,6 +4368,68 @@ class FigFillPicker extends HTMLElement {
|
|
|
4196
4368
|
container.replaceChildren(header, preview);
|
|
4197
4369
|
|
|
4198
4370
|
this.#setupVideoEvents(container);
|
|
4371
|
+
this.#applyDefaultVideo();
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
#revokeVideoPoster() {
|
|
4375
|
+
if (this.#video.poster?.startsWith("blob:")) {
|
|
4376
|
+
URL.revokeObjectURL(this.#video.poster);
|
|
4377
|
+
this.#ownedBlobUrls.delete(this.#video.poster);
|
|
4378
|
+
}
|
|
4379
|
+
this.#video.poster = null;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
#applyDefaultVideo({ emit = true } = {}) {
|
|
4383
|
+
if (this.#video.url) {
|
|
4384
|
+
this.#video.missing = false;
|
|
4385
|
+
if (!this.#video.poster) this.#captureVideoPoster(this.#video.url, { emit });
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4388
|
+
const fallback = this.getAttribute("default-video");
|
|
4389
|
+
if (!fallback) {
|
|
4390
|
+
this.#video.missing = true;
|
|
4391
|
+
return;
|
|
4392
|
+
}
|
|
4393
|
+
this.#video.url = fallback;
|
|
4394
|
+
this.#video.missing = false;
|
|
4395
|
+
const preview = this.#dialog?.querySelector(
|
|
4396
|
+
".fig-fill-picker-video-preview",
|
|
4397
|
+
);
|
|
4398
|
+
if (preview) this.#updateVideoPreviewStyle(preview);
|
|
4399
|
+
this.#captureVideoPoster(fallback, { emit });
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
async #captureVideoPoster(src, { emit = true } = {}) {
|
|
4403
|
+
if (!src) return;
|
|
4404
|
+
const video = document.createElement("video");
|
|
4405
|
+
video.muted = true;
|
|
4406
|
+
video.playsInline = true;
|
|
4407
|
+
video.preload = "auto";
|
|
4408
|
+
video.crossOrigin = "anonymous";
|
|
4409
|
+
try {
|
|
4410
|
+
await new Promise((resolve, reject) => {
|
|
4411
|
+
const fail = () => reject(new Error("video poster failed"));
|
|
4412
|
+
video.addEventListener("error", fail, { once: true });
|
|
4413
|
+
video.addEventListener("loadeddata", resolve, { once: true });
|
|
4414
|
+
video.src = src;
|
|
4415
|
+
});
|
|
4416
|
+
if (!video.videoWidth || !video.videoHeight) return;
|
|
4417
|
+
const canvas = document.createElement("canvas");
|
|
4418
|
+
canvas.width = video.videoWidth;
|
|
4419
|
+
canvas.height = video.videoHeight;
|
|
4420
|
+
canvas.getContext("2d").drawImage(video, 0, 0);
|
|
4421
|
+
const blob = await new Promise((resolve) =>
|
|
4422
|
+
canvas.toBlob(resolve, "image/jpeg", 0.85),
|
|
4423
|
+
);
|
|
4424
|
+
if (!blob) return;
|
|
4425
|
+
this.#revokeVideoPoster();
|
|
4426
|
+
this.#video.poster = URL.createObjectURL(blob);
|
|
4427
|
+
this.#ownedBlobUrls.add(this.#video.poster);
|
|
4428
|
+
this.#updateSwatch();
|
|
4429
|
+
if (emit) this.#emitInput();
|
|
4430
|
+
} catch {
|
|
4431
|
+
// Cross-origin or decode failures leave the swatch empty until a poster exists.
|
|
4432
|
+
}
|
|
4199
4433
|
}
|
|
4200
4434
|
|
|
4201
4435
|
#setupVideoEvents(container) {
|
|
@@ -4218,8 +4452,10 @@ class FigFillPicker extends HTMLElement {
|
|
|
4218
4452
|
const src = e.detail?.src || preview.src;
|
|
4219
4453
|
if (!src) return;
|
|
4220
4454
|
this.#video.url = src;
|
|
4455
|
+
this.#video.missing = false;
|
|
4221
4456
|
this.#updateVideoPreviewStyle(preview);
|
|
4222
4457
|
preview.play?.();
|
|
4458
|
+
this.#captureVideoPoster(src);
|
|
4223
4459
|
this.#updateSwatch();
|
|
4224
4460
|
this.#emitInput();
|
|
4225
4461
|
});
|
|
@@ -4227,6 +4463,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
4227
4463
|
preview.addEventListener("change", () => {
|
|
4228
4464
|
if (preview.src) return;
|
|
4229
4465
|
this.#video.url = null;
|
|
4466
|
+
this.#revokeVideoPoster();
|
|
4467
|
+
this.#applyDefaultVideo();
|
|
4230
4468
|
this.#updateVideoPreviewStyle(preview);
|
|
4231
4469
|
this.#updateSwatch();
|
|
4232
4470
|
this.#emitInput();
|
|
@@ -4327,20 +4565,81 @@ class FigFillPicker extends HTMLElement {
|
|
|
4327
4565
|
if (ready) {
|
|
4328
4566
|
status.querySelector("span").textContent = "Camera ready";
|
|
4329
4567
|
status.style.display = "none";
|
|
4568
|
+
this.#syncLiveWebcamSwatch(video);
|
|
4330
4569
|
}
|
|
4331
4570
|
};
|
|
4332
4571
|
video.addEventListener("loadedmetadata", updateFrameReadiness);
|
|
4333
4572
|
video.addEventListener("canplay", updateFrameReadiness);
|
|
4334
4573
|
|
|
4574
|
+
const attachPreview = (stream) => {
|
|
4575
|
+
this.#webcam.stream = stream;
|
|
4576
|
+
video.srcObject = stream;
|
|
4577
|
+
video.style.display = "block";
|
|
4578
|
+
updateFrameReadiness();
|
|
4579
|
+
};
|
|
4580
|
+
|
|
4581
|
+
const populateCameras = async (requestId, selectedId) => {
|
|
4582
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
4583
|
+
if (requestId != null && requestId !== this.#webcamRequestId) return;
|
|
4584
|
+
const cameras = devices.filter((d) => d.kind === "videoinput");
|
|
4585
|
+
|
|
4586
|
+
if (cameras.length > 1) {
|
|
4587
|
+
cameraField.style.display = "";
|
|
4588
|
+
let panel = cameraSelect.querySelector(":scope > fig-select-options");
|
|
4589
|
+
if (!panel) {
|
|
4590
|
+
panel = document.createElement("fig-select-options");
|
|
4591
|
+
cameraSelect.append(panel);
|
|
4592
|
+
}
|
|
4593
|
+
panel.replaceChildren();
|
|
4594
|
+
cameras.forEach((cam, i) => {
|
|
4595
|
+
const option = document.createElement("fig-select-option");
|
|
4596
|
+
option.value = cam.deviceId;
|
|
4597
|
+
const label =
|
|
4598
|
+
cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
|
|
4599
|
+
option.textContent = label.replace(
|
|
4600
|
+
/\s*\((?:[0-9a-f]{4}:)*([0-9a-f]{4})\)$/i,
|
|
4601
|
+
(_, id) => {
|
|
4602
|
+
const displayId = /^\d+$/.test(id)
|
|
4603
|
+
? Number.parseInt(id, 10).toString()
|
|
4604
|
+
: id.replace(/^0+/, "") || "0";
|
|
4605
|
+
return ` ${displayId}`;
|
|
4606
|
+
},
|
|
4607
|
+
);
|
|
4608
|
+
panel.append(option);
|
|
4609
|
+
});
|
|
4610
|
+
if (selectedId) cameraSelect.value = selectedId;
|
|
4611
|
+
} else {
|
|
4612
|
+
cameraField.style.display = "none";
|
|
4613
|
+
cameraSelect
|
|
4614
|
+
.querySelector(":scope > fig-select-options")
|
|
4615
|
+
?.replaceChildren();
|
|
4616
|
+
}
|
|
4617
|
+
};
|
|
4618
|
+
|
|
4335
4619
|
const startWebcam = async (deviceId = null) => {
|
|
4336
|
-
this.#
|
|
4620
|
+
const requested = deviceId || this.#webcam.deviceId || null;
|
|
4621
|
+
const existing = this.#webcam.stream;
|
|
4622
|
+
const liveTracks =
|
|
4623
|
+
existing?.getTracks?.().filter((track) => track.readyState !== "ended") ??
|
|
4624
|
+
[];
|
|
4625
|
+
if (existing && liveTracks.length) {
|
|
4626
|
+
const currentId = this.#webcam.deviceId;
|
|
4627
|
+
if (!requested || !currentId || requested === currentId) {
|
|
4628
|
+
attachPreview(existing);
|
|
4629
|
+
this.#emitWebcamStream();
|
|
4630
|
+
populateCameras(null, requested || currentId);
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4635
|
+
this.#stopWebcam({ emit: Boolean(existing) });
|
|
4337
4636
|
const requestId = this.#webcamRequestId;
|
|
4338
4637
|
setCaptureReady(false);
|
|
4339
4638
|
status.querySelector("span").textContent = "Starting camera";
|
|
4340
4639
|
status.style.display = "flex";
|
|
4341
4640
|
try {
|
|
4342
4641
|
const constraints = {
|
|
4343
|
-
video:
|
|
4642
|
+
video: requested ? { deviceId: { exact: requested } } : true,
|
|
4344
4643
|
};
|
|
4345
4644
|
|
|
4346
4645
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
@@ -4353,47 +4652,13 @@ class FigFillPicker extends HTMLElement {
|
|
|
4353
4652
|
return;
|
|
4354
4653
|
}
|
|
4355
4654
|
|
|
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
|
-
}
|
|
4655
|
+
const track = stream.getVideoTracks?.()?.[0];
|
|
4656
|
+
this.#webcam.deviceId =
|
|
4657
|
+
requested || track?.getSettings?.()?.deviceId || null;
|
|
4658
|
+
this.#webcam.live = this.#webcamMode() === "live";
|
|
4659
|
+
attachPreview(stream);
|
|
4660
|
+
this.#emitWebcamStream();
|
|
4661
|
+
await populateCameras(requestId, requested);
|
|
4397
4662
|
} catch (err) {
|
|
4398
4663
|
if (requestId !== this.#webcamRequestId) return;
|
|
4399
4664
|
console.error("Webcam error:", err.name, err.message);
|
|
@@ -4423,30 +4688,18 @@ class FigFillPicker extends HTMLElement {
|
|
|
4423
4688
|
cameraSelect.addEventListener("change", (e) => {
|
|
4424
4689
|
const next =
|
|
4425
4690
|
typeof e.detail === "string" ? e.detail : e.target?.value;
|
|
4426
|
-
if (next)
|
|
4691
|
+
if (!next) return;
|
|
4692
|
+
this.#webcam.deviceId = next;
|
|
4693
|
+
startWebcam(next);
|
|
4427
4694
|
});
|
|
4428
4695
|
|
|
4429
4696
|
captureBtn.addEventListener("click", async () => {
|
|
4430
4697
|
if (!this.#webcam.stream) return;
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
const canvas = document.createElement("canvas");
|
|
4434
|
-
canvas.width = video.videoWidth;
|
|
4435
|
-
canvas.height = video.videoHeight;
|
|
4436
|
-
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
4698
|
+
const snapshot = await this.#snapshotWebcamVideo(video);
|
|
4699
|
+
if (!snapshot) return;
|
|
4437
4700
|
|
|
4438
|
-
|
|
4439
|
-
|
|
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;
|
|
4701
|
+
this.#image.url = snapshot;
|
|
4702
|
+
this.#webcam.live = false;
|
|
4450
4703
|
|
|
4451
4704
|
const imagePreview = this.#dialog.querySelector(
|
|
4452
4705
|
".fig-fill-picker-image-preview",
|
|
@@ -4458,10 +4711,8 @@ class FigFillPicker extends HTMLElement {
|
|
|
4458
4711
|
).some((candidate) => candidate.dataset.tab === "image");
|
|
4459
4712
|
|
|
4460
4713
|
if (hasImageTab) {
|
|
4461
|
-
// Switch to image tab to show result
|
|
4462
4714
|
this.#switchTab("image");
|
|
4463
4715
|
} else {
|
|
4464
|
-
// Webcam-only pickers keep the webcam type and report the snapshot
|
|
4465
4716
|
this.#updateSwatch();
|
|
4466
4717
|
this.#emitInput();
|
|
4467
4718
|
}
|
|
@@ -4830,12 +5081,12 @@ class FigFillPicker extends HTMLElement {
|
|
|
4830
5081
|
case "video":
|
|
4831
5082
|
return {
|
|
4832
5083
|
...base,
|
|
4833
|
-
video:
|
|
5084
|
+
video: this.#videoValue(),
|
|
4834
5085
|
};
|
|
4835
5086
|
case "webcam":
|
|
4836
5087
|
return {
|
|
4837
5088
|
...base,
|
|
4838
|
-
|
|
5089
|
+
webcam: this.#webcamValue(),
|
|
4839
5090
|
};
|
|
4840
5091
|
default:
|
|
4841
5092
|
return { ...base, ...this.#customData[this.#fillType] };
|
|
@@ -4874,6 +5125,15 @@ class FigFillPicker extends HTMLElement {
|
|
|
4874
5125
|
if (wasOpen && this.isConnected) this.#openDialog();
|
|
4875
5126
|
break;
|
|
4876
5127
|
}
|
|
5128
|
+
case "webcam-mode":
|
|
5129
|
+
if (this.#fillType === "webcam") this.#updateSwatch();
|
|
5130
|
+
break;
|
|
5131
|
+
case "default-video":
|
|
5132
|
+
if (this.#fillType === "video") {
|
|
5133
|
+
this.#applyDefaultVideo({ emit: false });
|
|
5134
|
+
this.#updateSwatch();
|
|
5135
|
+
}
|
|
5136
|
+
break;
|
|
4877
5137
|
case "aria-label":
|
|
4878
5138
|
if (this.#dialog) {
|
|
4879
5139
|
this.#dialog.setAttribute("aria-label", this.#triggerLabel());
|