@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/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 = normalizeGradientConfig(gradient);
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 = { url: null, scaleMode: "fill", scale: 50 };
1638
- #webcam = { stream: null, snapshot: null };
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
- this.#ownedBlobUrls.forEach((url) => URL.revokeObjectURL(url));
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
- if (this.#webcam.snapshot?.startsWith("blob:")) {
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 = normalizeGradientConfig({
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) this.#video = { ...this.#video, ...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 = `url(${this.#image.url})`;
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.url) {
1904
- bg = `url(${this.#video.url})`;
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
- bg = this.#webcam.snapshot ? `url(${this.#webcam.snapshot})` : "";
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.#stopWebcam();
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
- #stopWebcam() {
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.#stopWebcam();
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 === "webcam") this.#webcamStart?.();
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
- const interpolationModes = figEditorCreateElement(
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 = normalizeGradientConfig({
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 = normalizeGradientConfig(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.#stopWebcam();
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: deviceId ? { deviceId: { exact: deviceId } } : true,
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
- 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
- }
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) startWebcam(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
- if (!video.videoWidth || !video.videoHeight) return;
4648
+ const snapshot = await this.#snapshotWebcamVideo(video);
4649
+ if (!snapshot) return;
4432
4650
 
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);
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: { ...this.#video },
5034
+ video: this.#videoValue(),
4834
5035
  };
4835
5036
  case "webcam":
4836
5037
  return {
4837
5038
  ...base,
4838
- image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
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());