@rogieking/figui3 8.9.19 → 8.9.20

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
@@ -1668,6 +1668,8 @@ class FigFillPicker extends HTMLElement {
1668
1668
  opacity: 1,
1669
1669
  };
1670
1670
  #webcamPosterStream = null;
1671
+ #webcamSnapshotting = false;
1672
+ #webcamImageCapture = null;
1671
1673
 
1672
1674
  // Custom mode slots and data
1673
1675
  #customSlots = {};
@@ -1779,8 +1781,13 @@ class FigFillPicker extends HTMLElement {
1779
1781
 
1780
1782
  #applyParsedWebcam(parsed) {
1781
1783
  if (parsed.webcam && typeof parsed.webcam === "object") {
1782
- const { stream: _ignored, ...rest } = parsed.webcam;
1784
+ const { stream: _ignored, snapshot, ...rest } = parsed.webcam;
1783
1785
  Object.assign(this.#webcam, rest);
1786
+ // Host writes can arrive as `{ snapshot: null }` while a live frame is
1787
+ // in flight — keep the captured still so the closed swatch can paint it.
1788
+ if (typeof snapshot === "string" && snapshot) {
1789
+ this.#webcam.snapshot = snapshot;
1790
+ }
1784
1791
  return;
1785
1792
  }
1786
1793
  if (parsed.type === "webcam" && parsed.image) {
@@ -1822,12 +1829,96 @@ class FigFillPicker extends HTMLElement {
1822
1829
  return this.#webcam.snapshot;
1823
1830
  }
1824
1831
 
1832
+ #webcamFrameIsBlank(ctx, width, height) {
1833
+ if (typeof ctx.getImageData !== "function") return false;
1834
+ let data;
1835
+ try {
1836
+ data = ctx.getImageData(0, 0, width, height).data;
1837
+ } catch {
1838
+ return false;
1839
+ }
1840
+ const step = Math.max(1, Math.floor((width * height) / 256));
1841
+ let max = 0;
1842
+ let lit = 0;
1843
+ let samples = 0;
1844
+ for (let i = 0; i < data.length; i += step * 4) {
1845
+ const peak = Math.max(data[i], data[i + 1], data[i + 2]);
1846
+ max = Math.max(max, peak);
1847
+ samples += 1;
1848
+ if (peak > 24) lit += 1;
1849
+ }
1850
+ if (!samples) return true;
1851
+ // Built-in cams often emit near-black boot frames with a bit of noise.
1852
+ return max <= 40 || lit / samples < 0.04;
1853
+ }
1854
+
1855
+ async #webcamFrameSources(video) {
1856
+ const sources = [];
1857
+ const track = this.#webcam.stream?.getVideoTracks?.()?.[0];
1858
+ if (track && typeof ImageCapture === "function") {
1859
+ try {
1860
+ if (this.#webcamImageCapture?.track !== track) {
1861
+ this.#webcamImageCapture = new ImageCapture(track);
1862
+ }
1863
+ sources.push(await this.#webcamImageCapture.grabFrame());
1864
+ } catch {
1865
+ this.#webcamImageCapture = null;
1866
+ }
1867
+ }
1868
+ if (typeof createImageBitmap === "function") {
1869
+ try {
1870
+ sources.push(await createImageBitmap(video));
1871
+ } catch {
1872
+ /* video not readable yet */
1873
+ }
1874
+ }
1875
+ sources.push(video);
1876
+ return sources.filter(Boolean);
1877
+ }
1878
+
1879
+ async #paintWebcamFrame(ctx, video, width, height) {
1880
+ const sources = await this.#webcamFrameSources(video);
1881
+ for (const source of sources) {
1882
+ try {
1883
+ ctx.drawImage(source, 0, 0, width, height);
1884
+ } catch {
1885
+ continue;
1886
+ } finally {
1887
+ source.close?.();
1888
+ }
1889
+ if (!this.#webcamFrameIsBlank(ctx, width, height)) return true;
1890
+ }
1891
+ return false;
1892
+ }
1893
+
1894
+ #waitForNextVideoFrame(video) {
1895
+ return new Promise((resolve) => {
1896
+ if (typeof video.requestVideoFrameCallback === "function") {
1897
+ const timer = setTimeout(() => resolve(false), 400);
1898
+ video.requestVideoFrameCallback(() => {
1899
+ clearTimeout(timer);
1900
+ resolve(true);
1901
+ });
1902
+ return;
1903
+ }
1904
+ setTimeout(() => resolve(false), 200);
1905
+ });
1906
+ }
1907
+
1825
1908
  async #snapshotWebcamVideo(video) {
1826
1909
  if (!video?.videoWidth || !video.videoHeight) return null;
1827
1910
  const canvas = document.createElement("canvas");
1828
1911
  canvas.width = video.videoWidth;
1829
1912
  canvas.height = video.videoHeight;
1830
- canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
1913
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
1914
+ if (!ctx) return null;
1915
+ const painted = await this.#paintWebcamFrame(
1916
+ ctx,
1917
+ video,
1918
+ canvas.width,
1919
+ canvas.height,
1920
+ );
1921
+ if (!painted) return null;
1831
1922
  const blob = await new Promise((resolve) =>
1832
1923
  canvas.toBlob(resolve, "image/png"),
1833
1924
  );
@@ -1842,14 +1933,34 @@ class FigFillPicker extends HTMLElement {
1842
1933
  this.#updateSwatch();
1843
1934
  return;
1844
1935
  }
