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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.