@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/.cursor/skills/fig-editor/SKILL.md +4 -5
- package/.cursor/skills/fig-lab/SKILL.md +4 -3
- package/.cursor/skills/figui3/SKILL.md +1 -1
- package/.cursor/skills/propkit/SKILL.md +1 -1
- package/README.md +26 -4
- package/components.css +26 -12
- package/dist/components.css +1 -1
- package/dist/fig-editor.css +1 -1
- package/dist/fig-editor.js +3 -3
- package/dist/fig-lab.css +1 -1
- package/dist/fig-lab.js +2 -2
- package/dist/fig.css +1 -1
- package/dist/fig.js +4 -4
- package/fig-editor.css +4 -2
- package/fig-editor.js +139 -13
- package/fig-lab.css +23 -6
- package/fig-lab.js +494 -2
- package/fig.js +126 -12
- package/package.json +1 -1
package/fig-editor.css
CHANGED
|
@@ -174,8 +174,10 @@ fig-fill-picker {
|
|
|
174
174
|
display: block;
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
-
fig-
|
|
178
|
-
|
|
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"
|
|
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.#
|
|
1846
|
-
this.#
|
|
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 =
|
|
2083
|
-
|
|
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
|
|
4577
|
-
|
|
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 ||
|
|
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
|
-
|
|
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,
|