@rogieking/figui3 8.9.19 → 8.9.21

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) {
@@ -2117,6 +2228,8 @@ class FigFillPicker extends HTMLElement {
2117
2228
  if (this.#isDisabled()) return;
2118
2229
  if (!this.#dialog) {
2119
2230
  this.#createDialog();
2231
+ } else {
2232
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
2120
2233
  }
2121
2234
 
2122
2235
  this.#valueAtOpen = JSON.stringify(this.value);
@@ -2202,6 +2315,8 @@ class FigFillPicker extends HTMLElement {
2202
2315
  this.#webcam.stream = null;
2203
2316
  }
2204
2317
  this.#webcamPosterStream = null;
2318
+ this.#webcamSnapshotting = false;
2319
+ this.#webcamImageCapture = null;
2205
2320
  const video = this.#dialog?.querySelector(
2206
2321
  ".fig-fill-picker-webcam-video",
2207
2322
  );
@@ -4524,11 +4639,16 @@ class FigFillPicker extends HTMLElement {
4524
4639
  };
4525
4640
  video.addEventListener("loadedmetadata", updateFrameReadiness);
4526
4641
  video.addEventListener("canplay", updateFrameReadiness);
4642
+ video.addEventListener("playing", updateFrameReadiness);
4527
4643
 
4528
4644
  const attachPreview = (stream) => {
4529
4645
  this.#webcam.stream = stream;
4530
4646
  video.srcObject = stream;
4647
+ video.muted = true;
4648
+ video.playsInline = true;
4531
4649
  video.style.display = "block";
4650
+ const play = video.play?.();
4651
+ if (play?.catch) play.catch(() => {});
4532
4652
  updateFrameReadiness();
4533
4653
  };
4534
4654
 
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,11 +653,46 @@ 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 {
657
- outline: none !important;
658
- }
660
+ :is(propskit-color, propskit-fill, propskit-gradient) {
661
+ outline: none;
662
+
663
+ fig-field:not([direction="vertical"]) {
664
+ outline: none;
665
+ }
666
+
667
+ &:focus-within fig-field:not([direction="vertical"]),
668
+ &.has-popup-open fig-field:not([direction="vertical"]) {
669
+ outline: var(--figma-focus-outline);
670
+ outline-offset: var(--figma-focus-outline-offset);
671
+ }
672
+
673
+ /* Same split as fig-input-gradient: chrome outlines, inner focus does not. */
674
+ :is(fig-swatch, fig-chit, fig-input-gradient, fig-input-fill, fig-fill-picker) {
675
+ --selected: 0 !important;
676
+
677
+ &:hover,
678
+ &:focus,
679
+ &:active {
680
+ outline: 0 !important;
681
+ }
682
+
683
+ &:focus-within:not(:focus) {
684
+ outline: 0 !important;
659
685
  }
686
+
687
+ &:focus-within:not(:focus) :is(input, fig-swatch, fig-chit) {
688
+ outline: 0 !important;
689
+ outline-offset: 0 !important;
690
+ }
691
+ }
692
+
693
+ input[type="color"]:is(:focus, :focus-visible) {
694
+ outline: 0 !important;
695
+ outline-offset: 0 !important;
660
696
  }
661
697
  }
662
698
 
@@ -1341,6 +1377,7 @@ propskit-slider {
1341
1377
  :is(
1342
1378
  propskit-switch,
1343
1379
  propskit-color,
1380
+ propskit-fill,
1344
1381
  propskit-gradient,
1345
1382
  propskit-select,
1346
1383
  propskit-text,
@@ -1365,6 +1402,7 @@ propskit-slider {
1365
1402
  :is(
1366
1403
  propskit-switch,
1367
1404
  propskit-color,
1405
+ propskit-fill,
1368
1406
  propskit-gradient,
1369
1407
  propskit-select,
1370
1408
  propskit-text,