PageSourceSearch

https://fairus-stepaudio-3-music.hf.space/studio/assets/js/ui.js

js fairus-stepaudio-3-music.hf.space collected 2026-10-03 16:51:25 UTC 14,663 bytes, 341 lines download raw bytes

1/* =============================================================================
2   ui.js · Toast、卡片、音符粒子、通用小组件
3   ============================================================================= */
4
5import { t, locField } from './i18n.js';
6import { fmt } from './audio.js';
7
8export const esc = s => String(s ?? '').replace(/[&<>"']/g,
9  c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
10
11export const icon = (id, cls = 'icon-sm', sw = 1.8) =>
12  `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}"
13    stroke-linecap="round" stroke-linejoin="round"><use href="#${id}"/></svg>`;
14
15/* ── Toast ─────────────────────────────────────────────────────────────── */
16const toastHost = document.getElementById('toasts');
17let toastTimer = null;
18export function toast(msg) {
19  if (!msg) return;
20  // 一次只留一条,连续操作不堆成一面墙
21  toastHost.querySelectorAll('.toast').forEach(el => el.remove());
22  const el = document.createElement('div');
23  el.className = 'toast';
24  el.textContent = msg;
25  toastHost.appendChild(el);
26  clearTimeout(toastTimer);
27  toastTimer = setTimeout(() => {
28    el.classList.add('leaving');
29    el.addEventListener('animationend', () => el.remove(), { once: true });
30  }, 2400);
31}
32
33/* ── 卡片 ──────────────────────────────────────────────────────────────── */
34const ACT = (name, ico, on = false) =>
35  `<span class="card-act${on ? ' liked' : ''}${name === 'del' ? ' danger' : ''}"
36     role="button" tabindex="0" data-act="${name}">${icon(ico, 'icon-sm', 1.9)}</span>`;
37
38export function cardHTML(track, { showRef = false, acts = false, liked = false } = {}) {
39  const title = locField(track, 'title');
40  const tags = locField(track, 'tags') || [];
41  const meta = [...tags, fmt(track.duration)].join(' · ');
42  const hasRef = showRef && track.inputs && Object.values(track.inputs).some(Boolean);
43  return `<button class="card" data-id="${esc(track.id)}" aria-current="false"
44      aria-label="${esc(title)}">
45    <span class="card-art">
46      <img src="${esc(track.cover)}" alt="" loading="lazy" decoding="async" width="512" height="512">
47      <span class="card-play" aria-hidden="true">${icon('i-play', 'icon-sm', 2)}</span>
48      ${hasRef ? `<span class="card-ref" aria-hidden="true">${icon('i-waves', 'icon-sm', 2)}${t('listen.in')}</span>` : ''}
49    </span>
50    <span class="card-title">
51      <span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>
52      <span>${esc(title)}</span>
53      ${acts ? `<span class="card-acts">
54        ${ACT('like', 'i-heart', liked)}${ACT('dl', 'i-download')}${ACT('del', 'i-trash')}
55      </span>` : ''}
56    </span>
57    <span class="card-meta">${esc(meta)}</span>
58  </button>`;
59}
60
61/** 网格里所有卡片共用一个点击委托 */
62export function wireGrid(host, tracks, onPlay) {
63  host.addEventListener('click', e => {
64    const card = e.target.closest('.card');
65    if (!card) return;
66    const tr = tracks.find(x => x.id === card.dataset.id);
67    if (tr) onPlay(tr, tracks);
68  });
69}
70
71/* ── 音符粒子 ──────────────────────────────────────────────────────────── */
72const NOTE_PATHS = [
73  // ♪ 单音符
74  'M9 3.2v9.1a2.6 2.6 0 1 0 1.6 2.4V6.6l4.2-1v6.1a2.6 2.6 0 1 0 1.6 2.4V2z',
75  // ♫ 双音符
76  'M6.4 5v8.6a2.4 2.4 0 1 0 1.5 2.2V7.9l7.1-1.6v6.3a2.4 2.4 0 1 0 1.5 2.2V3.4z',
77];
78const canvas = document.getElementById('notes');
79const nctx = canvas.getContext('2d');
80let particles = [];
81let nraf = null;
82
83function resizeNotes() {
84  const dpr = window.devicePixelRatio || 1;
85  canvas.width = Math.round(innerWidth * dpr);
86  canvas.height = Math.round(innerHeight * dpr);
87  nctx.setTransform(dpr, 0, 0, dpr, 0, 0);
88}
89
90const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
91
92export function spawnNotes(x, y, n = 3) {
93  if (reduced()) return;
94  if (particles.length > 40) particles = particles.slice(-30);
95  for (let i = 0; i < n; i++) {
96    particles.push({
97      x, y,
98      vx: (Math.random() - 0.5) * 26,
99      vy: -(58 + Math.random() * 34),
100      rot: (Math.random() - 0.5) * 0.5,
101      vr: (Math.random() - 0.5) * 1.4,
102      size: 18 + Math.random() * 8,
103      path: NOTE_PATHS[Math.floor(Math.random() * NOTE_PATHS.length)],
104      life: 0,
105      ttl: 0.9,
106    });
107  }
108  if (!nraf) { last = performance.now(); nraf = requestAnimationFrame(stepNotes); }
109}
110
111let last = 0;
112const noteShapes = new Map();
113function shape(d) {
114  if (!noteShapes.has(d)) noteShapes.set(d, new Path2D(d));
115  return noteShapes.get(d);
116}
117
118function stepNotes(now) {
119  const dt = Math.min(0.05, (now - last) / 1000);
120  last = now;
121  nctx.clearRect(0, 0, innerWidth, innerHeight);
122  const ink = getComputedStyle(document.documentElement).getPropertyValue('--ink').trim() || '#14161A';
123
124  particles = particles.filter(p => {
125    p.life += dt;
126    if (p.life >= p.ttl) return false;
127    p.x += p.vx * dt;
128    p.y += p.vy * dt;
129    p.vy += 26 * dt;                      // 轻微减速,像浮起来
130    p.rot += p.vr * dt;
131    const k = p.life / p.ttl;
132    nctx.save();
133    nctx.translate(p.x, p.y);
134    nctx.rotate(p.rot);
135    nctx.scale(p.size / 24, p.size / 24);
136    nctx.translate(-12, -12);
137    nctx.globalAlpha = 0.2 * (1 - k * k);
138    nctx.fillStyle = ink;
139    nctx.fill(shape(p.path));
140    nctx.restore();
141    return true;
142  });
143
144  if (particles.length) nraf = requestAnimationFrame(stepNotes);
145  else { nraf = null; nctx.clearRect(0, 0, innerWidth, innerHeight); }
146}
147
148export function initNotes() {
149  resizeNotes();
150  addEventListener('resize', resizeNotes);
151
152  // 只在内容区响应;按钮/输入/卡片/播放器有自己的反馈,叠加就成了噪音
153  document.addEventListener('pointerdown', e => {
154    if (e.button !== 0) return;
155    const bad = e.target.closest('button, a, input, textarea, select, label, .player, .np, .nav, canvas');
156    if (bad) return;
157    if (!e.target.closest('.main')) return;
158    spawnNotes(e.clientX, e.clientY, 2 + Math.floor(Math.random() * 3));
159  });
160
161  // 输入框打字时从光标附近冒一个
162  document.addEventListener('keydown', e => {
163    const el = e.target;
164    if (!(el instanceof HTMLTextAreaElement) && !(el instanceof HTMLInputElement)) return;
165    if (el.type === 'range') return;
166    if (e.key.length !== 1) return;
167    const r = el.getBoundingClientRect();
168    spawnNotes(r.left + Math.min(r.width - 20, 20 + (el.value.length % 30) * 7), r.top + 18, 1);
169  });
170}
171
172/* ── 自定义下拉 ─────────────────────────────────────────────────────────────
173   原生 <select> 即使 appearance:none,**展开后的选项列表仍由操作系统绘制**,
174   CSS 完全管不到,在这套浅色界面里很突兀。所以自己实现一个 listbox。
175   ========================================================================= */
176export function selectHTML(id, options, value) {
177  const cur = options.find(o => o.v === value) || options[0];
178  return `<div class="cselect" id="${id}" data-value="${esc(cur.v)}">
179    <button type="button" class="cselect-btn" aria-haspopup="listbox" aria-expanded="false">
180      <span class="cselect-cur">${esc(cur.label)}</span>${icon('i-chev-down')}
181    </button>
182    <div class="cselect-list" role="listbox" hidden>
183      ${options.map(o => `<button type="button" role="option" data-v="${esc(o.v)}"
184        aria-selected="${o.v === cur.v}">${esc(o.label)}${icon('i-check')}</button>`).join('')}
185    </div>
186  </div>`;
187}
188
189export function wireSelect(root, onChange) {
190  const btn = root.querySelector('.cselect-btn');
191  const list = root.querySelector('.cselect-list');
192  const opts = [...list.querySelectorAll('[role="option"]')];
193
194  const close = () => {
195    list.hidden = true;
196    btn.setAttribute('aria-expanded', 'false');
197    root.classList.remove('open');
198  };
199  const open = () => {
200    // 同一时刻只允许一个下拉展开
201    document.querySelectorAll('.cselect.open').forEach(o => {
202      if (o !== root) { o.classList.remove('open'); o.querySelector('.cselect-list').hidden = true;
203        o.querySelector('.cselect-btn').setAttribute('aria-expanded', 'false'); }
204    });
205    list.hidden = false;
206    btn.setAttribute('aria-expanded', 'true');
207    root.classList.add('open');
208    (opts.find(o => o.getAttribute('aria-selected') === 'true') || opts[0])?.focus();
209  };
210  const pick = o => {
211    root.dataset.value = o.dataset.v;
212    root.querySelector('.cselect-cur').textContent = o.textContent.trim();
213    opts.forEach(x => x.setAttribute('aria-selected', String(x === o)));
214    close();
215    btn.focus();
216    onChange?.(o.dataset.v);
217  };
218
219  btn.onclick = e => { e.stopPropagation(); list.hidden ? open() : close(); };
220  opts.forEach(o => { o.onclick = e => { e.stopPropagation(); pick(o); }; });
221
222  root.addEventListener('keydown', e => {
223    const i = opts.indexOf(document.activeElement);
224    if (e.key === 'Escape') { close(); btn.focus(); return; }
225    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
226      e.preventDefault();
227      if (list.hidden) return open();
228      opts[Math.max(0, Math.min(opts.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]?.focus();
229    }
230    if ((e.key === 'Enter' || e.key === ' ') && i >= 0) { e.preventDefault(); pick(opts[i]); }
231  });
232  // 点外面关掉
233  document.addEventListener('click', ev => { if (!root.contains(ev.target)) close(); });
234}
235
236export const selectValue = root => root?.dataset.value;
237
238/* ── 通用弹层 ───────────────────────────────────────────────────────────────
239   confirmDialog 只能「标题 + 一段纯文本 + 两个按钮」,装不下带输入框和实时
240   校验状态的面板。这里提供一个自带遮罩 / Esc / 焦点循环的空壳,内容和按钮
241   由调用方自己拼,拿到 host 后自行 wire。
242   ========================================================================= */
243export function modal(innerHTML, { onClose, labelledBy = 'mdlT' } = {}) {
244  const host = document.createElement('div');
245  host.className = 'dlg-scrim';
246  host.innerHTML = `<div class="dlg dlg-wide" role="dialog" aria-modal="true"
247    aria-labelledby="${labelledBy}">${innerHTML}</div>`;
248  document.body.appendChild(host);
249  requestAnimationFrame(() => host.classList.add('on'));
250
251  const prevFocus = document.activeElement;
252  let closed = false;
253  const close = () => {
254    if (closed) return;
255    closed = true;
256    host.classList.remove('on');
257    setTimeout(() => host.remove(), 200);
258    document.removeEventListener('keydown', onKey, true);
259    prevFocus?.focus?.();
260    onClose?.();
261  };
262  const onKey = e => {
263    if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
264    if (e.key === 'Tab') {
265      // 只在可聚焦且可见的控件之间循环
266      const f = [...host.querySelectorAll('button, input, a[href], textarea')]
267        .filter(el => !el.disabled && el.offsetParent !== null);
268      if (!f.length) return;
269      const i = f.indexOf(document.activeElement);
270      e.preventDefault();
271      f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus();
272    }
273  };
274  document.addEventListener('keydown', onKey, true);
275  host.onclick = e => { if (e.target === host) close(); };
276  return { host, close };
277}
278
279/* ── 确认弹窗 ───────────────────────────────────────────────────────────────
280   不用 window.confirm:它是浏览器原生弹窗,样式完全不受控,
281   在这套界面里非常突兀,而且会阻塞主线程。
282   ========================================================================= */
283export function confirmDialog({ title, body = '', ok, cancel, danger = true }) {
284  return new Promise(resolve => {
285    const host = document.createElement('div');
286    host.className = 'dlg-scrim';
287    host.innerHTML = `
288      <div class="dlg" role="dialog" aria-modal="true" aria-labelledby="dlgT">
289        <h3 id="dlgT">${esc(title)}</h3>
290        ${body ? `<p>${esc(body)}</p>` : ''}
291        <div class="dlg-foot">
292          <button type="button" class="btn btn-ghost" data-a="0">${esc(cancel || t('confirm.cancel'))}</button>
293          <button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'}" data-a="1">${esc(ok)}</button>
294        </div>
295      </div>`;
296    document.body.appendChild(host);
297    requestAnimationFrame(() => host.classList.add('on'));
298
299    const prevFocus = document.activeElement;
300    const done = v => {
301      host.classList.remove('on');
302      setTimeout(() => host.remove(), 200);
303      document.removeEventListener('keydown', onKey, true);
304      prevFocus?.focus?.();
305      resolve(v);
306    };
307    const onKey = e => {
308      if (e.key === 'Escape') { e.stopPropagation(); done(false); }
309      if (e.key === 'Tab') {                       // 简单焦点循环
310        const f = [...host.querySelectorAll('button')];
311        const i = f.indexOf(document.activeElement);
312        e.preventDefault();
313        f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus();
314      }
315    };
316    document.addEventListener('keydown', onKey, true);
317    host.querySelectorAll('[data-a]').forEach(b => {
318      b.onclick = () => done(b.dataset.a === '1');
319    });
320    host.onclick = e => { if (e.target === host) done(false); };
321    host.querySelector('[data-a="1"]').focus();
322  });
323}
324
325/* ── 通用:把 DOM 里的 data-i18n 填上 ──────────────────────────────────── */
326export function applyI18n(root = document) {
327  root.querySelectorAll('[data-i18n]').forEach(el => {
328    el.textContent = t(el.dataset.i18n);
329  });
330  root.querySelectorAll('[data-i18n-ph]').forEach(el => {
331    el.placeholder = t(el.dataset.i18nPh);
332  });
333  root.querySelectorAll('[data-i18n-label]').forEach(el => {
334    el.setAttribute('aria-label', t(el.dataset.i18nLabel));
335  });
336  root.querySelectorAll('[data-tip-i18n]').forEach(el => {
337    const s = t(el.dataset.tipI18n);
338    el.dataset.tip = s;
339    if (!el.querySelector('.nav-label')) el.setAttribute('aria-label', s);
340  });
341}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.