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  `);