@rogieking/figui3 8.9.18 → 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.css CHANGED
@@ -174,8 +174,10 @@ fig-fill-picker {
174
174
  display: block;
175
175
  }
176
176
  }
177
- fig-preview{
178
- overflow: visible;
177
+ .fig-fill-picker-tab:is([data-tab="solid"], [data-tab="gradient"]) {
178
+ fig-preview {
179
+ overflow: visible;
180
+ }
179
181
  }
180
182
 
181
183
  .fig-fill-picker-color-area {
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) {
@@ -2078,9 +2189,13 @@ class FigFillPicker extends HTMLElement {
2078
2189
  bg = "";
2079
2190
  }
2080
2191
  break;
2081
- default:
2082
- const slot = this.#customSlots[this.#fillType];
2083
- bg = slot?.element?.getAttribute("swatch-background") || "#D9D9D9";
2192
+ default: {
2193
+ const slot =
2194
+ this.#customSlots[this.#fillType]?.element ||
2195
+ this.querySelector(`[slot="mode-${this.#fillType}"]`);
2196
+ bg = slot?.getAttribute("swatch-background") || "";
2197
+ break;
2198
+ }
2084
2199
  }
2085
2200
 
2086
2201
  this.#swatch.setAttribute("background", bg);
@@ -2198,6 +2313,8 @@ class FigFillPicker extends HTMLElement {
2198
2313
  this.#webcam.stream = null;
2199
2314
  }
2200
2315
  this.#webcamPosterStream = null;
2316
+ this.#webcamSnapshotting = false;
2317
+ this.#webcamImageCapture = null;
2201
2318
  const video = this.#dialog?.querySelector(
2202
2319
  ".fig-fill-picker-webcam-video",
2203
2320
  );
@@ -4520,11 +4637,16 @@ class FigFillPicker extends HTMLElement {
4520
4637
  };
4521
4638
  video.addEventListener("loadedmetadata", updateFrameReadiness);
4522
4639
  video.addEventListener("canplay", updateFrameReadiness);
4640
+ video.addEventListener("playing", updateFrameReadiness);
4523
4641
 
4524
4642
  const attachPreview = (stream) => {
4525
4643
  this.#webcam.stream = stream;
4526
4644
  video.srcObject = stream;
4645
+ video.muted = true;
4646
+ video.playsInline = true;
4527
4647
  video.style.display = "block";
4648
+ const play = video.play?.();
4649
+ if (play?.catch) play.catch(() => {});
4528
4650
  updateFrameReadiness();
4529
4651
  };
4530
4652
 
@@ -4573,11 +4695,16 @@ class FigFillPicker extends HTMLElement {
4573
4695
  existing?.getTracks?.().filter((track) => track.readyState !== "ended") ??
4574
4696
  [];
4575
4697
  if (existing && liveTracks.length) {
4576
- const currentId = this.#webcam.deviceId;
4577
- if (!requested || !currentId || requested === currentId) {
4698
+ const liveId =
4699
+ existing.getVideoTracks?.()?.[0]?.getSettings?.()?.deviceId ||
4700
+ this.#webcam.deviceId ||
4701
+ null;
4702
+ // Reuse the live stream only when it is already the requested device.
4703
+ // An unknown liveId is not a match — otherwise the camera select cannot switch.
4704
+ if (!requested || requested === liveId) {
4578
4705
  attachPreview(existing);
4579
4706
  this.#emitWebcamStream();
4580
- populateCameras(null, requested || currentId);
4707
+ populateCameras(null, requested || liveId);
4581
4708
  return;
4582
4709
  }
4583
4710
  }
@@ -4638,8 +4765,7 @@ class FigFillPicker extends HTMLElement {
4638
4765
  cameraSelect.addEventListener("change", (e) => {
4639
4766
  const next =
4640
4767
  typeof e.detail === "string" ? e.detail : e.target?.value;
4641
- if (!next) return;
4642
- this.#webcam.deviceId = next;
4768
+ if (!next || next === this.#webcam.deviceId) return;
4643
4769
  startWebcam(next);
4644
4770
  });
4645
4771
 
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,