@rogieking/figui3 11.3.2 → 11.4.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
@@ -15300,6 +15300,351 @@ class FigAudioWaveform extends HTMLElement {
15300
15300
  }
15301
15301
  figDefineElement("fig-audio-waveform", FigAudioWaveform);
15302
15302
 
15303
+ /**
15304
+ * Audio player composed from a waveform and shared media controls.
15305
+ *
15306
+ * @attr {string} src - Audio source URL
15307
+ * @attr {boolean} autoplay - Audio autoplay
15308
+ * @attr {boolean} controls - Show playback controls (default false)
15309
+ * @attr {boolean} muted - Mute audio playback
15310
+ * @attr {boolean} loop - Loop audio playback
15311
+ */
15312
+ class FigAudio extends HTMLElement {
15313
+ #src = "";
15314
+ #audioEl = null;
15315
+ #waveformEl = null;
15316
+ #controlsEl = null;
15317
+ #controlsWiredFor = null;
15318
+ #controlsWiredControls = null;
15319
+ #controlsSync = null;
15320
+ #controlsOnPlay = null;
15321
+ #controlsOnPause = null;
15322
+ #controlsOnSeek = null;
15323
+
15324
+ static get observedAttributes() {
15325
+ return ["src", "autoplay", "controls", "muted", "loop"];
15326
+ }
15327
+
15328
+ connectedCallback() {
15329
+ this.#src = this.getAttribute("src") || "";
15330
+ this.#ensureAudioElement();
15331
+ this.#ensureWaveform();
15332
+ this.#syncAudioElement();
15333
+ this.#syncControlsVisibility();
15334
+ this.#syncPlaybackUi();
15335
+ }
15336
+
15337
+ disconnectedCallback() {
15338
+ this.#unwireControls();
15339
+ }
15340
+
15341
+ get src() {
15342
+ return this.#src;
15343
+ }
15344
+
15345
+ set src(value) {
15346
+ if (value === null || value === undefined || value === "") {
15347
+ this.removeAttribute("src");
15348
+ } else {
15349
+ this.setAttribute("src", String(value));
15350
+ }
15351
+ }
15352
+
15353
+ get audioEl() {
15354
+ return this.#audioEl;
15355
+ }
15356
+
15357
+ #isEnabledAttr(name) {
15358
+ return this.hasAttribute(name) && this.getAttribute(name) !== "false";
15359
+ }
15360
+
15361
+ #ensureAudioElement() {
15362
+ const authored = this.querySelector(":scope > audio:not([data-generated])");
15363
+ if (authored) {
15364
+ this.#audioEl = authored;
15365
+ return;
15366
+ }
15367
+
15368
+ const generated = Array.from(
15369
+ this.querySelectorAll(":scope > audio[data-generated]"),
15370
+ );
15371
+ const audio = generated[0] || document.createElement("audio");
15372
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15373
+ if (!audio.hasAttribute("data-generated")) {
15374
+ audio.setAttribute("data-generated", "");
15375
+ audio.preload = "metadata";
15376
+ this.append(audio);
15377
+ }
15378
+ this.#audioEl = audio;
15379
+ }
15380
+
15381
+ #ensureWaveform() {
15382
+ const authored = this.querySelector(
15383
+ ":scope > fig-audio-waveform:not([data-generated])",
15384
+ );
15385
+ if (authored) {
15386
+ this.querySelectorAll(
15387
+ ":scope > fig-audio-waveform[data-generated]",
15388
+ ).forEach((waveform) => waveform.remove());
15389
+ this.#waveformEl = authored;
15390
+ } else {
15391
+ const generated = Array.from(
15392
+ this.querySelectorAll(":scope > fig-audio-waveform[data-generated]"),
15393
+ );
15394
+ const waveform = generated[0] || document.createElement("fig-audio-waveform");
15395
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15396
+ if (!waveform.hasAttribute("data-generated")) {
15397
+ waveform.setAttribute("data-generated", "");
15398
+ waveform.setAttribute("full", "");
15399
+ this.prepend(waveform);
15400
+ }
15401
+ this.#waveformEl = waveform;
15402
+ }
15403
+ this.#syncWaveformSource();
15404
+ }
15405
+
15406
+ #syncWaveformSource() {
15407
+ if (!this.#waveformEl) return;
15408
+ if (this.#src) this.#waveformEl.setAttribute("url", this.#src);
15409
+ else this.#waveformEl.removeAttribute("url");
15410
+ }
15411
+
15412
+ #syncAudioElement() {
15413
+ if (!this.#audioEl) return;
15414
+ if (this.#audioEl.getAttribute("src") !== this.#src) {
15415
+ if (this.#src) this.#audioEl.setAttribute("src", this.#src);
15416
+ else {
15417
+ this.#audioEl.removeAttribute("src");
15418
+ this.#audioEl.load?.();
15419
+ }
15420
+ }
15421
+ this.#audioEl.controls = false;
15422
+ this.#audioEl.removeAttribute("controls");
15423
+ this.#audioEl.autoplay = this.#isEnabledAttr("autoplay");
15424
+ this.#audioEl.muted = this.#isEnabledAttr("muted");
15425
+ this.#audioEl.loop = this.#isEnabledAttr("loop");
15426
+ this.#syncWaveformSource();
15427
+ this.#syncControlsDisabled();
15428
+ }
15429
+
15430
+ #hasPlayableAudio() {
15431
+ return Boolean(this.#src);
15432
+ }
15433
+
15434
+ #syncControlsVisibility() {
15435
+ const authored = this.querySelector(
15436
+ ":scope > fig-media-controls:not([data-generated])",
15437
+ );
15438
+ if (authored) {
15439
+ if (this.#controlsEl !== authored) {
15440
+ this.#removeControls();
15441
+ this.#controlsEl = authored;
15442
+ }
15443
+ this.#wireControlsToAudio();
15444
+ this.#syncControlsDisabled();
15445
+ return;
15446
+ }
15447
+
15448
+ if (this.#isEnabledAttr("controls")) {
15449
+ this.#ensureControls();
15450
+ } else {
15451
+ this.#removeControls();
15452
+ }
15453
+ }
15454
+
15455
+ #ensureControls() {
15456
+ const generated = Array.from(
15457
+ this.querySelectorAll(":scope > fig-media-controls[data-generated]"),
15458
+ );
15459
+ const controls = generated[0] || document.createElement("fig-media-controls");
15460
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15461
+ if (!controls.hasAttribute("data-generated")) {
15462
+ controls.setAttribute("data-generated", "");
15463
+ this.append(controls);
15464
+ }
15465
+ this.#controlsEl = controls;
15466
+ this.#wireControlsToAudio();
15467
+ this.#syncControlsDisabled();
15468
+ }
15469
+
15470
+ #syncControlsDisabled() {
15471
+ if (!this.#controlsEl) return;
15472
+ if (this.#hasPlayableAudio()) {
15473
+ if (this.#controlsEl.hasAttribute("data-fig-empty-disabled")) {
15474
+ this.#controlsEl.removeAttribute("disabled");
15475
+ this.#controlsEl.removeAttribute("data-fig-empty-disabled");
15476
+ }
15477
+ return;
15478
+ }
15479
+ this.#controlsEl.setAttribute("disabled", "");
15480
+ this.#controlsEl.setAttribute("data-fig-empty-disabled", "");
15481
+ this.#controlsEl.removeAttribute("playing");
15482
+ this.#controlsEl.setAttribute("time", "0");
15483
+ this.#controlsEl.setAttribute("duration", "0");
15484
+ this.#syncWaveformProgress(0, 0);
15485
+ }
15486
+
15487
+ #syncWaveformProgress(time, duration) {
15488
+ if (!this.#waveformEl) return;
15489
+ const progress =
15490
+ Number.isFinite(time) && Number.isFinite(duration) && duration > 0
15491
+ ? Math.max(0, Math.min(time / duration, 1))
15492
+ : 0;
15493
+ this.#waveformEl.setAttribute("progress", String(progress));
15494
+ }
15495
+
15496
+ #syncPlaybackUi() {
15497
+ const audio = this.#audioEl;
15498
+ if (!audio) return;
15499
+ const time = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
15500
+ const duration = Number.isFinite(audio.duration) ? audio.duration : 0;
15501
+ if (this.#controlsEl) {
15502
+ this.#controlsEl.toggleAttribute("playing", !audio.paused && !audio.ended);
15503
+ this.#controlsEl.setAttribute("time", String(time));
15504
+ this.#controlsEl.setAttribute("duration", String(duration));
15505
+ }
15506
+ this.#syncWaveformProgress(time, duration);
15507
+ }
15508
+
15509
+ #wireControlsToAudio() {
15510
+ if (!this.#controlsEl || !this.#audioEl) return;
15511
+ if (
15512
+ this.#controlsWiredFor === this.#audioEl &&
15513
+ this.#controlsWiredControls === this.#controlsEl
15514
+ ) {
15515
+ this.#syncControlsDisabled();
15516
+ this.#syncPlaybackUi();
15517
+ return;
15518
+ }
15519
+ this.#unwireControls();
15520
+
15521
+ const controls = this.#controlsEl;
15522
+ const audio = this.#audioEl;
15523
+ let pendingSeekTime = null;
15524
+ const syncFromAudio = () => {
15525
+ this.#syncControlsDisabled();
15526
+ if (!this.#hasPlayableAudio()) return;
15527
+ const duration = Number.isFinite(audio.duration) ? audio.duration : 0;
15528
+ const time = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
15529
+ controls.toggleAttribute("playing", !audio.paused && !audio.ended);
15530
+ controls.setAttribute("duration", String(duration));
15531
+ if (pendingSeekTime !== null) {
15532
+ if (Math.abs(time - pendingSeekTime) < 0.25) pendingSeekTime = null;
15533
+ else {
15534
+ this.#syncWaveformProgress(time, duration);
15535
+ return;
15536
+ }
15537
+ }
15538
+ controls.setAttribute("time", String(time));
15539
+ this.#syncWaveformProgress(time, duration);
15540
+ };
15541
+ const onPlay = () => this.play();
15542
+ const onPause = () => this.pause();
15543
+ const onSeek = (event) => {
15544
+ if (!this.#hasPlayableAudio()) return;
15545
+ const next = Number(event?.detail?.time);
15546
+ if (!Number.isFinite(next)) return;
15547
+ pendingSeekTime = next;
15548
+ try {
15549
+ audio.currentTime = next;
15550
+ } catch {}
15551
+ this.#syncWaveformProgress(next, audio.duration);
15552
+ };
15553
+
15554
+ this.#controlsWiredFor = audio;
15555
+ this.#controlsWiredControls = controls;
15556
+ this.#controlsSync = syncFromAudio;
15557
+ this.#controlsOnPlay = onPlay;
15558
+ this.#controlsOnPause = onPause;
15559
+ this.#controlsOnSeek = onSeek;
15560
+
15561
+ [
15562
+ "play",
15563
+ "pause",
15564
+ "ended",
15565
+ "timeupdate",
15566
+ "loadedmetadata",
15567
+ "durationchange",
15568
+ "seeked",
15569
+ "emptied",
15570
+ ].forEach((type) => audio.addEventListener(type, syncFromAudio));
15571
+ controls.addEventListener("play", onPlay);
15572
+ controls.addEventListener("pause", onPause);
15573
+ controls.addEventListener("seek", onSeek);
15574
+ syncFromAudio();
15575
+ }
15576
+
15577
+ #unwireControls() {
15578
+ const audio = this.#controlsWiredFor;
15579
+ const controls = this.#controlsWiredControls;
15580
+ if (audio && this.#controlsSync) {
15581
+ [
15582
+ "play",
15583
+ "pause",
15584
+ "ended",
15585
+ "timeupdate",
15586
+ "loadedmetadata",
15587
+ "durationchange",
15588
+ "seeked",
15589
+ "emptied",
15590
+ ].forEach((type) => audio.removeEventListener(type, this.#controlsSync));
15591
+ }
15592
+ if (controls) {
15593
+ if (this.#controlsOnPlay) {
15594
+ controls.removeEventListener("play", this.#controlsOnPlay);
15595
+ }
15596
+ if (this.#controlsOnPause) {
15597
+ controls.removeEventListener("pause", this.#controlsOnPause);
15598
+ }
15599
+ if (this.#controlsOnSeek) {
15600
+ controls.removeEventListener("seek", this.#controlsOnSeek);
15601
+ }
15602
+ }
15603
+ this.#controlsWiredFor = null;
15604
+ this.#controlsWiredControls = null;
15605
+ this.#controlsSync = null;
15606
+ this.#controlsOnPlay = null;
15607
+ this.#controlsOnPause = null;
15608
+ this.#controlsOnSeek = null;
15609
+ }
15610
+
15611
+ #removeControls() {
15612
+ this.#unwireControls();
15613
+ this.querySelectorAll(
15614
+ ":scope > fig-media-controls[data-generated]",
15615
+ ).forEach((controls) => controls.remove());
15616
+ this.#controlsEl = null;
15617
+ }
15618
+
15619
+ play() {
15620
+ if (!this.#audioEl || !this.#hasPlayableAudio()) return;
15621
+ const promise = this.#audioEl.play?.();
15622
+ if (promise && typeof promise.catch === "function") promise.catch(() => {});
15623
+ }
15624
+
15625
+ pause() {
15626
+ this.#audioEl?.pause?.();
15627
+ }
15628
+
15629
+ toggle() {
15630
+ if (!this.#audioEl || !this.#hasPlayableAudio()) return;
15631
+ if (this.#audioEl.paused || this.#audioEl.ended) this.play();
15632
+ else this.pause();
15633
+ }
15634
+
15635
+ attributeChangedCallback(name, oldValue, newValue) {
15636
+ if (oldValue === newValue) return;
15637
+ if (name === "src") this.#src = newValue || "";
15638
+ if (!this.isConnected) return;
15639
+ if (name === "controls") this.#syncControlsVisibility();
15640
+ if (["src", "autoplay", "muted", "loop"].includes(name)) {
15641
+ this.#syncAudioElement();
15642
+ this.#syncPlaybackUi();
15643
+ }
15644
+ }
15645
+ }
15646
+ figDefineElement("fig-audio", FigAudio);
15647
+
15303
15648
  /* File Upload Input */
15304
15649
  const FIG_INPUT_FILE_IMAGE_EXTENSIONS = [
15305
15650
  ".png",
@@ -15318,11 +15663,23 @@ const FIG_INPUT_FILE_AUDIO_EXTENSIONS = [
15318
15663
  ".ogg",
15319
15664
  ".flac",
15320
15665
  ];
15666
+ const FIG_INPUT_FILE_VIDEO_EXTENSIONS = [
15667
+ ".mp4",
15668
+ ".webm",
15669
+ ".mov",
15670
+ ".m4v",
15671
+ ".avi",
15672
+ ".mkv",
15673
+ ".ogv",
15674
+ ".mpeg",
15675
+ ".mpg",
15676
+ ".3gp",
15677
+ ];
15321
15678
 
15322
15679
  /**
15323
15680
  * File upload input with file-aware icons, image previews, and audio playback waveforms.
15324
15681
  * @attr {string} accepts - Native file chooser filter.
15325
- * @attr {boolean} preview - Preview selected files with a supported image extension.
15682
+ * @attr {boolean} preview - Preview supported files or show their media-specific icon.
15326
15683
  * @attr {string} label - Upload button label. Defaults by known file type.
15327
15684
  */
15328
15685
  class FigInputFile extends HTMLElement {
@@ -15334,13 +15691,12 @@ class FigInputFile extends HTMLElement {
15334
15691
  #tooltipEl = null;
15335
15692
  #uploadBtn = null;
15336
15693
  #files = null;
15337
- #previewImage = null;
15694
+ #imageBlobUrl = null;
15338
15695
  #audioPreview = null;
15339
15696
  #audioIcon = null;
15340
15697
  #audioWaveform = null;
15341
15698
  #audioPlaying = false;
15342
15699
  #audioBlobUrl = null;
15343
- #syncingPreview = false;
15344
15700
  #suppressAttributeRender = false;
15345
15701
 
15346
15702
  get files() {
@@ -15380,6 +15736,10 @@ class FigInputFile extends HTMLElement {
15380
15736
  );
15381
15737
  }
15382
15738
 
15739
+ get #largePreviewEnabled() {
15740
+ return this.getAttribute("preview")?.toLowerCase() === "large";
15741
+ }
15742
+
15383
15743
  #hasKnownExtension(filename, extensions) {
15384
15744
  const lowerName = filename.toLowerCase();
15385
15745
  return extensions.some((extension) => lowerName.endsWith(extension));
@@ -15467,9 +15827,10 @@ class FigInputFile extends HTMLElement {
15467
15827
 
15468
15828
  #hasPreviewableImage() {
15469
15829
  if (this.#files?.length) {
15470
- return this.#hasKnownExtension(
15471
- this.#files[0].name,
15830
+ return this.#isKnownFile(
15831
+ this.#files[0],
15472
15832
  FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15833
+ "image/",
15473
15834
  );
15474
15835
  }
15475
15836
  return (
@@ -15498,9 +15859,90 @@ class FigInputFile extends HTMLElement {
15498
15859
  );
15499
15860
  }
15500
15861
 
15862
+ #largePreviewKind() {
15863
+ const file = this.#files?.[0];
15864
+ if (file) {
15865
+ if (
15866
+ this.#isKnownFile(
15867
+ file,
15868
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15869
+ "video/",
15870
+ )
15871
+ ) {
15872
+ return "video";
15873
+ }
15874
+ if (
15875
+ this.#isKnownFile(
15876
+ file,
15877
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15878
+ "image/",
15879
+ )
15880
+ ) {
15881
+ return "image";
15882
+ }
15883
+ return null;
15884
+ }
15885
+
15886
+ if (
15887
+ this.#hasKnownExtension(
15888
+ this.#urlFilename,
15889
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15890
+ )
15891
+ ) {
15892
+ return "video";
15893
+ }
15894
+ if (
15895
+ this.#hasKnownExtension(
15896
+ this.#urlFilename,
15897
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15898
+ )
15899
+ ) {
15900
+ return "image";
15901
+ }
15902
+
15903
+ const acceptedKinds = this.#effectiveAccepts
15904
+ .split(",")
15905
+ .map((value) => value.trim().toLowerCase())
15906
+ .filter(Boolean)
15907
+ .map((value) => {
15908
+ if (
15909
+ value.startsWith("video/") ||
15910
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS.includes(value)
15911
+ ) {
15912
+ return "video";
15913
+ }
15914
+ if (
15915
+ value.startsWith("image/") ||
15916
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS.includes(value)
15917
+ ) {
15918
+ return "image";
15919
+ }
15920
+ return null;
15921
+ });
15922
+ if (
15923
+ acceptedKinds.length > 0 &&
15924
+ acceptedKinds.every((kind) => kind === acceptedKinds[0])
15925
+ ) {
15926
+ return acceptedKinds[0];
15927
+ }
15928
+ return null;
15929
+ }
15930
+
15501
15931
  #selectedFileIcon() {
15502
15932
  if (this.#files && this.#files.length > 0) {
15503
15933
  const files = Array.from(this.#files);
15934
+ if (
15935
+ this.#previewEnabled &&
15936
+ files.every((file) =>
15937
+ this.#isKnownFile(
15938
+ file,
15939
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15940
+ "video/",
15941
+ ),
15942
+ )
15943
+ ) {
15944
+ return "video";
15945
+ }
15504
15946
  if (
15505
15947
  files.every((file) =>
15506
15948
  this.#isKnownFile(
@@ -15524,6 +15966,15 @@ class FigInputFile extends HTMLElement {
15524
15966
  return "image";
15525
15967
  }
15526
15968
  }
15969
+ if (
15970
+ this.#previewEnabled &&
15971
+ this.#hasKnownExtension(
15972
+ this.#urlFilename,
15973
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15974
+ )
15975
+ ) {
15976
+ return "video";
15977
+ }
15527
15978
  if (
15528
15979
  this.#hasKnownExtension(
15529
15980
  this.#urlFilename,
@@ -15553,7 +16004,7 @@ class FigInputFile extends HTMLElement {
15553
16004
  disconnectedCallback() {
15554
16005
  this.#fileInput?.removeEventListener("change", this.#onFileChange);
15555
16006
  this.#clearBtn?.removeEventListener("click", this.#onClear);
15556
- this.#disconnectPreview();
16007
+ this.#disconnectImagePreview();
15557
16008
  this.#disconnectAudioPreview();
15558
16009
  this.removeEventListener("dragover", this.#onDragOver);
15559
16010
  this.removeEventListener("dragleave", this.#onDragLeave);
@@ -15581,10 +16032,17 @@ class FigInputFile extends HTMLElement {
15581
16032
  this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
15582
16033
  }
15583
16034
 
16035
+ #removeExternalFileAttributes() {
16036
+ this.#suppressAttributeRender = true;
16037
+ this.removeAttribute("url");
16038
+ this.removeAttribute("filename");
16039
+ this.#suppressAttributeRender = false;
16040
+ }
16041
+
15584
16042
  #onFileChange = () => {
15585
16043
  if (this.#fileInput.files.length > 0) {
15586
16044
  this.#files = this.#fileInput.files;
15587
- this.removeAttribute("url");
16045
+ this.#removeExternalFileAttributes();
15588
16046
  this.#render();
15589
16047
  this.#emitEvents();
15590
16048
  }
@@ -15596,7 +16054,6 @@ class FigInputFile extends HTMLElement {
15596
16054
  };
15597
16055
 
15598
16056
  #onDragOver = (e) => {
15599
- if (this.#previewImage) return;
15600
16057
  if (
15601
16058
  this.hasAttribute("disabled") &&
15602
16059
  this.getAttribute("disabled") !== "false"
@@ -15613,7 +16070,6 @@ class FigInputFile extends HTMLElement {
15613
16070
  };
15614
16071
 
15615
16072
  #onDragLeave = () => {
15616
- if (this.#previewImage) return;
15617
16073
  this.removeAttribute("dragover");
15618
16074
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
15619
16075
  this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
@@ -15622,7 +16078,6 @@ class FigInputFile extends HTMLElement {
15622
16078
  };
15623
16079
 
15624
16080
  #onDrop = (e) => {
15625
- if (this.#previewImage) return;
15626
16081
  e.preventDefault();
15627
16082
  this.removeAttribute("dragover");
15628
16083
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
@@ -15664,59 +16119,15 @@ class FigInputFile extends HTMLElement {
15664
16119
  if (this.#fileInput) {
15665
16120
  this.#fileInput.files = dt.files;
15666
16121
  }
15667
- this.removeAttribute("url");
15668
- this.#render();
15669
- this.#emitEvents();
15670
- };
15671
-
15672
- #stopPreviewInputEvent = (e) => {
15673
- e.stopPropagation();
15674
- };
15675
-
15676
- #removeExternalFileAttributes() {
15677
- this.#suppressAttributeRender = true;
15678
- this.removeAttribute("url");
15679
- this.removeAttribute("filename");
15680
- this.#suppressAttributeRender = false;
15681
- }
15682
-
15683
- #onPreviewLoaded = (e) => {
15684
- if (e.currentTarget !== this.#previewImage) return;
15685
- e.stopPropagation();
15686
- const file = e.detail?.file;
15687
- if (!file || this.#files?.[0] === file) return;
15688
-
15689
- const dt = new DataTransfer();
15690
- const selectedFiles =
15691
- this.#previewImage.querySelector("fig-input-file[data-generated]")?.files;
15692
- if (selectedFiles?.length) {
15693
- Array.from(selectedFiles).forEach((selectedFile) =>
15694
- dt.items.add(selectedFile),
15695
- );
15696
- } else {
15697
- dt.items.add(file);
15698
- }
15699
- this.#files = dt.files;
15700
- this.#removeExternalFileAttributes();
15701
- if (!this.#syncingPreview) this.#emitEvents();
15702
- };
15703
-
15704
- #onPreviewChange = (e) => {
15705
- if (e.currentTarget !== this.#previewImage) return;
15706
- e.stopPropagation();
15707
- if (this.#previewImage.file || (!this.#files && !this.#urlFilename)) return;
15708
-
15709
- this.#files = null;
15710
16122
  this.#removeExternalFileAttributes();
15711
16123
  this.#render();
15712
- if (!this.#syncingPreview) this.#emitEvents({ cleared: true });
16124
+ this.#emitEvents();
15713
16125
  };
15714
16126
 
15715
- #disconnectPreview() {
15716
- if (!this.#previewImage) return;
15717
- this.#previewImage.removeEventListener("loaded", this.#onPreviewLoaded);
15718
- this.#previewImage.removeEventListener("change", this.#onPreviewChange);
15719
- this.#previewImage = null;
16127
+ #disconnectImagePreview() {
16128
+ if (!this.#imageBlobUrl) return;
16129
+ URL.revokeObjectURL(this.#imageBlobUrl);
16130
+ this.#imageBlobUrl = null;
15720
16131
  }
15721
16132
 
15722
16133
  #disconnectAudioPreview() {
@@ -15801,38 +16212,69 @@ class FigInputFile extends HTMLElement {
15801
16212
  this.append(audio);
15802
16213
  }
15803
16214
 
15804
- #renderImagePreview({ accepts, label, disabled, multiple }) {
15805
- const image = document.createElement("fig-image");
15806
- image.setAttribute("full", "");
15807
- image.setAttribute("checkerboard", "");
15808
- image.setAttribute("upload", "");
15809
- image.setAttribute("aspect-ratio", "4/3");
15810
- image.setAttribute("label", label);
15811
- const url = this.getAttribute("url");
15812
- if (url) image.setAttribute("src", url);
15813
-
15814
- this.#previewImage = image;
15815
- image.addEventListener("loaded", this.#onPreviewLoaded);
15816
- image.addEventListener("change", this.#onPreviewChange);
15817
- this.appendChild(image);
16215
+ #createImagePreviewSwatch() {
16216
+ const swatch = document.createElement("fig-swatch");
16217
+ const file = this.#files?.[0];
16218
+ const url = file
16219
+ ? (this.#imageBlobUrl = URL.createObjectURL(file))
16220
+ : this.getAttribute("url") || "";
16221
+ swatch.setAttribute("background", `url(${JSON.stringify(url)})`);
16222
+ swatch.setAttribute("slot", "prepend");
16223
+ return swatch;
16224
+ }
15818
16225
 
15819
- const nestedInput = image.querySelector("fig-input-file[data-generated]");
15820
- if (!nestedInput) return;
15821
- nestedInput.addEventListener("input", this.#stopPreviewInputEvent);
15822
- nestedInput.addEventListener("change", this.#stopPreviewInputEvent);
15823
- nestedInput.setAttribute("accepts", accepts);
15824
- if (disabled) nestedInput.setAttribute("disabled", "");
15825
- if (multiple) nestedInput.setAttribute("multiple", "");
16226
+ #createMultipleFileIcon(count) {
16227
+ const icon = document.createElement("fig-icon");
16228
+ icon.setAttribute("slot", "prepend");
16229
+ const label = document.createElement("label");
16230
+ label.textContent = String(count);
16231
+ icon.append(label);
16232
+ return icon;
16233
+ }
15826
16234
 
15827
- if (this.#files?.length) {
15828
- const nativeInput = nestedInput.querySelector('input[type="file"]');
15829
- if (nativeInput) {
15830
- this.#syncingPreview = true;
15831
- nativeInput.files = this.#files;
15832
- nativeInput.dispatchEvent(new Event("change", { bubbles: true }));
15833
- this.#syncingPreview = false;
15834
- }
16235
+ #onLargePreviewChange = (event) => {
16236
+ if (event.target !== event.currentTarget) {
16237
+ event.stopPropagation();
16238
+ return;
16239
+ }
16240
+ event.stopPropagation();
16241
+ const media = event.currentTarget;
16242
+ const file = media.file;
16243
+ if (file) {
16244
+ const dataTransfer = new DataTransfer();
16245
+ dataTransfer.items.add(file);
16246
+ this.#files = dataTransfer.files;
16247
+ this.#removeExternalFileAttributes();
16248
+ this.#render();
16249
+ this.#emitEvents();
16250
+ return;
15835
16251
  }
16252
+
16253
+ this.#files = null;
16254
+ this.#removeExternalFileAttributes();
16255
+ this.#render();
16256
+ this.#emitEvents({ cleared: true });
16257
+ };
16258
+
16259
+ #renderLargePreview(kind, disabled) {
16260
+ const media = document.createElement(
16261
+ kind === "video" ? "fig-video" : "fig-image",
16262
+ );
16263
+ media.setAttribute("upload", "");
16264
+ media.setAttribute("full", "");
16265
+ media.setAttribute("checkerboard", "");
16266
+ const file = this.#files?.[0];
16267
+ const url = file
16268
+ ? (this.#imageBlobUrl = URL.createObjectURL(file))
16269
+ : this.getAttribute("url") || "";
16270
+ if (url) media.setAttribute("src", url);
16271
+ media.addEventListener("change", this.#onLargePreviewChange);
16272
+ this.append(media);
16273
+ const nestedInput = media.querySelector(
16274
+ ":scope > fig-preview > fig-input-file[data-generated]",
16275
+ );
16276
+ if (disabled) nestedInput?.setAttribute("disabled", "");
16277
+ this.setAttribute("data-previewing", "large");
15836
16278
  }
15837
16279
 
15838
16280
  #render() {
@@ -15849,28 +16291,46 @@ class FigInputFile extends HTMLElement {
15849
16291
  (this.#files && this.#files.length > 0) ||
15850
16292
  !!this.getAttribute("url") ||
15851
16293
  !!this.getAttribute("filename");
16294
+ const showsMultiplePreview =
16295
+ this.#previewEnabled &&
16296
+ multiple &&
16297
+ (this.#files?.length ?? 0) > 1;
15852
16298
  const showsImagePreview =
15853
- this.#previewEnabled && this.#hasPreviewableImage();
16299
+ !showsMultiplePreview &&
16300
+ this.#previewEnabled &&
16301
+ this.#hasPreviewableImage();
15854
16302
  const showsAudioPreview =
15855
- this.#previewEnabled && this.#hasPreviewableAudio();
16303
+ !showsMultiplePreview &&
16304
+ this.#previewEnabled &&
16305
+ this.#hasPreviewableAudio();
15856
16306
 
15857
- this.#disconnectPreview();
16307
+ this.#disconnectImagePreview();
15858
16308
  this.#disconnectAudioPreview();
15859
16309
  this.replaceChildren();
15860
16310
  this.#fileInput = null;
15861
16311
  this.#uploadBtn = null;
15862
16312
  this.#clearBtn = null;
15863
16313
  this.#tooltipEl = null;
15864
- this.toggleAttribute("data-previewing", showsImagePreview);
16314
+ this.removeAttribute("data-previewing");
15865
16315
 
15866
- if (showsImagePreview) {
15867
- this.#renderImagePreview({ accepts, label, disabled, multiple });
16316
+ const largePreviewKind =
16317
+ this.#largePreviewEnabled && !showsMultiplePreview
16318
+ ? this.#largePreviewKind()
16319
+ : null;
16320
+ if (largePreviewKind) {
16321
+ this.#renderLargePreview(largePreviewKind, disabled);
15868
16322
  return;
15869
16323
  }
15870
16324
 
15871
16325
  if (hasFile) {
15872
16326
  const filename = this.value;
15873
- const tooltipText = showsAudioPreview
16327
+ const selectedFileNames =
16328
+ multiple && this.#files?.length
16329
+ ? Array.from(this.#files, (file) => file.name).join(", ")
16330
+ : "";
16331
+ const tooltipText = selectedFileNames
16332
+ ? selectedFileNames
16333
+ : showsAudioPreview
15874
16334
  ? filename
15875
16335
  : accepts
15876
16336
  ? `Accepts ${accepts
@@ -15884,9 +16344,13 @@ class FigInputFile extends HTMLElement {
15884
16344
  if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15885
16345
  this.#uploadBtn.className = "fig-input-file-filename";
15886
16346
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
15887
- const fileIcon = createFigIcon(
15888
- showsAudioPreview ? "play" : this.#selectedFileIcon(),
15889
- );
16347
+ const fileIcon = showsMultiplePreview
16348
+ ? this.#createMultipleFileIcon(this.#files.length)
16349
+ : showsImagePreview
16350
+ ? this.#createImagePreviewSwatch()
16351
+ : createFigIcon(
16352
+ showsAudioPreview ? "play" : this.#selectedFileIcon(),
16353
+ );
15890
16354
  if (showsAudioPreview) {
15891
16355
  this.#audioIcon = fileIcon;
15892
16356
  fileIcon.setAttribute("slot", "prepend");
@@ -15895,9 +16359,14 @@ class FigInputFile extends HTMLElement {
15895
16359
  const truncEl = document.createElement("fig-truncate");
15896
16360
  truncEl.setAttribute("position", "middle");
15897
16361
  truncEl.setAttribute("tooltip", "");
15898
- const dotIdx = filename.lastIndexOf(".");
15899
- if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
15900
- truncEl.textContent = filename;
16362
+ const buttonLabel = showsMultiplePreview
16363
+ ? "Multiple files"
16364
+ : filename;
16365
+ const dotIdx = buttonLabel.lastIndexOf(".");
16366
+ if (!showsMultiplePreview && dotIdx > 0) {
16367
+ truncEl.setAttribute("tail", buttonLabel.slice(dotIdx));
16368
+ }
16369
+ truncEl.textContent = buttonLabel;
15901
16370
  fileIcon.setAttribute("slot", "prepend");
15902
16371
  this.#uploadBtn.append(fileIcon, truncEl);
15903
16372
  }
@@ -20310,6 +20779,7 @@ const FIG_ICON_TOKENS = {
20310
20779
  download: "--icon-24-download",
20311
20780
  upload: "--icon-24-upload",
20312
20781
  image: "--icon-24-image",
20782
+ video: { medium: "--icon-24-video", small: "--icon-16-video" },
20313
20783
  sound: { medium: "--icon-24-sound", small: "--icon-16-sound" },
20314
20784
  "sound-off": {
20315
20785
  medium: "--icon-24-sound-off",