@rogieking/figui3 9.2.3 → 9.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/.cursor/skills/fig-lab/SKILL.md +3 -3
- package/.cursor/skills/fig-lab/components.md +22 -2
- package/.cursor/skills/fig-lab/reference.md +22 -2
- package/.cursor/skills/figui3/SKILL.md +2 -2
- package/.cursor/skills/figui3/components.md +27 -0
- package/.cursor/skills/figui3/react.md +4 -1
- package/.cursor/skills/figui3/reference.md +10 -0
- package/CHANGELOG.md +3951 -0
- package/README.md +69 -4
- package/components.css +248 -3
- package/dist/components.css +1 -1
- package/dist/fig-lab.css +1 -1
- package/dist/fig-lab.js +1 -1
- package/dist/fig.css +1 -1
- package/dist/fig.js +5 -5
- package/fig-lab.css +116 -1
- package/fig-lab.js +447 -5
- package/fig.js +507 -0
- package/package.json +2 -4
package/fig-lab.css
CHANGED
|
@@ -217,6 +217,121 @@ 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
|
+
|
|
220
335
|
fig-input-wheel {
|
|
221
336
|
--fig-input-wheel-handle-drag-max: 0.5rem;
|
|
222
337
|
--fig-input-wheel-handle-drag-offset: 0px;
|
|
@@ -504,7 +619,7 @@ fig-reorder:not([handle]):is(:not([disabled]), [disabled="false"]) > [data-reord
|
|
|
504
619
|
}
|
|
505
620
|
|
|
506
621
|
fig-reorder > .dragging {
|
|
507
|
-
background-color: var(--figma-color-bg-
|
|
622
|
+
background-color: var(--figma-color-bg-secondary);
|
|
508
623
|
}
|
|
509
624
|
|
|
510
625
|
[data-reorder-handle] {
|
package/fig-lab.js
CHANGED
|
@@ -288,6 +288,375 @@ class FigAttachments extends HTMLElement {
|
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
figLabDefineElement("fig-attachments", FigAttachments);
|
|
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
|
+
|
|
291
660
|
/**
|
|
292
661
|
* Standalone interactive numeric wheel.
|
|
293
662
|
*
|
|
@@ -2535,7 +2904,7 @@ class FigCanvasControl extends HTMLElement {
|
|
|
2535
2904
|
figLabDefineElement("fig-canvas-control", FigCanvasControl);
|
|
2536
2905
|
/* Reorder wrapper */
|
|
2537
2906
|
class FigReorder extends HTMLElement {
|
|
2538
|
-
static observedAttributes = ["axis", "handle", "disabled"];
|
|
2907
|
+
static observedAttributes = ["axis", "handle", "items", "disabled"];
|
|
2539
2908
|
|
|
2540
2909
|
static #DRAG_THRESHOLD = 6;
|
|
2541
2910
|
|
|
@@ -2550,6 +2919,7 @@ class FigReorder extends HTMLElement {
|
|
|
2550
2919
|
"fig-input-gradient",
|
|
2551
2920
|
"fig-easing-curve",
|
|
2552
2921
|
"fig-angle",
|
|
2922
|
+
"fig-input-audio",
|
|
2553
2923
|
"fig-input-wheel",
|
|
2554
2924
|
"fig-joystick",
|
|
2555
2925
|
"fig-origin-grid",
|
|
@@ -2557,7 +2927,19 @@ class FigReorder extends HTMLElement {
|
|
|
2557
2927
|
"fig-canvas-control",
|
|
2558
2928
|
];
|
|
2559
2929
|
|
|
2930
|
+
static #OWNED_ATTRIBUTES = new Set([
|
|
2931
|
+
"data-reorder-item",
|
|
2932
|
+
"data-reorder-generated-role",
|
|
2933
|
+
"data-reorder-handle",
|
|
2934
|
+
"role",
|
|
2935
|
+
"tabindex",
|
|
2936
|
+
"aria-label",
|
|
2937
|
+
"aria-roledescription",
|
|
2938
|
+
]);
|
|
2939
|
+
|
|
2560
2940
|
#childObserver = null;
|
|
2941
|
+
#attributeObserver = null;
|
|
2942
|
+
#attributeSyncQueued = false;
|
|
2561
2943
|
#bindings = new Map();
|
|
2562
2944
|
#drag = null;
|
|
2563
2945
|
#indicator = null;
|
|
@@ -2573,11 +2955,14 @@ class FigReorder extends HTMLElement {
|
|
|
2573
2955
|
this.#syncChildren();
|
|
2574
2956
|
this.#childObserver = new MutationObserver(() => this.#syncChildren());
|
|
2575
2957
|
this.#childObserver.observe(this, { childList: true });
|
|
2958
|
+
this.#syncAttributeObserver();
|
|
2576
2959
|
}
|
|
2577
2960
|
|
|
2578
2961
|
disconnectedCallback() {
|
|
2579
2962
|
this.#childObserver?.disconnect();
|
|
2580
2963
|
this.#childObserver = null;
|
|
2964
|
+
this.#attributeObserver?.disconnect();
|
|
2965
|
+
this.#attributeObserver = null;
|
|
2581
2966
|
this.#unbindAll();
|
|
2582
2967
|
this.#cancelDrag();
|
|
2583
2968
|
this.#removeIndicator();
|
|
@@ -2588,10 +2973,39 @@ class FigReorder extends HTMLElement {
|
|
|
2588
2973
|
attributeChangedCallback() {
|
|
2589
2974
|
if (this.isConnected) {
|
|
2590
2975
|
if (this.#disabled) this.#cancelDrag();
|
|
2976
|
+
this.#syncAttributeObserver();
|
|
2591
2977
|
this.#syncChildren();
|
|
2592
2978
|
}
|
|
2593
2979
|
}
|
|
2594
2980
|
|
|
2981
|
+
refresh() {
|
|
2982
|
+
if (this.isConnected) this.#syncChildren();
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
#syncAttributeObserver() {
|
|
2986
|
+
if (!this.#itemsSelector) {
|
|
2987
|
+
this.#attributeObserver?.disconnect();
|
|
2988
|
+
this.#attributeObserver = null;
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (this.#attributeObserver) return;
|
|
2992
|
+
this.#attributeObserver = new MutationObserver((records) => {
|
|
2993
|
+
const relevant = records.some(
|
|
2994
|
+
(record) =>
|
|
2995
|
+
record.target.parentElement === this &&
|
|
2996
|
+
!FigReorder.#OWNED_ATTRIBUTES.has(record.attributeName) &&
|
|
2997
|
+
!(record.attributeName === "class" && record.target === this.#drag?.item),
|
|
2998
|
+
);
|
|
2999
|
+
if (!relevant || this.#attributeSyncQueued) return;
|
|
3000
|
+
this.#attributeSyncQueued = true;
|
|
3001
|
+
queueMicrotask(() => {
|
|
3002
|
+
this.#attributeSyncQueued = false;
|
|
3003
|
+
if (this.isConnected) this.#syncChildren();
|
|
3004
|
+
});
|
|
3005
|
+
});
|
|
3006
|
+
this.#attributeObserver.observe(this, { subtree: true, attributes: true });
|
|
3007
|
+
}
|
|
3008
|
+
|
|
2595
3009
|
get #disabled() {
|
|
2596
3010
|
return (
|
|
2597
3011
|
this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
|
|
@@ -2607,7 +3021,11 @@ class FigReorder extends HTMLElement {
|
|
|
2607
3021
|
return (this.getAttribute("handle") || "").trim();
|
|
2608
3022
|
}
|
|
2609
3023
|
|
|
2610
|
-
#
|
|
3024
|
+
get #itemsSelector() {
|
|
3025
|
+
return (this.getAttribute("items") || "").trim();
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
#getAllElementChildren() {
|
|
2611
3029
|
return [...this.children].filter(
|
|
2612
3030
|
(node) =>
|
|
2613
3031
|
node.nodeType === Node.ELEMENT_NODE &&
|
|
@@ -2615,7 +3033,19 @@ class FigReorder extends HTMLElement {
|
|
|
2615
3033
|
);
|
|
2616
3034
|
}
|
|
2617
3035
|
|
|
3036
|
+
#getElementChildren() {
|
|
3037
|
+
const children = this.#getAllElementChildren();
|
|
3038
|
+
const selector = this.#itemsSelector;
|
|
3039
|
+
if (!selector) return children;
|
|
3040
|
+
try {
|
|
3041
|
+
return children.filter((child) => child.matches(selector));
|
|
3042
|
+
} catch {
|
|
3043
|
+
return [];
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
2618
3047
|
#syncChildren() {
|
|
3048
|
+
const allChildren = this.#getAllElementChildren();
|
|
2619
3049
|
const children = this.#getElementChildren();
|
|
2620
3050
|
const childSet = new Set(children);
|
|
2621
3051
|
|
|
@@ -2632,8 +3062,9 @@ class FigReorder extends HTMLElement {
|
|
|
2632
3062
|
}
|
|
2633
3063
|
}
|
|
2634
3064
|
|
|
2635
|
-
this.#clearHandleMarks(
|
|
2636
|
-
this.#clearReorderItemMarks(
|
|
3065
|
+
this.#clearHandleMarks(allChildren);
|
|
3066
|
+
this.#clearReorderItemMarks(allChildren);
|
|
3067
|
+
this.#markNonItems(allChildren.filter((child) => !childSet.has(child)));
|
|
2637
3068
|
|
|
2638
3069
|
if (this.#disabled || children.length < 2) {
|
|
2639
3070
|
this.#unbindAll();
|
|
@@ -2680,6 +3111,15 @@ class FigReorder extends HTMLElement {
|
|
|
2680
3111
|
}
|
|
2681
3112
|
}
|
|
2682
3113
|
|
|
3114
|
+
#markNonItems(children) {
|
|
3115
|
+
for (const child of children) {
|
|
3116
|
+
if (!child.hasAttribute("role")) {
|
|
3117
|
+
child.setAttribute("role", "none");
|
|
3118
|
+
child.setAttribute("data-reorder-generated-role", "");
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
2683
3123
|
#clearHandleMarks(children) {
|
|
2684
3124
|
for (const child of children) {
|
|
2685
3125
|
child
|
|
@@ -3031,7 +3471,8 @@ class FigReorder extends HTMLElement {
|
|
|
3031
3471
|
const clamped = Math.max(0, Math.min(index, items.length));
|
|
3032
3472
|
|
|
3033
3473
|
if (clamped >= items.length) {
|
|
3034
|
-
|
|
3474
|
+
const last = items[items.length - 1];
|
|
3475
|
+
if (last && last !== item) last.after(item);
|
|
3035
3476
|
return;
|
|
3036
3477
|
}
|
|
3037
3478
|
|
|
@@ -3043,6 +3484,7 @@ class FigReorder extends HTMLElement {
|
|
|
3043
3484
|
const items = this.#getElementChildren().filter((candidate) => candidate !== item);
|
|
3044
3485
|
const ref = items[newIndex] ?? null;
|
|
3045
3486
|
if (ref) this.insertBefore(item, ref);
|
|
3487
|
+
else if (items.length) items[items.length - 1].after(item);
|
|
3046
3488
|
else this.appendChild(item);
|
|
3047
3489
|
}
|
|
3048
3490
|
|