@rogieking/figui3 4.5.3 → 4.6.0

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.js CHANGED
@@ -8255,60 +8255,115 @@ customElements.define("fig-chit", FigChit);
8255
8255
  class FigSwatch extends FigChit {}
8256
8256
  customElements.define("fig-swatch", FigSwatch);
8257
8257
 
8258
- /* Image */
8258
+ /* Media */
8259
8259
  /**
8260
- * @attr {string} src - Image source URL
8260
+ * @attr {string} src - Media source URL
8261
+ * @attr {string} type - "image" (default) or "video" (for fig-media)
8262
+ * @attr {string} alt - Alt text for the generated image (default "")
8261
8263
  * @attr {boolean} upload - Show upload overlay (generates fig-input-file)
8262
8264
  * @attr {string} label - Upload button label (default "Upload")
8263
- * @attr {string} size - small | medium | large | auto
8264
- * @attr {string} aspect-ratio - CSS aspect-ratio or "auto" (lazy dimension sniff)
8265
+ * @attr {string} size - small | medium | large | auto (token-sized square)
8266
+ * @attr {string} aspect-ratio - CSS aspect-ratio value
8265
8267
  * @attr {string} fit - CSS object-fit value
8266
- * @attr {boolean} checkerboard - Show checkerboard behind transparent images
8268
+ * @attr {boolean} checkerboard - Show checkerboard behind transparent media
8269
+ * @attr {boolean} controls - Video controls visibility (default false)
8270
+ * @attr {boolean} autoplay - Video autoplay
8271
+ * @attr {boolean} loop - Video loop
8272
+ * @attr {boolean} muted - Video muted
8273
+ * @attr {string} poster - Video poster image URL
8274
+ *
8275
+ * Sizing model:
8276
+ * - Default: host shrinkwraps to its inner <img>/<video> intrinsic size.
8277
+ * - `size` attribute applies a token-sized square.
8278
+ * - `aspect-ratio` attribute fills container width and applies the ratio.
8267
8279
  */