1845
- this.#webcamPosterStream = stream;
1846
- this.#snapshotWebcamVideo(video).then((url) => {
1936
+ if (this.#webcamSnapshotting) return;
1937
+ this.#webcamSnapshotting = true;
1938
+
1939
+ const finish = (url) => {
1940
+ this.#webcamSnapshotting = false;
1847
1941
  if (!url || this.#fillType !== "webcam" || this.#webcam.stream !== stream) {
1848
1942
  return;
1849
1943
  }
1944
+ this.#webcamPosterStream = stream;
1850
1945
  this.#updateSwatch();
1851
1946
  this.#emitInput();
1852
- });
1947
+ };
1948
+
1949
+ const attempt = async () => {
1950
+ const url = await this.#snapshotWebcamVideo(video);
1951
+ if (url) {
1952
+ finish(url);
1953
+ return;
1954
+ }
1955
+ if (this.#fillType !== "webcam" || this.#webcam.stream !== stream) {
1956
+ this.#webcamSnapshotting = false;
1957
+ return;
1958
+ }
1959
+ await this.#waitForNextVideoFrame(video);
1960
+ return attempt();
1961
+ };
1962
+
1963
+ attempt();
1853
1964
  }
1854
1965
 
1855
1966
  #scheduleFrame(callback) {
@@ -2202,6 +2313,8 @@ class FigFillPicker extends HTMLElement {
2202
2313
  this.#webcam.stream = null;
2203
2314
  }
2204
2315
  this.#webcamPosterStream = null;
2316
+ this.#webcamSnapshotting = false;
2317
+ this.#webcamImageCapture = null;
2205
2318
  const video = this.#dialog?.querySelector(
2206
2319
  ".fig-fill-picker-webcam-video",
2207
2320
  );
@@ -4524,11 +4637,16 @@ class FigFillPicker extends HTMLElement {
4524
4637
  };
4525
4638
  video.addEventListener("loadedmetadata", updateFrameReadiness);
4526
4639
  video.addEventListener("canplay", updateFrameReadiness);
4640
+ video.addEventListener("playing", updateFrameReadiness);
4527
4641
 
4528
4642
  const attachPreview = (stream) => {
4529
4643
  this.#webcam.stream = stream;
4530
4644
  video.srcObject = stream;
4645
+ video.muted = true;
4646
+ video.playsInline = true;
4531
4647
  video.style.display = "block";
4648
+ const play = video.play?.();
4649
+ if (play?.catch) play.catch(() => {});
4532
4650
  updateFrameReadiness();
4533
4651
  };
4534
4652
 
package/fig-lab.css CHANGED
@@ -349,6 +349,7 @@ fig-content:has(> propskit-group[name]:last-child) {
349
349
  :is(
350
350
  propskit-switch,
351
351
  propskit-color,
352
+ propskit-fill,
352
353
  propskit-gradient,
353
354
  propskit-select,
354
355
  propskit-text,
@@ -372,6 +373,7 @@ fig-content:has(> propskit-group[name]:last-child) {
372
373
  :is(
373
374
  propskit-switch,
374
375
  propskit-color,
376
+ propskit-fill,
375
377
  propskit-gradient,
376
378
  propskit-text,
377
379
  propskit-number,
@@ -472,7 +474,8 @@ propskit-switch {
472
474
  }
473
475
  }
474
476
 
475
- propskit-color {
477
+ propskit-color,
478
+ propskit-fill {
476
479
  --propskit-color-height: 1.5rem;
477
480
  --propskit-color-vertical-padding: var(--spacer-1);
478
481
  --propskit-color-horizontal-padding: var(--spacer-3);
@@ -551,16 +554,14 @@ propskit-color {
551
554
  width: 33%;
552
555
  min-width: 0;
553
556
  margin-left: auto;
557
+ overflow: hidden;
558
+ contain: paint;
554
559
  --width: 100%;
555
560
  --height: var(--propskit-color-height);
556
561
  --size: var(--propskit-color-height);
557
562
  --border-radius: var(--radius-medium);
558
563
  height: var(--propskit-color-height);
559
564
  border-radius: var(--radius-medium);
560
-
561
- &:hover {
562
- outline: none !important;
563
- }
564
565
  }
565
566
  }
566
567
  }
@@ -652,8 +653,22 @@ propskit-gradient {
652
653
  min-width: 0;
653
654
  margin-left: auto;
654
655
  border-radius: var(--radius-medium);
656
+ }
657
+ }
658
+ }
655
659
 
656
- &:hover {
660
+ :is(propskit-color, propskit-fill, propskit-gradient) {
661
+ fig-field:not([direction="vertical"]) {
662
+ outline: none;
663
+
664
+ &:focus-within {
665
+ outline: var(--figma-focus-outline);
666
+ outline-offset: var(--figma-focus-outline-offset);
667
+ }
668
+
669
+ :is(fig-swatch, fig-input-gradient, fig-input-fill, fig-fill-picker) {
670
+ &:is(:hover, :focus, :focus-visible, :focus-within),
671
+ &.has-popup-open {
657
672
  outline: none !important;
658
673
  }
659
674
  }
@@ -1341,6 +1356,7 @@ propskit-slider {
1341
1356
  :is(
1342
1357
  propskit-switch,
1343
1358
  propskit-color,
1359
+ propskit-fill,
1344
1360
  propskit-gradient,
1345
1361
  propskit-select,
1346
1362
  propskit-text,
@@ -1365,6 +1381,7 @@ propskit-slider {
1365
1381
  :is(
1366
1382
  propskit-switch,
1367
1383
  propskit-color,
1384
+ propskit-fill,
1368
1385
  propskit-gradient,
1369
1386
  propskit-select,
1370
1387
  propskit-text,