github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/web/common.ts (about) 1 // Copyright 2020 Daniel Erat. 2 // All rights reserved. 3 4 // Empty GIF: https://stackoverflow.com/a/14115340 5 export const emptyImg = 6 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; 7 8 // Returns the element under |root| with ID |id|. 9 export function $( 10 id: string, 11 root: Document | ShadowRoot = document 12 ): HTMLElement { 13 const el = root.getElementById(id); 14 if (!el) throw new Error(`Didn't find element #${id}`); 15 return el; 16 } 17 18 // Clamps |val| between |min| and |max|. 19 export const clamp = (val: number, min: number, max: number) => 20 Math.min(Math.max(val, min), max); 21 22 function pad(num: number, width: number) { 23 let str = num.toString(); 24 while (str.length < width) str = '0' + str; 25 return str; 26 } 27 28 // Formats |sec| as 'm:ss'. 29 export const formatDuration = (sec: number) => 30 `${Math.floor(sec / 60)}:${pad(Math.floor(sec % 60), 2)}`; 31 32 // Formats |sec| as a rounded relative time, e.g. '1 second ago' for -1 33 // or 'in 2 hours' for 7200. 34 export function formatRelativeTime(sec: number) { 35 const rtf = new Intl.RelativeTimeFormat('en', { style: 'long' }); 36 const fmt = (n: number, u: Intl.RelativeTimeFormatUnit) => 37 rtf.format(Math.round(n), u); 38 const days = sec / 86400; 39 const hours = sec / 3600; 40 const min = sec / 60; 41 42 if (Math.abs(Math.round(hours)) >= 24) return fmt(days, 'day'); 43 if (Math.abs(Math.round(min)) >= 60) return fmt(hours, 'hour'); 44 if (Math.abs(Math.round(sec)) >= 60) return fmt(min, 'minute'); 45 return fmt(sec, 'second'); 46 } 47 48 // Sets |element|'s 'title' attribute to |text| if its content overflows its 49 // area or removes it otherwise. 50 // 51 // Note that this can be slow, as accessing |scrollWidth| and |offsetWidth| may 52 // trigger a reflow. 53 export function updateTitleAttributeForTruncation( 54 element: HTMLElement, 55 text: string 56 ) { 57 // TODO: This can fail to set the attribute when the content just barely 58 // overflows since |scrollWidth| is infuriatingly rounded to an integer: 59 // https://stackoverflow.com/q/21666892 60 // 61 // This hasn't been changed due to compat issues: 62 // https://crbug.com/360889 63 // https://groups.google.com/a/chromium.org/g/blink-dev/c/_Q7A4AQBFKY 64 // 65 // getClientBoundingRect() and getClientRects() use fractional units but only 66 // report the actual layout size, so we get the same width for all elements 67 // regardless of the content size. 68 // 69 // It sounds like it may be possible to get the actual size by setting 70 // 'width: max-content' and then calling getClientBoundingRect() as described 71 // at https://github.com/w3c/csswg-drafts/issues/4123, but that seems like it 72 // might be slow. 73 if (element.scrollWidth > element.offsetWidth) element.title = text; 74 else element.removeAttribute('title'); 75 } 76 77 // Creates and returns a new |type| element. All other parameters are optional. 78 export function createElement( 79 type: string, 80 className: string | null = null, 81 parentElement: HTMLElement | ShadowRoot | null = null, 82 text: string | null = null 83 ) { 84 const element = document.createElement(type); 85 if (className) element.className = className; 86 if (parentElement) parentElement.appendChild(element); 87 if (text || text === '') element.appendChild(document.createTextNode(text)); 88 return element; 89 } 90 91 // Creates and returns a new shadow DOM attached to |el|. If |template| is 92 // supplied, a copy of it is attached as a child of the root node. 93 export function createShadow(el: HTMLElement, template?: HTMLTemplateElement) { 94 const shadow = el.attachShadow({ mode: 'open' }); 95 if (template) shadow.appendChild(template.content.cloneNode(true)); 96 return shadow; 97 } 98 99 // Creates and returns a new <template> containing the supplied HTML. 100 export function createTemplate(html: string) { 101 const template = document.createElement('template'); 102 template.innerHTML = html; 103 return template; 104 } 105 106 // Returns an absolute URL for the song specified by |filename| (corresponding 107 // to a song's |filename| property). 108 export const getSongUrl = (filename: string) => 109 getAbsUrl(`/song?filename=${encodeURIComponent(filename)}`); 110 111 // Image sizes that can be passed to getCoverUrl(). 112 export const smallCoverSize = 256; 113 export const largeCoverSize = 512; 114 115 // Returns a URL for the cover image identified by |filename|. 116 // If |filename| is null or empty, an empty string is returned. 117 // If |size| isn't supplied, returns the full-size, possibly-non-square image. 118 // Otherwise (i.e. |smallCoverSize| or |largeCoverSize|), returns a scaled, 119 // square version. 120 export function getCoverUrl(filename: string | null, size = 0) { 121 if (!filename) return ''; 122 let path = `/cover?filename=${encodeURIComponent(filename)}`; 123 if (size) path += `&size=${size}&webp=1`; 124 return getAbsUrl(path); 125 } 126 127 // Fetches an image at |src| so it can be loaded from the cache later. 128 export const preloadImage = (src: string) => (new Image().src = src); 129 130 // Returns a URL for dumping information about the song identified by |songId|. 131 export const getDumpSongUrl = (songId: string) => `/dump_song?songId=${songId}`; 132 133 // Returns an absolute version of |url| if it's relative. 134 // If it's already absolute, it is returned unchanged. 135 const getAbsUrl = (url: string) => new URL(url, document.baseURI).href; 136 137 // Throws if |response| failed due to the server returning an error status. 138 export function handleFetchError(response: Response) { 139 if (!response.ok) { 140 return response.text().then((text) => { 141 throw new Error(`${response.status}: ${text}`); 142 }); 143 } 144 return response; 145 } 146 147 // Converts a rating in the range [0, 5] (0 for unrated) to a string. 148 export function getRatingString(rating: number) { 149 rating = clamp(Math.round(rating), 0, 5); 150 return rating === 0 || isNaN(rating) 151 ? 'Unrated' 152 : '★'.repeat(rating) + '☆'.repeat(5 - rating); 153 } 154 155 // Moves the item at index |from| in |array| to index |to|. 156 // If |idx| is passed, it is adjusted if needed and returned. 157 export function moveItem<T>( 158 array: Array<T>, 159 from: number, 160 to: number, 161 idx?: number 162 ) { 163 if (from === to) return idx; 164 165 // https://stackoverflow.com/a/2440723 166 array.splice(to, 0, array.splice(from, 1)[0]); 167 168 if (typeof idx !== 'undefined' && idx >= 0) { 169 if (from === idx) idx = to; 170 else if (from < idx && to >= idx) idx--; 171 else if (from > idx && to <= idx) idx++; 172 } 173 return idx; 174 } 175 176 // Wraps |orig| across multiple lines that are each at most |max| characters 177 // (corresponding to the width of a lowercase 's') wide. 178 export function wrapString(orig: string, max: number): string { 179 if (!orig.length) return ''; 180 181 // TODO: If this ends up getting called a lot, cache canvas on function? 182 const canvas = document.createElement('canvas'); 183 const ctx = canvas.getContext('2d')!; // font defaults to '10px sans-serif' 184 const measure = (s: string) => ctx.measureText(s).width; 185 const maxWidth = max * measure('s'); 186 187 const lines: string[] = []; 188 for (const ln of orig.split('\n')) { 189 let out = ''; 190 for (const word of ln.trim().split(/\s+/)) { 191 if (!out.length) { 192 out += word; 193 } else { 194 const joined = out + ' ' + word; 195 if (measure(joined) <= maxWidth) { 196 out = joined; 197 } else { 198 lines.push(out); 199 out = word; 200 } 201 } 202 } 203 lines.push(out); 204 } 205 return lines.join('\n'); 206 } 207 208 // Returns true if the code is currently being tested. 209 // navigator.webdriver is set by Chrome when running under Selenium, while 210 // navigator.unitTest is injected by the unit test code's fake implementation. 211 export const underTest = () => 212 !!(navigator as any).webdriver || !!(navigator as any).unitTest; 213 214 // "icon-cross_mark" from MFG Labs. 215 export const xIcon = createTemplate(` 216 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1480.6939 2545.2094" class="x-icon" width="32" height="32"><path d="M0 1788q0-73 53-126l400-400L53 861Q0 809 0 736t53-125 125.5-52T303 611l401 401 400-401q52-52 125-52t125 52 52 125-52 125l-400 401 400 400q52 53 52 126t-52 125q-51 52-125 52t-125-52l-400-400-401 400q-51 52-125 52t-125-52q-53-52-53-125z"/></svg> 217 `); 218 219 // "icon-star" from MFG Labs. 220 export const starIcon = createTemplate(` 221 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1907 2545.2094" width="32" height="32"><path d="M3 1031q24-76 208-76h462l141-425v-2q38-114 89-154.5t101.5 0T1093 528l1 2 140 425h462q120 0 174 35t30.5 94.5T1779 1214l-10 8-362 259 139 424 3 4q56 174-9 222.5t-214-58.5l-6-4-5-3-362-260-367 263-4 4q-149 107-215 58.5t-8-222.5l1-2v-3l139-423-362-259-4-4-5-4Q-21 1107 3 1031z"/></svg> 222 `); 223 224 // "icon-star_empty" from MFG Labs. 225 export const emptyStarIcon = createTemplate(` 226 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2043 2545.2094" width="32" height="32"><path d="M6.5 1079Q-19 1015 39 977.5T226 940h495l151-456v-2q30-92 69-139t81-47q41 0 80 47t69 139l2 2 149 456h495q129 0 187 37.5t32.5 101.5-130.5 139l-10 8-389 278 150 452 2 7q39 122 22.5 186.5T1600 2214q-75 0-179-77l-12-7-387-278-393 281-6 4q-107 79-180 79-65 0-81.5-65.5T384 1963l2-7 150-452-389-278-4-5-6-3Q32 1143 6.5 1079zm338.5 53l416 297-43 134-117 354 421-301 420 301-116-354-44-134 416-297h-514l-43-132-119-359-163 491H345z"/></svg> 227 `); 228 229 // "spin6" from Fontelico. 230 export const spinnerIcon = createTemplate(` 231 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="32" height="32" class="spinner"><path class="fil0" d="M854.569 841.338c-188.268 189.444 -519.825 171.223 -704.157 -13.109 -190.56 -190.56 -200.048 -493.728 -28.483 -695.516 10.739 -12.623 21.132 -25.234 34.585 -33.667 36.553 -22.89 85.347 -18.445 117.138 13.347 30.228 30.228 35.737 75.83 16.531 111.665 -4.893 9.117 -9.221 14.693 -16.299 22.289 -140.375 150.709 -144.886 378.867 -7.747 516.005 152.583 152.584 406.604 120.623 541.406 -34.133 106.781 -122.634 142.717 -297.392 77.857 -451.04 -83.615 -198.07 -305.207 -291.19 -510.476 -222.476l-.226 -.226c235.803 -82.501 492.218 23.489 588.42 251.384 70.374 166.699 36.667 355.204 -71.697 493.53 -11.48 14.653 -23.724 28.744 -36.852 41.948z"/></svg> 232 `); 233 234 // setIcon clones |tmpl| (e.g. |xIcon|) and uses it to replace |orig|. 235 export function setIcon(orig: HTMLElement, tmpl: HTMLTemplateElement) { 236 const icon = tmpl.content.firstElementChild!.cloneNode(true) as HTMLElement; 237 if (orig.id) icon.id = orig.id; 238 if (orig.title) icon.title = orig.title; 239 icon.classList.add(...orig.classList.values()); 240 orig.replaceWith(icon); 241 return icon; 242 } 243 244 // Common CSS used in the document and shadow roots. 245 export const commonStyles = new CSSStyleSheet(); 246 commonStyles.replaceSync(` 247 /* With Chrome using cache partitioning since version 85 248 * (https://developer.chrome.com/blog/http-cache-partitioning/), there doesn't 249 * seem to be much benefit to using Google Fonts, and doing so also requires an 250 * extra round trip for CSS before font files can be fetched. So, self-host: 251 * https://google-webfonts-helper.herokuapp.com/fonts/roboto?subsets=latin */ 252 @font-face { 253 font-display: swap; 254 font-family: 'Roboto'; 255 font-style: normal; 256 font-weight: 400; 257 /* prettier-ignore */ 258 src: local('Roboto'), 259 url('roboto-v30-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 260 url('roboto-v30-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 261 } 262 @font-face { 263 font-display: swap; 264 font-family: 'Fontello'; 265 font-style: normal; 266 font-weight: 400; 267 /* prettier-ignore */ 268 src: local(''), 269 url('fontello-v1.woff2') format('woff2'), 270 url('fontello-v1.woff') format('woff'); 271 } 272 273 :root { 274 --font-family: 'Roboto', 'Fontello', sans-serif; 275 --font-size: 13.3333px; 276 277 --control-border: 1px solid var(--control-color); 278 --control-border-radius: 4px; 279 --control-line-height: 16px; 280 281 --margin: 10px; /* margin around groups of elements */ 282 --button-spacing: 6px; /* horizontal spacing between buttons */ 283 284 --bg-color: #fff; 285 --bg-active-color: #eee; /* song row with context menu */ 286 --text-color: #000; 287 --text-label-color: #666; /* song detail field names, menu hotkeys */ 288 --text-hover-color: #666; 289 --accent-color: #42a5f5; /* song row highlight, material blue 400 */ 290 --accent-active-color: #1976d2; /* material blue 700 */ 291 --accent-text-color: #fff; 292 --border-color: #ddd; /* between frames */ 293 --button-color: #aaa; 294 --button-hover-color: #666; 295 --button-text-color: #fff; 296 --chart-bar-rgb: 66, 165, 245; /* #42a5f5, material blue 400 */ 297 --chart-text-color: #fff; 298 --control-color: #ddd; 299 --control-active-color: #999; /* checked checkbox */ 300 --cover-missing-color: #f5f5f5; 301 --dialog-title-color: var(--accent-color); 302 --frame-border-color: var(--bg-color); /* dialogs, menus, rating/tags */ 303 --header-color: #f5f5f5; /* song table header */ 304 --icon-color: #aaa; /* clear button, select arrow */ 305 --icon-hover-color: #666; 306 --menu-hover-color: #eee; 307 --suggestions-color: #eee; /* tag suggestions background */ 308 } 309 310 [data-theme='dark'] { 311 --bg-color: #222; 312 --bg-active-color: #333; 313 --text-color: #ccc; 314 --text-label-color: #999; 315 --text-hover-color: #eee; 316 --accent-color: #1f517a; 317 --accent-active-color: #296ea6; 318 --accent-text-color: #fff; 319 --border-color: #555; 320 --button-color: #888; 321 --button-hover-color: #aaa; 322 --button-text-color: #000; 323 --chart-bar-rgb: 66, 165, 245; /* #42a5f5, material blue 400 */ 324 --chart-text-color: #fff; 325 --control-color: #555; 326 --control-active-color: #888; 327 --cover-missing-color: #333; 328 --frame-border-color: #444; 329 --dialog-title-color: #42a5f5; /* material blue 400 */ 330 --header-color: #333; 331 --icon-color: #aaa; 332 --icon-hover-color: #ccc; 333 --menu-hover-color: #444; 334 --suggestions-color: #444; 335 } 336 337 html { 338 color-scheme: light; 339 } 340 html[data-theme='dark'] { 341 color-scheme: dark; 342 } 343 344 svg.x-icon { 345 cursor: pointer; 346 fill: var(--icon-color); 347 height: 12px; 348 width: 12px; 349 } 350 svg.x-icon:hover { 351 fill: var(--icon-hover-color); 352 } 353 354 svg.spinner { 355 animation: spin 1s infinite linear; 356 transform-origin: 50% 50%; 357 } 358 @keyframes spin { 359 from { 360 transform: rotate(0deg); 361 } 362 to { 363 transform: rotate(360deg); 364 } 365 } 366 367 button { 368 background-color: var(--button-color); 369 border: none; 370 border-radius: var(--control-border-radius); 371 color: var(--button-text-color); 372 cursor: pointer; 373 display: inline-block; 374 font-family: var(--font-family); 375 font-size: 12px; 376 font-weight: bold; 377 height: 28px; 378 letter-spacing: 0.09em; 379 overflow: hidden; /* prevent icon from extending focus ring */ 380 padding: 1px 12px 0 12px; 381 text-transform: uppercase; 382 user-select: none; 383 } 384 button:hover:not(:disabled) { 385 background-color: var(--button-hover-color); 386 } 387 button:disabled { 388 box-shadow: none; 389 cursor: default; 390 opacity: 0.4; 391 } 392 button svg { 393 fill: var(--button-text-color); 394 vertical-align: middle; 395 } 396 397 input[type='text'], 398 textarea { 399 appearance: none; 400 -moz-appearance: none; 401 -ms-appearance: none; 402 -webkit-appearance: none; 403 background-color: var(--bg-color); 404 border: var(--control-border); 405 border-radius: var(--control-border-radius); 406 color: var(--text-color); 407 font-family: var(--font-family); 408 line-height: var(--control-line-height); 409 padding: 6px 4px 4px 6px; 410 } 411 412 input[type='checkbox'] { 413 appearance: none; 414 -moz-appearance: none; 415 -ms-appearance: none; 416 -webkit-appearance: none; 417 background-color: var(--bg-color); 418 border: solid 1px var(--control-color); 419 border-radius: 2px; 420 height: 14px; 421 position: relative; 422 width: 14px; 423 } 424 input[type='checkbox']:checked { 425 border-color: var(--control-active-color); 426 color: var(--control-active-color); 427 } 428 input[type='checkbox']:checked:before { 429 background-color: var(--control-active-color); 430 content: ''; 431 height: 12px; 432 left: calc(50% - 6px); 433 /* "icon-check" from MFG Labs */ 434 /* Chrome 104 seems to still need the vendor-prefixed version of 'mask': 435 * https:/crbug.com/432153 */ 436 mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1886 2545.2094' width='12' height='12'%3E%3Cpath d='M0 1278.5Q0 1350 50 1400l503 491q50 50 124 50 72 0 122-50L1834 877q52-50 52-121t-52-120q-52-50-123.5-50T1587 636l-910 893-380-372q-52-50-123.5-50T50 1157q-50 50-50 121.5z'/%3E%3C/svg%3E"); 437 -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1886 2545.2094' width='12' height='12'%3E%3Cpath d='M0 1278.5Q0 1350 50 1400l503 491q50 50 124 50 72 0 122-50L1834 877q52-50 52-121t-52-120q-52-50-123.5-50T1587 636l-910 893-380-372q-52-50-123.5-50T50 1157q-50 50-50 121.5z'/%3E%3C/svg%3E"); 438 mask-position: center; 439 -webkit-mask-position: center; 440 mask-size: cover; 441 -webkit-mask-size: cover; 442 position: absolute; 443 top: calc(50% - 6px); 444 width: 12px; 445 } 446 447 input[type='checkbox'].small { 448 height: 12px; 449 width: 12px; 450 } 451 input[type='checkbox'].small:checked:before { 452 height: 10px; 453 left: calc(50% - 5px); 454 top: calc(50% - 5px); 455 width: 10px; 456 } 457 458 input:disabled { 459 opacity: 0.5; 460 } 461 462 /* To avoid spacing differences between minified and non-minified code, omit 463 * whitespace between </select> and the closing .select-wrapper </span> tag. 464 * I think that https://github.com/tdewolff/minify/issues/240 is related. */ 465 .select-wrapper { 466 display: inline-block; 467 margin: 0 4px; 468 position: relative; 469 } 470 .select-wrapper:after { 471 background-color: var(--icon-color); 472 content: ''; 473 height: 12px; 474 /* "icon-chevron_down" from MFG Labs */ 475 mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1675 2545.2094' width='12' height='12'%3E%3Cpath d='M0 880q0-51 37-88t89-37 88 37l624 622 623-622q37-37 89-37t88 37q37 37 37 88.5t-37 88.5l-800 800L37 969Q0 930 0 880z'/%3E%3C/svg%3E"); 476 -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1675 2545.2094' width='12' height='12'%3E%3Cpath d='M0 880q0-51 37-88t89-37 88 37l624 622 623-622q37-37 89-37t88 37q37 37 37 88.5t-37 88.5l-800 800L37 969Q0 930 0 880z'/%3E%3C/svg%3E"); 477 mask-position: center; 478 -webkit-mask-position: center; 479 mask-size: cover; 480 -webkit-mask-size: cover; 481 pointer-events: none; 482 position: absolute; 483 right: 8px; 484 top: calc(50% - 5px); 485 width: 12px; 486 } 487 select { 488 appearance: none; 489 -moz-appearance: none; 490 -ms-appearance: none; 491 -webkit-appearance: none; 492 background-color: var(--bg-color); 493 border: var(--control-border); 494 border-radius: var(--control-border-radius); 495 color: var(--text-color); 496 font-family: var(--font-family); 497 line-height: var(--control-line-height); 498 padding: 6px 24px 4px 6px; 499 } 500 select:disabled { 501 opacity: 0.5; 502 } 503 504 /* TODO: Also style range inputs since they're used by <options-dialog>. */ 505 `);