8268
- class FigImage extends HTMLElement {
8280
+ class FigMedia extends HTMLElement {
8269
8281
  #src = null;
8270
8282
  #chit = null;
8283
+ #mediaEl = null;
8271
8284
  #fileInput = null;
8272
8285
  #blobUrl = null;
8273
8286
  #file = null;
8274
8287
  #boundHandleFileInput = this.#handleFileInput.bind(this);
8288
+ #boundHandleMediaPlay = this.#handleMediaPlay.bind(this);
8289
+ #boundHandleMediaPause = this.#handleMediaPause.bind(this);
8290
+ #boundHandleMediaEnded = this.#handleMediaEnded.bind(this);
8275
8291
 
8276
8292
  static get observedAttributes() {
8277
- return ["src", "upload", "aspect-ratio", "fit", "checkerboard"];
8293
+ return [
8294
+ "src",
8295
+ "type",
8296
+ "alt",
8297
+ "upload",
8298
+ "label",
8299
+ "aspect-ratio",
8300
+ "fit",
8301
+ "checkerboard",
8302
+ "controls",
8303
+ "autoplay",
8304
+ "loop",
8305
+ "muted",
8306
+ "poster",
8307
+ ];
8308
+ }
8309
+
8310
+ get mediaKind() {
8311
+ const type = (this.getAttribute("type") || "image").toLowerCase();
8312
+ return type === "video" ? "video" : "image";
8278
8313
  }
8279
8314
 
8280
8315
  get src() {
8281
8316
  return this.#src;
8282
8317
  }
8283
8318
  set src(value) {
8284
- this.#src = value;
8285
- this.setAttribute("src", value);
8319
+ this.#src = value || "";
8320
+ if (value === null || value === undefined || value === "") {
8321
+ this.removeAttribute("src");
8322
+ } else {
8323
+ this.setAttribute("src", value);
8324
+ }
8286
8325
  }
8287
8326
 
8288
8327
  get file() {
8289
8328
  return this.#file;
8290
8329
  }
8291
8330
 
8331
+ /**
8332
+ * Returns a base64 data URL for the loaded image.
8333
+ * Requires a CORS-clean image (same-origin or with appropriate Access-Control headers);
8334
+ * cross-origin images without proper headers will throw a tainted-canvas error.
8335
+ */
8292
8336
  async getBase64() {
8293
- const src = this.#src;
8294
- if (!src) return null;
8295
- const res = await fetch(src);
8296
- const blob = await res.blob();
8297
- const bitmap = await createImageBitmap(blob);
8337
+ if (this.mediaKind !== "image") return null;
8338
+ if (!this.#src) return null;
8339
+ if (!this.#mediaEl) return null;
8340
+ try {
8341
+ if (typeof this.#mediaEl.decode === "function") {
8342
+ await this.#mediaEl.decode();
8343
+ } else if (!this.#mediaEl.complete) {
8344
+ await new Promise((resolve, reject) => {
8345
+ this.#mediaEl.addEventListener("load", resolve, { once: true });
8346
+ this.#mediaEl.addEventListener("error", reject, { once: true });
8347
+ });
8348
+ }
8349
+ } catch {
8350
+ // continue; canvas draw will throw if image truly unusable
8351
+ }
8352
+ const w = this.#mediaEl.naturalWidth;
8353
+ const h = this.#mediaEl.naturalHeight;
8354
+ if (!(w > 0) || !(h > 0)) return null;
8298
8355
  const canvas = document.createElement("canvas");
8299
- canvas.width = bitmap.width;
8300
- canvas.height = bitmap.height;
8301
- canvas.getContext("2d").drawImage(bitmap, 0, 0);
8302
- bitmap.close();
8303
- const dataUrl = canvas.toDataURL();
8304
- return dataUrl;
8356
+ canvas.width = w;
8357
+ canvas.height = h;
8358
+ canvas.getContext("2d").drawImage(this.#mediaEl, 0, 0);
8359
+ return canvas.toDataURL();
8305
8360
  }
8306
8361
 
8307
8362
  connectedCallback() {
8308
8363
  this.#src = this.getAttribute("src") || "";
8309
8364
 
8310
8365
  const ar = this.getAttribute("aspect-ratio");
8311
- if (ar && ar !== "auto") {
8366
+ if (ar) {
8312
8367
  this.style.setProperty("--aspect-ratio", ar);
8313
8368
  }
8314
8369
  const fit = this.getAttribute("fit");
@@ -8320,7 +8375,7 @@ class FigImage extends HTMLElement {
8320
8375
  const chit = document.createElement("fig-chit");
8321
8376
  chit.setAttribute("data-generated", "");
8322
8377
  chit.setAttribute("size", "large");
8323
- chit.setAttribute("data-type", "image");
8378
+ chit.setAttribute("data-type", this.mediaKind);
8324
8379
  chit.setAttribute("disabled", "");
8325
8380
  this.#applyChitBackground(chit);
8326
8381
  if (this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false") {
@@ -8329,19 +8384,19 @@ class FigImage extends HTMLElement {
8329
8384
  this.prepend(chit);
8330
8385
  }
8331
8386
  this.#chit = this.querySelector("fig-chit");
8387
+ this.#syncChitType();
8388
+ this.#ensureMediaElement();
8389
+ this.#syncGeneratedMediaElement();
8332
8390
 
8333
8391
  const isUpload = this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
8334
8392
  if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
8335
8393
  this.#createFileInput();
8336
8394
  }
8337
-
8338
- if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
8339
- this.#sniffDimensions(this.#src);
8340
- }
8341
8395
  }
8342
8396
 
8343
8397
  disconnectedCallback() {
8344
8398
  this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
8399
+ this.#removeMediaElementListeners();
8345
8400
  if (this.#blobUrl) {
8346
8401
  URL.revokeObjectURL(this.#blobUrl);
8347
8402
  this.#blobUrl = null;
@@ -8350,17 +8405,112 @@ class FigImage extends HTMLElement {
8350
8405
 
8351
8406
  #applyChitBackground(chit) {
8352
8407
  const cb = this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false";
8353
- if (this.#src) {
8354
- chit.setAttribute("background", `url(${this.#src})`);
8408
+ chit.setAttribute("background", cb ? "url()" : "var(--figma-color-bg-secondary)");
8409
+ }
8410
+
8411
+ #syncChitType() {
8412
+ if (!this.#chit) return;
8413
+ this.#chit.setAttribute("data-type", this.mediaKind);
8414
+ }
8415
+
8416
+ #removeMediaElementListeners() {
8417
+ if (!this.#mediaEl) return;
8418
+ if (this.#mediaEl.tagName === "VIDEO") {
8419
+ this.#mediaEl.removeEventListener("play", this.#boundHandleMediaPlay);
8420
+ this.#mediaEl.removeEventListener("pause", this.#boundHandleMediaPause);
8421
+ this.#mediaEl.removeEventListener("ended", this.#boundHandleMediaEnded);
8422
+ }
8423
+ }
8424
+
8425
+ #userProvidedMediaEl() {
8426
+ const tag = this.mediaKind === "video" ? "video" : "img";
8427
+ return this.querySelector(`${tag}:not([data-generated])`);
8428
+ }
8429
+
8430
+ #ensureMediaElement() {
8431
+ const userEl = this.#userProvidedMediaEl();
8432
+ if (userEl) {
8433
+ if (this.#mediaEl && this.#mediaEl !== userEl) {
8434
+ this.#removeMediaElementListeners();
8435
+ if (this.#mediaEl.hasAttribute("data-generated")) {
8436
+ this.#mediaEl.remove();
8437
+ }
8438
+ }
8439
+ this.#mediaEl = userEl;
8440
+ return;
8441
+ }
8442
+
8443
+ const expectedTag = this.mediaKind === "video" ? "VIDEO" : "IMG";
8444
+ if (this.#mediaEl && this.#mediaEl.tagName !== expectedTag) {
8445
+ this.#removeMediaElementListeners();
8446
+ if (this.#mediaEl.hasAttribute("data-generated")) {
8447
+ this.#mediaEl.remove();
8448
+ }
8449
+ this.#mediaEl = null;
8450
+ }
8451
+ if (this.#mediaEl) return;
8452
+
8453
+ if (this.mediaKind === "video") {
8454
+ const video = document.createElement("video");
8455
+ video.setAttribute("data-generated", "");
8456
+ video.className = "fig-media-element";
8457
+ video.setAttribute("playsinline", "");
8458
+ video.preload = "metadata";
8459
+ this.prepend(video);
8460
+ this.#mediaEl = video;
8461
+ this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
8462
+ this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
8463
+ this.#mediaEl.addEventListener("ended", this.#boundHandleMediaEnded);
8355
8464
  } else {
8356
- chit.setAttribute("background", cb ? "url()" : "var(--figma-color-bg-secondary)");
8465
+ const img = document.createElement("img");
8466
+ img.setAttribute("data-generated", "");
8467
+ img.className = "fig-media-element";
8468
+ img.loading = "lazy";
8469
+ img.decoding = "async";
8470
+ img.alt = this.getAttribute("alt") || "";
8471
+ this.prepend(img);
8472
+ this.#mediaEl = img;
8357
8473
  }
8358
8474
  }
8359
8475
 
8476
+ #isEnabledAttr(name, defaultEnabled = false) {
8477
+ if (!this.hasAttribute(name)) return defaultEnabled;
8478
+ return this.getAttribute(name) !== "false";
8479
+ }
8480
+
8481
+ #syncGeneratedMediaElement() {
8482
+ if (!this.#mediaEl) return;
8483
+ if (!this.#mediaEl.hasAttribute("data-generated")) return;
8484
+ const src = this.#src || "";
8485
+ if (this.#mediaEl.getAttribute("src") !== src) {
8486
+ if (src) {
8487
+ this.#mediaEl.setAttribute("src", src);
8488
+ } else {
8489
+ this.#mediaEl.removeAttribute("src");
8490
+ if (this.#mediaEl.tagName === "VIDEO") this.#mediaEl.load();
8491
+ }
8492
+ }
8493
+ if (this.#mediaEl.tagName === "IMG") {
8494
+ this.#mediaEl.alt = this.getAttribute("alt") || "";
8495
+ return;
8496
+ }
8497
+ const poster = this.getAttribute("poster");
8498
+ if (poster) {
8499
+ this.#mediaEl.setAttribute("poster", poster);
8500
+ } else {
8501
+ this.#mediaEl.removeAttribute("poster");
8502
+ }
8503
+ this.#mediaEl.controls = this.#isEnabledAttr("controls", false);
8504
+ this.#mediaEl.autoplay = this.#isEnabledAttr("autoplay", false);
8505
+ this.#mediaEl.loop = this.#isEnabledAttr("loop", false);
8506
+ this.#mediaEl.muted = this.#isEnabledAttr("muted", false);
8507
+ this.#mediaEl.playsInline = true;
8508
+ }
8509
+
8360
8510
  #createFileInput() {
8361
8511
  const fi = document.createElement("fig-input-file");
8362
8512
  fi.setAttribute("data-generated", "");
8363
- fi.setAttribute("accepts", "image/*");
8513
+ fi.setAttribute("accepts", this.mediaKind === "video" ? "video/*" : "image/*");
8364
8514
  fi.setAttribute("variant", "overlay");
8365
8515
  const defaultLabel = this.getAttribute("label") || "Upload";
8366
8516
  fi.setAttribute("label", this.#src ? "Replace" : defaultLabel);
@@ -8422,32 +8572,45 @@ class FigImage extends HTMLElement {
8422
8572
  }
8423
8573
  }
8424
8574
 
8425
- async #sniffDimensions(src) {
8426
- try {
8427
- let blob;
8428
- if (src.startsWith("blob:")) {
8429
- const res = await fetch(src);
8430
- blob = await res.blob();
8431
- } else {
8432
- const res = await fetch(src, { mode: "cors" });
8433
- blob = await res.blob();
8434
- }
8435
- const bitmap = await createImageBitmap(blob);
8436
- this.style.setProperty("--aspect-ratio", `${bitmap.width}/${bitmap.height}`);
8437
- bitmap.close();
8438
- } catch {
8439
- // Non-critical — CSS aspect-ratio fallback handles it
8440
- }
8575
+ #emitPlaybackEvent(type) {
8576
+ if (!this.#mediaEl) return;
8577
+ this.dispatchEvent(
8578
+ new CustomEvent(type, {
8579
+ bubbles: true,
8580
+ cancelable: false,
8581
+ composed: true,
8582
+ detail: {
8583
+ src: this.#src || "",
8584
+ currentTime: this.#mediaEl.currentTime,
8585
+ duration: this.#mediaEl.duration,
8586
+ },
8587
+ }),
8588
+ );
8589
+ }
8590
+
8591
+ #handleMediaPlay() {
8592
+ this.#emitPlaybackEvent("play");
8593
+ }
8594
+
8595
+ #handleMediaPause() {
8596
+ this.#emitPlaybackEvent("pause");
8597
+ }
8598
+
8599
+ #handleMediaEnded() {
8600
+ this.#emitPlaybackEvent("ended");
8441
8601
  }
8442
8602
 
8443
8603
  attributeChangedCallback(name, oldValue, newValue) {
8444
8604
  if (oldValue === newValue) return;
8445
8605
 
8446
8606
  if (name === "src") {
8447
- this.#src = newValue;
8448
- if (this.#chit) {
8449
- this.#applyChitBackground(this.#chit);
8607
+ this.#src = newValue || "";
8608
+ if (this.#blobUrl && this.#src !== this.#blobUrl) {
8609
+ URL.revokeObjectURL(this.#blobUrl);
8610
+ this.#blobUrl = null;
8611
+ this.#file = null;
8450
8612
  }
8613
+ this.#syncGeneratedMediaElement();
8451
8614
  if (this.#fileInput) {
8452
8615
  const defaultLabel = this.getAttribute("label") || "Upload";
8453
8616
  this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
@@ -8457,11 +8620,24 @@ class FigImage extends HTMLElement {
8457
8620
  this.#fileInput.removeAttribute("url");
8458
8621
  }
8459
8622
  }
8460
- if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
8461
- this.#sniffDimensions(this.#src);
8623
+ }
8624
+
8625
+ if (name === "type") {
8626
+ this.#syncChitType();
8627
+ this.#ensureMediaElement();
8628
+ this.#syncGeneratedMediaElement();
8629
+ if (this.#fileInput) {
8630
+ this.#fileInput.setAttribute(
8631
+ "accepts",
8632
+ this.mediaKind === "video" ? "video/*" : "image/*",
8633
+ );
8462
8634
  }
8463
8635
  }
8464
8636
 
8637
+ if (name === "alt" && this.#mediaEl && this.#mediaEl.tagName === "IMG") {
8638
+ this.#mediaEl.alt = newValue || "";
8639
+ }
8640
+
8465
8641
  if (name === "upload") {
8466
8642
  const on = newValue !== null && newValue !== "false";
8467
8643
  if (on && !this.#fileInput) {
@@ -8472,12 +8648,10 @@ class FigImage extends HTMLElement {
8472
8648
  }
8473
8649
 
8474
8650
  if (name === "aspect-ratio") {
8475
- if (newValue && newValue !== "auto") {
8651
+ if (newValue) {
8476
8652
  this.style.setProperty("--aspect-ratio", newValue);
8477
- } else if (!newValue) {
8653
+ } else {
8478
8654
  this.style.removeProperty("--aspect-ratio");
8479
- } else if (newValue === "auto" && this.#src) {
8480
- this.#sniffDimensions(this.#src);
8481
8655
  }
8482
8656
  }
8483
8657
 
@@ -8496,12 +8670,37 @@ class FigImage extends HTMLElement {
8496
8670
  } else {
8497
8671
  this.#chit.removeAttribute("checkerboard");
8498
8672
  }
8673
+ this.#applyChitBackground(this.#chit);
8499
8674
  }
8500
8675
  }
8676
+
8677
+ if (name === "label" && this.#fileInput) {
8678
+ const defaultLabel = this.getAttribute("label") || "Upload";
8679
+ this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8680
+ }
8681
+
8682
+ if (["controls", "autoplay", "loop", "muted", "poster"].includes(name)) {
8683
+ this.#syncGeneratedMediaElement();
8684
+ }
8685
+ }
8686
+ }
8687
+
8688
+ customElements.define("fig-media", FigMedia);
8689
+
8690
+ class FigImage extends FigMedia {
8691
+ get mediaKind() {
8692
+ return "image";
8501
8693
  }
8502
8694
  }
8503
8695
  customElements.define("fig-image", FigImage);
8504
8696
 
8697
+ class FigVideo extends FigMedia {
8698
+ get mediaKind() {
8699
+ return "video";
8700
+ }
8701
+ }
8702
+ customElements.define("fig-video", FigVideo);
8703
+
8505
8704
  /* File Upload Input */
8506
8705
  class FigInputFile extends HTMLElement {
8507
8706
  static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "4.5.3",
3
+ "version": "4.6.0",
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",