@rogieking/figui3 11.2.2 → 11.3.1

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
@@ -6443,7 +6443,7 @@ class FigReorder extends HTMLElement {
6443
6443
  "fig-input-gradient",
6444
6444
  "fig-easing-curve",
6445
6445
  "fig-angle",
6446
- "fig-input-audio",
6446
+ "fig-audio-waveform",
6447
6447
  "fig-input-wheel",
6448
6448
  "fig-joystick",
6449
6449
  "fig-origin-grid",
package/fig-lab.css CHANGED
@@ -217,121 +217,6 @@ fig-chat-message {
217
217
  }
218
218
  }
219
219
 
220
- fig-input-audio {
221
- --fig-input-audio-waveform-fade: 1rem;
222
- --fig-input-audio-waveform-opacity: 0.5;
223
- --fig-input-audio-waveform-opacity-min: 0.1;
224
- --fig-input-audio-waveform-stroke-width: 1.5px;
225
-
226
- position: relative;
227
- display: inline-flex;
228
- box-sizing: border-box;
229
- max-width: 100%;
230
-
231
- &[full]:not([full="false"]) {
232
- width: 100%;
233
- }
234
-
235
- .fig-input-audio-waveform {
236
- display: none;
237
- }
238
-
239
- &[data-has-audio] {
240
- width: 18rem;
241
- height: var(--control-height);
242
- border-radius: var(--radius-medium);
243
- background-color: var(--figma-color-bg-secondary);
244
-
245
- &[full]:not([full="false"]) {
246
- width: 100%;
247
- }
248
-
249
- .fig-input-audio-waveform {
250
- position: absolute;
251
- inset: 0;
252
- display: block;
253
- overflow: hidden;
254
- border-radius: inherit;
255
- color: var(--figma-color-icon-secondary);
256
- pointer-events: none;
257
- }
258
-
259
- .fig-input-audio-waveform > svg {
260
- display: block;
261
- width: calc(100% - var(--spacer-3));
262
- height: 100%;
263
- margin-inline: var(--spacer-2);
264
- overflow: visible;
265
- mask-image: linear-gradient(
266
- to right,
267
- rgb(0 0 0 / var(--fig-input-audio-waveform-opacity-min)) 0,
268
- rgb(0 0 0 / var(--fig-input-audio-waveform-opacity))
269
- var(--fig-input-audio-waveform-fade),
270
- rgb(0 0 0 / var(--fig-input-audio-waveform-opacity))
271
- calc(100% - var(--fig-input-audio-waveform-fade)),
272
- rgb(0 0 0 / var(--fig-input-audio-waveform-opacity-min)) 100%
273
- );
274
- }
275
-
276
- .fig-input-audio-waveform path {
277
- fill: currentColor;
278
- stroke: none;
279
- }
280
-
281
- .fig-input-audio-file {
282
- position: absolute;
283
- inset: 0;
284
- z-index: 1;
285
- display: flex;
286
- width: 100%;
287
- height: 100%;
288
- }
289
-
290
- .fig-input-audio-file > fig-tooltip:has(.fig-input-file-filename) {
291
- width: 100%;
292
- height: 100%;
293
- }
294
-
295
- .fig-input-audio-file .fig-input-file-filename {
296
- width: 100%;
297
- height: 100%;
298
- opacity: 0;
299
- }
300
-
301
- .fig-input-audio-file .fig-input-file-clear {
302
- visibility: hidden;
303
- pointer-events: none;
304
- }
305
-
306
- &:hover .fig-input-file-clear,
307
- &:focus-within .fig-input-file-clear {
308
- visibility: visible;
309
- pointer-events: auto;
310
- }
311
-
312
- &:hover .fig-input-file-clear {
313
- background-color: var(--figma-color-bg-secondary);
314
- }
315
-
316
- &:has(.fig-input-audio-file input[type="file"]:focus-visible)
317
- .fig-input-audio-waveform,
318
- &:has(.fig-input-file-clear:focus) .fig-input-audio-waveform {
319
- outline: var(--figma-focus-outline);
320
- outline-offset: var(--figma-focus-outline-offset);
321
- }
322
-
323
- &:has(.fig-input-audio-file[dragover]) .fig-input-audio-waveform {
324
- outline: 1px dashed var(--figma-color-border-brand);
325
- outline-offset: -1px;
326
- }
327
- }
328
-
329
- &[disabled]:not([disabled="false"]) {
330
- pointer-events: none;
331
- opacity: 0.5;
332
- }
333
- }
334
-
335
220
  fig-input-wheel {
336
221
  --fig-input-wheel-handle-drag-max: 0.5rem;
337
222
  --fig-input-wheel-handle-drag-offset: 0px;
package/fig-lab.js CHANGED
@@ -289,374 +289,6 @@ class FigAttachments extends HTMLElement {
289
289
  }
290
290
  figLabDefineElement("fig-attachments", FigAttachments);
291
291
 
292
- /**
293
- * Audio file input with a responsive waveform preview.
294
- *
295
- * @attr {string} url - Audio URL to preview when CORS permits.
296
- * @attr {string} filename - Display filename for URL-backed audio.
297
- * @attr {string} label - Empty-state upload label. Defaults to "Upload audio".
298
- * @attr {string} accepts - Accepted file types. Defaults to "audio/*".
299
- * @attr {string} variant - Forwarded fig-input-file button variant.
300
- * @attr {boolean|string} disabled - Disables upload, replacement, and removal.
301
- * @attr {boolean|string} full - Makes the input fill its container.
302
- * @fires input - Composed file input event.
303
- * @fires change - Composed file change event.
304
- */
305
- class FigInputAudio extends HTMLElement {
306
- static observedAttributes = [
307
- "url",
308
- "filename",
309
- "label",
310
- "accepts",
311
- "variant",
312
- "disabled",
313
- "full",
314
- ];
315
-
316
- #fileInput = null;
317
- #surface = null;
318
- #svg = null;
319
- #waveformPath = null;
320
- #resizeObserver = null;
321
- #abortController = null;
322
- #audioContext = null;
323
- #peaks = [];
324
- #generation = 0;
325
- #layoutFrame = 0;
326
-
327
- get files() {
328
- return this.#fileInput?.files ?? null;
329
- }
330
-
331
- get value() {
332
- return this.#fileInput?.value ?? "";
333
- }
334
-
335
- connectedCallback() {
336
- if (!this.#fileInput) this.#render();
337
- this.#observeWaveform();
338
- this.#syncAttributes();
339
- this.#syncSource();
340
- }
341
-
342
- disconnectedCallback() {
343
- this.#cancelDecode();
344
- this.#resizeObserver?.disconnect();
345
- this.#resizeObserver = null;
346
- if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
347
- this.#layoutFrame = 0;
348
- }
349
-
350
- attributeChangedCallback(name, oldValue, newValue) {
351
- if (oldValue === newValue || !this.#fileInput) return;
352
- this.#syncAttributes();
353
- if (name === "url") this.#syncSource();
354
- }
355
-
356
- clear() {
357
- this.removeAttribute("url");
358
- this.removeAttribute("filename");
359
- this.#fileInput?.clear();
360
- }
361
-
362
- #render() {
363
- this.#surface = document.createElement("div");
364
- this.#surface.className = "fig-input-audio-waveform";
365
- this.#surface.setAttribute("aria-hidden", "true");
366
-
367
- this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
368
- this.#svg.setAttribute("aria-hidden", "true");
369
- this.#svg.setAttribute("preserveAspectRatio", "none");
370
- this.#waveformPath = document.createElementNS(
371
- "http://www.w3.org/2000/svg",
372
- "path",
373
- );
374
- this.#svg.appendChild(this.#waveformPath);
375
- this.#surface.appendChild(this.#svg);
376
-
377
- this.#fileInput = document.createElement("fig-input-file");
378
- this.#fileInput.className = "fig-input-audio-file";
379
- this.#fileInput.addEventListener("input", this.#onFileEvent);
380
- this.#fileInput.addEventListener("change", this.#onFileEvent);
381
-
382
- this.replaceChildren(this.#surface, this.#fileInput);
383
-
384
- this.#observeWaveform();
385
- }
386
-
387
- #observeWaveform() {
388
- if (this.#resizeObserver || !this.#svg) return;
389
- this.#resizeObserver = new ResizeObserver(() => this.#queueWaveformLayout());
390
- this.#resizeObserver.observe(this.#svg);
391
- }
392
-
393
- #syncAttributes() {
394
- const forwarded = ["url", "filename", "variant"];
395
- for (const name of forwarded) {
396
- const value = this.getAttribute(name);
397
- if (value === null) this.#fileInput.removeAttribute(name);
398
- else this.#fileInput.setAttribute(name, value);
399
- }
400
-
401
- this.#fileInput.setAttribute("accepts", this.getAttribute("accepts") || "audio/*");
402
- this.#fileInput.setAttribute("label", this.getAttribute("label") || "Upload audio");
403
- this.#toggleBooleanAttribute(this.#fileInput, "disabled", this.#isDisabled());
404
- this.#toggleBooleanAttribute(this.#fileInput, "full", this.#isTruthyAttribute("full"));
405
- this.#syncInteractiveLabels();
406
- this.#syncLoadedState();
407
- }
408
-
409
- #toggleBooleanAttribute(element, name, enabled) {
410
- if (enabled) element.setAttribute(name, "");
411
- else element.removeAttribute(name);
412
- }
413
-
414
- #isTruthyAttribute(name) {
415
- return this.hasAttribute(name) && this.getAttribute(name) !== "false";
416
- }
417
-
418
- #isDisabled() {
419
- return this.#isTruthyAttribute("disabled");
420
- }
421
-
422
- #syncLoadedState() {
423
- const hasAudio =
424
- (this.#fileInput?.files && this.#fileInput.files.length > 0) ||
425
- !!this.getAttribute("url");
426
- this.toggleAttribute("data-has-audio", hasAudio);
427
- this.#syncInteractiveLabels();
428
- }
429
-
430
- #syncInteractiveLabels() {
431
- const nativeInput = this.#fileInput?.querySelector('input[type="file"]');
432
- if (nativeInput) {
433
- nativeInput.setAttribute(
434
- "aria-label",
435
- this.hasAttribute("data-has-audio") ? "Replace audio" : "Upload audio",
436
- );
437
- }
438
- const clearButton = this.#fileInput?.querySelector(".fig-input-file-clear");
439
- clearButton?.setAttribute("aria-label", "Remove audio");
440
- }
441
-
442
- #onFileEvent = (event) => {
443
- event.stopPropagation();
444
- if (event.target !== this.#fileInput) return;
445
- const detail = event.detail || { files: this.#fileInput.files };
446
-
447
- if (event.type === "input") {
448
- const file = this.#fileInput.files?.[0] || null;
449
- if (file) {
450
- const replacedUrl = this.hasAttribute("url");
451
- if (replacedUrl) this.removeAttribute("url");
452
- this.#syncLoadedState();
453
- if (!replacedUrl) this.#decodeSource(() => file.arrayBuffer());
454
- } else {
455
- if (detail.cleared) {
456
- this.removeAttribute("url");
457
- this.removeAttribute("filename");
458
- }
459
- this.#clearWaveform();
460
- this.#syncLoadedState();
461
- }
462
- this.#syncInteractiveLabels();
463
- }
464
-
465
- this.dispatchEvent(
466
- new CustomEvent(event.type, {
467
- bubbles: true,
468
- composed: true,
469
- detail,
470
- }),
471
- );
472
- };
473
-
474
- #syncSource() {
475
- const file = this.#fileInput?.files?.[0];
476
- if (file) {
477
- this.#syncLoadedState();
478
- this.#decodeSource(() => file.arrayBuffer());
479
- return;
480
- }
481
-
482
- const url = this.getAttribute("url");
483
- this.#syncLoadedState();
484
- if (!url) {
485
- this.#clearWaveform();
486
- return;
487
- }
488
-
489
- this.#decodeSource(async (signal) => {
490
- const response = await fetch(url, { signal });
491
- if (!response.ok) throw new Error(`Audio request failed: ${response.status}`);
492
- return response.arrayBuffer();
493
- });
494
- }
495
-
496
- async #decodeSource(loadArrayBuffer) {
497
- this.#cancelDecode();
498
- const generation = ++this.#generation;
499
- const controller = new AbortController();
500
- this.#abortController = controller;
501
- this.setAttribute("data-waveform-state", "loading");
502
- this.setAttribute("aria-busy", "true");
503
- this.#peaks = [];
504
- this.#queueWaveformLayout();
505
-
506
- let context = null;
507
- try {
508
- const arrayBuffer = await loadArrayBuffer(controller.signal);
509
- if (generation !== this.#generation || !this.isConnected) return;
510
-
511
- const AudioContextConstructor =
512
- window.AudioContext || window.webkitAudioContext;
513
- if (!AudioContextConstructor) throw new Error("Web Audio is unavailable");
514
- context = new AudioContextConstructor();
515
- this.#audioContext = context;
516
- const audioBuffer = await context.decodeAudioData(arrayBuffer);
517
- if (generation !== this.#generation || !this.isConnected) return;
518
-
519
- this.#peaks = this.#extractPeaks(audioBuffer);
520
- this.setAttribute("data-waveform-state", "ready");
521
- this.#queueWaveformLayout();
522
- } catch (error) {
523
- if (generation !== this.#generation || error?.name === "AbortError") return;
524
- this.#peaks = new Array(256).fill(0);
525
- this.setAttribute("data-waveform-state", "unavailable");
526
- this.#queueWaveformLayout();
527
- } finally {
528
- if (context) {
529
- try {
530
- await context.close();
531
- } catch {
532
- // Closing is best-effort in browsers with suspended audio contexts.
533
- }
534
- }
535
- if (this.#audioContext === context) this.#audioContext = null;
536
- if (this.#abortController === controller) this.#abortController = null;
537
- if (generation === this.#generation) this.removeAttribute("aria-busy");
538
- }
539
- }
540
-
541
- #extractPeaks(audioBuffer) {
542
- const binCount = 256;
543
- const peaks = new Array(binCount).fill(0);
544
- const channelCount = audioBuffer.numberOfChannels;
545
- const frameCount = audioBuffer.length;
546
- const sampleStride = Math.max(1, Math.ceil(frameCount / 500000));
547
- let maximum = 0;
548
-
549
- for (let channel = 0; channel < channelCount; channel += 1) {
550
- const samples = audioBuffer.getChannelData(channel);
551
- for (let bin = 0; bin < binCount; bin += 1) {
552
- const start = Math.floor((bin / binCount) * frameCount);
553
- const end = Math.max(start + 1, Math.floor(((bin + 1) / binCount) * frameCount));
554
- let peak = peaks[bin];
555
- for (let index = start; index < end; index += sampleStride) {
556
- const magnitude = Math.abs(samples[index] || 0);
557
- if (Number.isFinite(magnitude)) peak = Math.max(peak, magnitude);
558
- }
559
- peaks[bin] = peak;
560
- maximum = Math.max(maximum, peak);
561
- }
562
- }
563
-
564
- if (!Number.isFinite(maximum) || maximum <= 0) return peaks.fill(0);
565
- return peaks.map((peak) => {
566
- const normalized = Math.sqrt(peak / maximum);
567
- return Number.isFinite(normalized) ? normalized : 0;
568
- });
569
- }
570
-
571
- #clearWaveform() {
572
- this.#cancelDecode();
573
- this.#peaks = [];
574
- this.removeAttribute("data-waveform-state");
575
- this.removeAttribute("aria-busy");
576
- this.#queueWaveformLayout();
577
- }
578
-
579
- #cancelDecode() {
580
- this.#generation += 1;
581
- this.#abortController?.abort();
582
- this.#abortController = null;
583
- if (this.#audioContext) {
584
- try {
585
- this.#audioContext.close();
586
- } catch {
587
- // Closing is best-effort during teardown and source replacement.
588
- }
589
- this.#audioContext = null;
590
- }
591
- }
592
-
593
- #queueWaveformLayout() {
594
- if (this.#layoutFrame) return;
595
- this.#layoutFrame = requestAnimationFrame(() => {
596
- this.#layoutFrame = 0;
597
- this.#layoutWaveform();
598
- });
599
- }
600
-
601
- #layoutWaveform() {
602
- if (!this.#svg || !this.#waveformPath) return;
603
- const bounds = this.#svg.getBoundingClientRect();
604
- const width = Math.max(1, Math.round(bounds.width));
605
- const height = Math.max(1, Math.round(bounds.height));
606
- this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
607
-
608
- if (this.#peaks.length === 0) {
609
- this.#waveformPath.setAttribute("d", "");
610
- return;
611
- }
612
-
613
- const barCount = Math.min(
614
- this.#peaks.length,
615
- Math.max(12, Math.floor(width / 4)),
616
- );
617
- const cellWidth = width / barCount;
618
- const configuredStrokeWidth = Number.parseFloat(
619
- getComputedStyle(this).getPropertyValue(
620
- "--fig-input-audio-waveform-stroke-width",
621
- ),
622
- );
623
- const strokeWidth =
624
- Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0
625
- ? configuredStrokeWidth
626
- : 2;
627
- const barWidth = Math.max(0.5, Math.min(strokeWidth, cellWidth * 0.75));
628
- const halfWidth = barWidth / 2;
629
- const centerY = height / 2;
630
- const maxBarHeight = Math.max(2, height - 12);
631
- const commands = [];
632
-
633
- for (let index = 0; index < barCount; index += 1) {
634
- const peakIndex = Math.min(
635
- this.#peaks.length - 1,
636
- Math.floor((index / barCount) * this.#peaks.length),
637
- );
638
- const barHeight = Math.max(barWidth, this.#peaks[peakIndex] * maxBarHeight);
639
- const x = (index + 0.5) * cellWidth;
640
- const top = centerY - barHeight / 2;
641
- const bottom = centerY + barHeight / 2;
642
- const left = x - halfWidth;
643
- const right = x + halfWidth;
644
- commands.push(
645
- `M ${x} ${top}` +
646
- ` Q ${right} ${top} ${right} ${top + halfWidth}` +
647
- ` V ${bottom - halfWidth}` +
648
- ` Q ${right} ${bottom} ${x} ${bottom}` +
649
- ` Q ${left} ${bottom} ${left} ${bottom - halfWidth}` +
650
- ` V ${top + halfWidth}` +
651
- ` Q ${left} ${top} ${x} ${top} Z`,
652
- );
653
- }
654
-
655
- this.#waveformPath.setAttribute("d", commands.join(" "));
656
- }
657
- }
658
- figLabDefineElement("fig-input-audio", FigInputAudio);
659
-
660
292
  /**
661
293
  * Standalone interactive numeric wheel.
662
294
  *