@rogieking/figui3 8.9.16 → 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/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 = { url: null, scaleMode: "fill", scale: 50 };
1638
- #webcam = { stream: null, snapshot: null };
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
- this.#ownedBlobUrls.forEach((url) => URL.revokeObjectURL(url));
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
- if (this.#webcam.snapshot?.startsWith("blob:")) {
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) this.#video = { ...this.#video, ...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 = `url(${this.#image.url})`;
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.url) {
1904
- bg = `url(${this.#video.url})`;
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
- bg = this.#webcam.snapshot ? `url(${this.#webcam.snapshot})` : "";
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.#stopWebcam();
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
- #stopWebcam() {
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.#stopWebcam();
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 === "webcam") this.#webcamStart?.();
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.#stopWebcam();
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: deviceId ? { deviceId: { exact: deviceId } } : true,
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
- this.#webcam.stream = stream;
4357
- video.srcObject = stream;
4358
- video.style.display = "block";
4359
- updateFrameReadiness();
4360
-
4361
- // Enumerate cameras
4362
- const devices = await navigator.mediaDevices.enumerateDevices();
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) startWebcam(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
- if (!video.videoWidth || !video.videoHeight) return;
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
- 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;
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: { ...this.#video },
5084
+ video: this.#videoValue(),
4834
5085
  };
4835
5086
  case "webcam":
4836
5087
  return {
4837
5088
  ...base,
4838
- image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
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());