@rogieking/figui3 8.9.20 → 8.9.22

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
@@ -29,6 +29,18 @@ function figEditorCssUrl(url) {
29
29
  return `url("${String(url).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
30
30
  }
31
31
 
32
+ function figEditorLooksLikeVideoUrl(raw) {
33
+ const text = String(raw ?? "").trim();
34
+ if (!text || text.startsWith("{") || text.startsWith("#")) return false;
35
+ if (text.startsWith("data:video/")) return true;
36
+ try {
37
+ const path = new URL(text, "https://fig.local").pathname;
38
+ return /\.(mp4|webm|mov|m4v|ogv)$/i.test(path);
39
+ } catch {
40
+ return /\.(mp4|webm|mov|m4v|ogv)(?:[?#]|$)/i.test(text);
41
+ }
42
+ }
43
+
32
44
  function figEditorCreateIcon(name, options = {}) {
33
45
  const icon = document.createElement("fig-icon");
34
46
  if (name) icon.setAttribute("name", name);
@@ -1692,6 +1704,7 @@ class FigFillPicker extends HTMLElement {
1692
1704
  #boundTriggerKeydown = null;
1693
1705
  #rafIds = new Set();
1694
1706
  #ownedBlobUrls = new Set();
1707
+ #videoPosterCapturing = null;
1695
1708
 
1696
1709
  constructor() {
1697
1710
  super();
@@ -1721,6 +1734,7 @@ class FigFillPicker extends HTMLElement {
1721
1734
  this.#setupTrigger();
1722
1735
  this.#parseValue();
1723
1736
  this.#updateSwatch();
1737
+ this.#ensureVideoPoster();
1724
1738
  });
1725
1739
  }
1726
1740
 
@@ -2117,8 +2131,12 @@ class FigFillPicker extends HTMLElement {
2117
2131
  }
2118
2132
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
2119
2133
  if (parsed.video) {
2134
+ const prevUrl = this.#video.url;
2120
2135
  this.#video = { ...this.#video, ...parsed.video };
2121
2136
  this.#video.missing = !this.#video.url;
2137
+ if (this.#video.url !== prevUrl && parsed.video.poster == null) {
2138
+ this.#revokeVideoPoster();
2139
+ }
2122
2140
  }
2123
2141
  this.#applyParsedWebcam(parsed);
2124
2142
 
@@ -2128,10 +2146,17 @@ class FigFillPicker extends HTMLElement {
2128
2146
  this.#customData[parsed.type] = rest;
2129
2147
  }
2130
2148
  } catch (e) {
2131
- // If not JSON, treat as hex color
2132
2149
  if (valueAttr.startsWith("#")) {
2133
2150
  this.#fillType = "solid";
2134
2151
  this.#color = this.#hexToHSV(valueAttr);
2152
+ return;
2153
+ }
2154
+ if (figEditorLooksLikeVideoUrl(valueAttr)) {
2155
+ const prevUrl = this.#video.url;
2156
+ this.#fillType = "video";
2157
+ this.#video.url = valueAttr.trim();
2158
+ this.#video.missing = false;
2159
+ if (this.#video.url !== prevUrl) this.#revokeVideoPoster();
2135
2160
  }
2136
2161
  }
2137
2162
  }
@@ -2228,6 +2253,8 @@ class FigFillPicker extends HTMLElement {
2228
2253
  if (this.#isDisabled()) return;
2229
2254
  if (!this.#dialog) {
2230
2255
  this.#createDialog();
2256
+ } else {
2257
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
2231
2258
  }
2232
2259
 
2233
2260
  this.#valueAtOpen = JSON.stringify(this.value);
@@ -4427,7 +4454,6 @@ class FigFillPicker extends HTMLElement {
4427
4454
  "aspect-ratio": "1/1",
4428
4455
  fit: "cover",
4429
4456
  checkerboard: "true",
4430
- autoplay: "true",
4431
4457
  controls: true,
4432
4458
  muted: "true",
4433
4459
  loop: "true",
@@ -4466,8 +4492,67 @@ class FigFillPicker extends HTMLElement {
4466
4492
  this.#captureVideoPoster(fallback, { emit });
4467
4493
  }
4468
4494
 
4495
+ #ensureVideoPoster({ emit = true } = {}) {
4496
+ if (this.#fillType !== "video") return;
4497
+ const src = this.#video.url;
4498
+ if (!src || this.#video.poster) return;
4499
+ this.#captureVideoPoster(src, { emit });
4500
+ }
4501
+
4502
+ async #videoFrameBitmap(video) {
4503
+ if (typeof video.requestVideoFrameCallback === "function") {
4504
+ await Promise.race([
4505
+ new Promise((resolve) => video.requestVideoFrameCallback(() => resolve())),
4506
+ new Promise((resolve) => setTimeout(resolve, 80)),
4507
+ ]);
4508
+ } else {
4509
+ try {
4510
+ await video.play();
4511
+ video.pause();
4512
+ } catch {
4513
+ /* use the current decoded frame */
4514
+ }
4515
+ }
4516
+ if (typeof createImageBitmap === "function") {
4517
+ try {
4518
+ return await createImageBitmap(video);
4519
+ } catch {
4520
+ /* tainted or undecoded — fall back to drawImage(video) */
4521
+ }
4522
+ }
4523
+ return null;
4524
+ }
4525
+
4526
+ async #blobFromVideoFrame(video) {
4527
+ const bitmap = await this.#videoFrameBitmap(video);
4528
+ const width = bitmap?.width || video.videoWidth;
4529
+ const height = bitmap?.height || video.videoHeight;
4530
+ if (!width || !height) {
4531
+ bitmap?.close?.();
4532
+ return null;
4533
+ }
4534
+ const canvas =
4535
+ typeof OffscreenCanvas === "function"
4536
+ ? new OffscreenCanvas(width, height)
4537
+ : Object.assign(document.createElement("canvas"), { width, height });
4538
+ const ctx = canvas.getContext("2d");
4539
+ if (!ctx) {
4540
+ bitmap?.close?.();
4541
+ return null;
4542
+ }
4543
+ try {
4544
+ ctx.drawImage(bitmap || video, 0, 0, width, height);
4545
+ } finally {
4546
+ bitmap?.close?.();
4547
+ }
4548
+ return canvas.convertToBlob
4549
+ ? canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 })
4550
+ : new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.85));
4551
+ }
4552
+
4469
4553
  async #captureVideoPoster(src, { emit = true } = {}) {
4470
- if (!src) return;
4554
+ if (!src || this.#videoPosterCapturing === src) return;
4555
+ this.#videoPosterCapturing = src;
4471
4556
  const video = document.createElement("video");
4472
4557
  video.muted = true;
4473
4558
  video.playsInline = true;
@@ -4481,14 +4566,8 @@ class FigFillPicker extends HTMLElement {
4481
4566
  video.src = src;
4482
4567
  });
4483
4568
  if (!video.videoWidth || !video.videoHeight) return;
4484
- const canvas = document.createElement("canvas");
4485
- canvas.width = video.videoWidth;
4486
- canvas.height = video.videoHeight;
4487
- canvas.getContext("2d").drawImage(video, 0, 0);
4488
- const blob = await new Promise((resolve) =>
4489
- canvas.toBlob(resolve, "image/jpeg", 0.85),
4490
- );
4491
- if (!blob) return;
4569
+ const blob = await this.#blobFromVideoFrame(video);
4570
+ if (!blob || this.#video.url !== src) return;
4492
4571
  this.#revokeVideoPoster();
4493
4572
  this.#video.poster = URL.createObjectURL(blob);
4494
4573
  this.#ownedBlobUrls.add(this.#video.poster);
@@ -4496,6 +4575,10 @@ class FigFillPicker extends HTMLElement {
4496
4575
  if (emit) this.#emitInput();
4497
4576
  } catch {
4498
4577
  // Cross-origin or decode failures leave the swatch empty until a poster exists.
4578
+ } finally {
4579
+ video.removeAttribute("src");
4580
+ video.load();
4581
+ if (this.#videoPosterCapturing === src) this.#videoPosterCapturing = null;
4499
4582
  }
4500
4583
  }
4501
4584
 
@@ -4521,7 +4604,6 @@ class FigFillPicker extends HTMLElement {
4521
4604
  this.#video.url = src;
4522
4605
  this.#video.missing = false;
4523
4606
  this.#updateVideoPreviewStyle(preview);
4524
- preview.play?.();
4525
4607
  this.#captureVideoPoster(src);
4526
4608
  this.#updateSwatch();
4527
4609
  this.#emitInput();
@@ -5184,6 +5266,7 @@ class FigFillPicker extends HTMLElement {
5184
5266
  case "value":
5185
5267
  this.#parseValue();
5186
5268
  this.#updateSwatch();
5269
+ this.#ensureVideoPoster();
5187
5270
  if (this.#dialog?.open && !this.#isDraggingColor) {
5188
5271
  this.#refreshDialogUI();
5189
5272
  this.#lastChangeValue = JSON.stringify(this.value);
package/fig-lab.css CHANGED
@@ -658,20 +658,41 @@ propskit-gradient {
658
658
  }
659
659
 
660
660
  :is(propskit-color, propskit-fill, propskit-gradient) {
661
+ outline: none;
662
+
661
663
  fig-field:not([direction="vertical"]) {
662
664
  outline: none;
665
+ }
663
666
 
664
- &:focus-within {
665
- outline: var(--figma-focus-outline);
666
- outline-offset: var(--figma-focus-outline-offset);
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;
667
681
  }
668
682
 
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 {
672
- outline: none !important;
673
- }
683
+ &:focus-within:not(:focus) {
684
+ outline: 0 !important;
674
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;
675
696
  }
676
697
  }
677
698
 
package/fig-lab.js CHANGED
@@ -728,6 +728,25 @@ function figLabCssUrl(url) {
728
728
  return `url("${String(url).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
729
729
  }
730
730
 
731
+ function figLabLooksLikeVideoUrl(raw) {
732
+ const text = String(raw ?? "").trim();
733
+ if (!text || text.startsWith("{") || text.startsWith("#")) return false;
734
+ if (text.startsWith("data:video/")) return true;
735
+ try {
736
+ const path = new URL(text, "https://fig.local").pathname;
737
+ return /\.(mp4|webm|mov|m4v|ogv)$/i.test(path);
738
+ } catch {
739
+ return /\.(mp4|webm|mov|m4v|ogv)(?:[?#]|$)/i.test(text);
740
+ }
741
+ }
742
+
743
+ function figLabVideoFillFromUrl(url) {
744
+ return {
745
+ type: "video",
746
+ video: { url, scaleMode: "fill", scale: 50 },
747
+ };
748
+ }
749
+
731
750
  function figLabParseFillValue(raw) {
732
751
  if (raw && typeof raw === "object") return raw;
733
752
  const text = String(raw ?? "").trim();
@@ -742,6 +761,7 @@ function figLabParseFillValue(raw) {
742
761
  const { color, alpha } = figLabParseSolidColor(text);
743
762
  return { type: "solid", color, alpha };
744
763
  }
764
+ if (figLabLooksLikeVideoUrl(text)) return figLabVideoFillFromUrl(text);
745
765
  return { type: "solid", color: "#D9D9D9", alpha: 1 };
746
766
  }
747
767
 
@@ -922,6 +942,7 @@ class PropskitColor extends HTMLElement {
922
942
  const label = customLabel || document.createElement("label");
923
943
  const picker = document.createElement("fig-fill-picker");
924
944
  const swatch = document.createElement("fig-swatch");
945
+ picker.anchorElement = this;
925
946
  picker.append(swatch);
926
947
  swatch.setAttribute("tabindex", "0");
927
948
  for (const node of initialChildren) {
@@ -1278,6 +1299,7 @@ class PropskitFill extends HTMLElement {
1278
1299
  const label = customLabel || document.createElement("label");
1279
1300
  const picker = document.createElement("fig-fill-picker");
1280
1301
  const swatch = document.createElement("fig-swatch");
1302
+ picker.anchorElement = this;
1281
1303
  picker.append(swatch);
1282
1304
  swatch.setAttribute("tabindex", "0");
1283
1305
  for (const node of initialChildren) {
@@ -1646,6 +1668,7 @@ class PropskitGradient extends HTMLElement {
1646
1668
  const field = document.createElement("fig-field");
1647
1669
  const label = customLabel || document.createElement("label");
1648
1670
  const input = document.createElement("fig-input-gradient");
1671
+ input.anchorElement = this;
1649
1672
 
1650
1673
  field.append(label, input);
1651
1674
  this.#field = field;
package/fig.js CHANGED
@@ -8684,6 +8684,18 @@ function figCssUrl(url) {
8684
8684
  return `url("${String(url).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
8685
8685
  }
8686
8686
 
8687
+ function figLooksLikeVideoUrl(raw) {
8688
+ const text = String(raw ?? "").trim();
8689
+ if (!text || text.startsWith("{") || text.startsWith("#")) return false;
8690
+ if (text.startsWith("data:video/")) return true;
8691
+ try {
8692
+ const path = new URL(text, "https://fig.local").pathname;
8693
+ return /\.(mp4|webm|mov|m4v|ogv)$/i.test(path);
8694
+ } catch {
8695
+ return /\.(mp4|webm|mov|m4v|ogv)(?:[?#]|$)/i.test(text);
8696
+ }
8697
+ }
8698
+
8687
8699
  function figHexToRGB(hex) {
8688
8700
  const h = hex.replace(/^#/, "");
8689
8701
  return {
@@ -9077,7 +9089,6 @@ class FigInputFill extends HTMLElement {
9077
9089
  }
9078
9090
  }
9079
9091
  } catch (e) {
9080
- // If not JSON, treat as hex color
9081
9092
  if (valueAttr.startsWith("#")) {
9082
9093
  this.#fillType = "solid";
9083
9094
  this.#solid.color = valueAttr.slice(0, 7);
@@ -9085,6 +9096,12 @@ class FigInputFill extends HTMLElement {
9085
9096
  const alphaHex = valueAttr.slice(7, 9);
9086
9097
  this.#solid.alpha = parseInt(alphaHex, 16) / 255;
9087
9098
  }
9099
+ return;
9100
+ }
9101
+ if (figLooksLikeVideoUrl(valueAttr)) {
9102
+ this.#fillType = "video";
9103
+ this.#video.url = valueAttr.trim();
9104
+ this.#video.missing = false;
9088
9105
  }
9089
9106
  }
9090
9107
  }
@@ -10363,6 +10380,7 @@ class FigInputGradient extends HTMLElement {
10363
10380
  #colorObserver = null;
10364
10381
  #repositionRAF = null;
10365
10382
  #pickerDefinitionPromise = null;
10383
+ anchorElement = null;
10366
10384
  #gradient = {
10367
10385
  type: "linear",
10368
10386
  angle: 90,
@@ -10733,7 +10751,7 @@ class FigInputGradient extends HTMLElement {
10733
10751
  #setupPickerEvents() {
10734
10752
  const picker = this.querySelector("fig-fill-picker");
10735
10753
  if (!picker) return;
10736
- picker.anchorElement = this;
10754
+ picker.anchorElement = this.anchorElement || this;
10737
10755
 
10738
10756
  const syncFromPicker = (e) => {
10739
10757
  e.stopPropagation();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "8.9.20",
3
+ "version": "8.9.22",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",