Skip to content
quarry Artifact [c0ca29ffe3]
Login

Artifact [c0ca29ffe3]

Artifact c0ca29ffe378e57d0dc5806ee12012f083e40788af16805a687fe38eb20b4745:


// settings.js — the settings page's three enhancements. None of them
// adds a control the page lacks without script (RFC 0001 Principle 3):
// the rail marks the section in view, a destructive button asks once
// before its form posts, and copy buttons — hidden in the markup —
// are shown, because a button that cannot copy is a lie.
(function () {
  "use strict";

  // The rail follows the reader. Each section reports how much of it
  // is visible; the link of the most visible one carries aria-current.
  // A section taller than the viewport never reaches 100%, so the
  // ratio is compared, not thresholded.
  var rail = document.querySelector(".set-rail");
  if (rail && "IntersectionObserver" in window) {
    var seen = {};
    var mark = function () {
      var best = null;
      for (var id in seen) {
        if (best === null || seen[id] > seen[best]) best = id;
      }
      rail.querySelectorAll("a[aria-current]").forEach(function (a) {
        a.removeAttribute("aria-current");
      });
      var a = best && rail.querySelector('a[href="#' + best + '"]');
      if (a) a.setAttribute("aria-current", "true");
    };
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { seen[e.target.id] = e.intersectionRatio; });
      mark();
    }, { threshold: [0, 0.25, 0.5, 0.75, 1] });
    document.querySelectorAll("main section[id]").forEach(function (s) { io.observe(s); });
  }

  // One dialog, shared. The submit button that opened it keeps its own
  // label inside the dialog, so "Revoke" is confirmed by "Revoke".
  // Confirming re-submits the form with the same button, flagged so the
  // interception lets it through this once.
  var dlg = document.getElementById("confirm");
  if (dlg && typeof dlg.showModal === "function") {
    var pending = null;
    document.addEventListener("submit", function (ev) {
      var b = ev.submitter;
      if (!b || !b.hasAttribute("data-confirm") || ev.target._confirmed) return;
      ev.preventDefault();
      pending = { form: ev.target, button: b };
      dlg.querySelector(".confirm-why").textContent = b.getAttribute("data-confirm");
      dlg.querySelector(".confirm-do").textContent = b.textContent;
      dlg.showModal();
    });
    dlg.querySelector(".confirm-do").addEventListener("click", function () {
      var p = pending;
      dlg.close();
      if (!p) return;
      p.form._confirmed = true;
      p.form.requestSubmit(p.button);
    });
    dlg.querySelector(".confirm-no").addEventListener("click", function () {
      pending = null;
      dlg.close();
    });
  }

  // Copy buttons: data-copy names the element whose text is copied.
  // Selection first, so a phone without the Clipboard API still ends
  // up with the text selected for its native gesture.
  document.querySelectorAll("button[data-copy]").forEach(function (b) { b.hidden = false; });
  document.addEventListener("click", function (ev) {
    var b = ev.target.closest && ev.target.closest("button[data-copy]");
    if (!b) return;
    var el = document.getElementById(b.getAttribute("data-copy"));
    if (!el) return;
    var range = document.createRange();
    range.selectNodeContents(el);
    var sel = getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    var done = function () {
      b.classList.add("done");
      clearTimeout(b._t);
      b._t = setTimeout(function () { b.classList.remove("done"); }, 4000);
    };
    if (navigator.clipboard) {
      navigator.clipboard.writeText(el.textContent).then(done, function () {});
    }
  });
})();

// Unit 2: pickers and dirty state.
(function () {
  "use strict";

  // Chip inputs. The field named by data-chips stays in the form and
  // keeps its syntax — tokens separated by spaces — so the handler never
  // learns the chips exist. A palette (data-chips="palette") draws every
  // option in the field's datalist as a toggle; an entry draws only the
  // chosen tokens, with a text box that completes from the same list.
  var tokensOf = function (v) { return v.split(/[\s,]+/).filter(Boolean); };
  document.querySelectorAll("input[data-chips]").forEach(function (input) {
    var list = document.getElementById(input.getAttribute("list") || "");
    var options = list ? [].map.call(list.options, function (o) { return o.value; }) : [];
    var palette = input.getAttribute("data-chips") === "palette";
    var box = document.createElement("span");
    box.className = "chips" + (palette ? " chips-palette" : "");
    var entry = document.createElement("input");
    entry.type = "text";
    entry.className = "chips-entry";
    entry.autocomplete = "off";
    entry.placeholder = palette ? "or an exact kind" : input.placeholder;
    if (input.getAttribute("aria-label")) entry.setAttribute("aria-label", input.getAttribute("aria-label"));
    if (list) entry.setAttribute("list", list.id);
    var set = function (toks) {
      input.value = toks.join(" ");
      // The form's own dirty tracking listens for this.
      input.dispatchEvent(new Event("input", { bubbles: true }));
      render();
    };
    var toggle = function (t) {
      var cur = tokensOf(input.value);
      var i = cur.indexOf(t);
      if (i < 0) cur.push(t); else cur.splice(i, 1);
      set(cur);
    };
    var add = function (t) {
      entry.value = "";
      if (t && tokensOf(input.value).indexOf(t) < 0) toggle(t);
    };
    var render = function () {
      box.querySelectorAll(".tok").forEach(function (c) { c.remove(); });
      var cur = tokensOf(input.value);
      var show = palette ? options.concat(cur.filter(function (t) { return options.indexOf(t) < 0; })) : cur;
      show.forEach(function (t) {
        var on = cur.indexOf(t) >= 0;
        var b = document.createElement("button");
        b.type = "button";
        b.className = "tok" + (on ? " on" : "");
        b.textContent = t;
        if (palette) b.setAttribute("aria-pressed", String(on));
        else b.setAttribute("aria-label", "remove " + t);
        b.addEventListener("click", function () { toggle(t); });
        box.insertBefore(b, entry);
      });
    };
    entry.addEventListener("keydown", function (ev) {
      var v = entry.value.trim();
      if (ev.key === "Enter" || ev.key === "," || (ev.key === " " && v)) {
        ev.preventDefault();
        add(v);
      } else if (ev.key === "Backspace" && !entry.value) {
        var cur = tokensOf(input.value);
        cur.pop();
        set(cur);
      }
    });
    // A pick from the datalist arrives as input with the whole option.
    entry.addEventListener("input", function () {
      if (options.indexOf(entry.value) >= 0) add(entry.value);
    });
    entry.addEventListener("blur", function () { add(entry.value.trim()); });
    input.type = "hidden";
    input.insertAdjacentElement("afterend", box);
    box.appendChild(entry);
    render();
  });

  // A filter over a long list: rows whose first cell lacks the text
  // are hidden. The box is hidden in the markup until it can act.
  document.querySelectorAll("input[data-filter]").forEach(function (f) {
    var body = document.getElementById(f.getAttribute("data-filter"));
    if (!body) return;
    f.hidden = false;
    f.addEventListener("input", function () {
      var q = f.value.trim().toLowerCase();
      body.querySelectorAll("tr").forEach(function (tr) {
        tr.hidden = q !== "" && (tr.cells[0].textContent || "").toLowerCase().indexOf(q) < 0;
      });
    });
  });

  // Dirty forms: the rail dots the section that holds one, and leaving
  // the page with one asks first. A submit — including the one the
  // confirm dialog re-fires — clears the form's mark.
  var rail = document.querySelector(".set-rail");
  var dirty = new Set();
  var mark = function () {
    if (!rail) return;
    rail.querySelectorAll("a[data-dirty]").forEach(function (a) { a.removeAttribute("data-dirty"); });
    dirty.forEach(function (f) {
      var s = f.closest("section[id]");
      var a = s && rail.querySelector('a[href="#' + s.id + '"]');
      if (a) a.setAttribute("data-dirty", "");
    });
  };
  document.addEventListener("input", function (ev) {
    var f = ev.target.form;
    if (!f || f.method.toLowerCase() !== "post") return;
    dirty.add(f);
    mark();
  });
  document.addEventListener("submit", function (ev) {
    dirty.delete(ev.target);
    mark();
  });
  window.addEventListener("beforeunload", function (ev) {
    dirty.forEach(function (f) { if (!f.isConnected) dirty.delete(f); });
    if (dirty.size === 0) return;
    ev.preventDefault();
    ev.returnValue = "";
  });

  // The roster's completion list: the server renders the candidates and
  // this puts the one that is tapped into the field. A click, not a
  // keystroke, because the field is small and the list is short — and
  // because a phone has no keyboard shortcuts to offer.
  document.addEventListener("click", function (ev) {
    var b = ev.target.closest && ev.target.closest("#roster-suggest li[data-handle] button");
    if (!b) return;
    ev.preventDefault();
    var li = b.closest("li");
    var form = b.closest("form");
    var input = form && form.querySelector('input[name="' + (b.getAttribute("data-fill") || "name") + '"]');
    if (!input) return;
    input.value = li.getAttribute("data-handle");
    input.focus();
    var ul = b.closest("ul");
    if (ul) ul.hidden = true;
  });

  // The image editor: a preview before saving, and paste or drop as
  // ways of choosing the file.
  //
  // Neither adds a control the page lacks without script (Principle
  // 3). The picker and its Upload button are the only path that
  // uploads either way — paste and drop FILL THE PICKER rather than
  // sending anything, so the thing that sends is still the button a
  // person presses, which is the rule attach.js already follows.
  //
  // The preview matters more than it looks: an avatar is judged small
  // and round-cornered, and choosing a file used to mean uploading it
  // to find out. Showing it in the same element the page draws the
  // mark in answers that before the commit rather than after.
  (function avatarEditor() {
    var edit = document.querySelector("[data-hook=avatar-edit]");
    if (!edit) return;
    var input = edit.querySelector('input[type=file]');
    var slot = edit.querySelector("[data-hook=avatar-current]");
    if (!input || !slot) return;
    var original = slot.innerHTML;
    var objectURL = "";
    var undo = edit.querySelector("[data-hook=avatar-undo]");

    function show(file) {
      if (!file || !/^image\//.test(file.type) || !window.URL || !URL.createObjectURL) return;
      if (objectURL) URL.revokeObjectURL(objectURL);
      objectURL = URL.createObjectURL(file);
      var img = document.createElement("img");
      img.className = "idn idn-img";
      img.alt = "";
      img.src = objectURL;
      slot.innerHTML = "";
      slot.appendChild(img);
      edit.setAttribute("data-preview", "");
      if (undo) undo.hidden = false;
    }

    // Undo, because a preview you cannot take back is a worse state
    // than none: the mark goes back to what the page was serving.
    //
    // Shown only while there IS a preview. It was unhidden on load, so
    // every settings page carried an Undo for something nobody had
    // done yet — a control that does nothing, which is the exact
    // thing the hidden attribute in the markup was for.
    if (undo) {
      undo.addEventListener("click", function () {
        if (objectURL) URL.revokeObjectURL(objectURL);
        objectURL = "";
        input.value = "";
        // The held file goes with the preview, and the requirement
        // comes back: Undo that left `pending` set would upload the
        // image the reader just took back.
        pending = null;
        input.setAttribute("required", "");
        slot.innerHTML = original;
        edit.removeAttribute("data-preview");
        undo.hidden = true;
      });
    }

    input.addEventListener("change", function () {
      if (input.files && input.files[0]) show(input.files[0]);
    });

    // pending is the pasted file we hold OURSELVES.
    //
    // take() used to trust `input.files = dt.files`. That assignment is
    // not universally honoured: where it is ignored — silently, without
    // throwing — the preview still drew, "Not saved yet" still appeared,
    // and the file input was still EMPTY. Pressing Upload then did
    // nothing at all, because the input is `required` and visually
    // hidden behind its label: the browser refused to submit and put
    // its "select a file" bubble on a control nobody can see. A form
    // that gates with no message and no way to satisfy it.
    //
    // So the pasted file is kept here, and the form is taught to send
    // it when the input did not take. The no-script path is untouched:
    // with no script there is no paste, the person picks a file, and
    // the input carries it the ordinary way.
    var pending = null;
    var form = input.form;

    function take(files) {
      if (!files || !files.length) return false;
      var file = null;
      for (var i = 0; i < files.length; i++) {
        if (/^image\//.test(files[i].type)) { file = files[i]; break; }
      }
      if (!file) return false;
      if (window.DataTransfer) {
        try {
          var dt = new DataTransfer();
          dt.items.add(file);
          input.files = dt.files;
        } catch (e) { /* fall through to pending */ }
      }
      // Did it actually take? Asking is the whole fix.
      if (!input.files || input.files.length === 0) {
        pending = file;
        // required on an invisible empty input is what does the
        // gating. We hold the file, so the requirement is met — just
        // not anywhere the validator can see.
        input.removeAttribute("required");
      } else {
        pending = null;
      }
      show(file);
      return true;
    }

    // Send the held file ourselves when the input never took it.
    // Everything else about the request is the form's own — method,
    // action, field name — so the server sees exactly what a picked
    // file sends.
    if (form) {
      form.addEventListener("submit", function (ev) {
        if (!pending) return;
        if (input.files && input.files.length) return;
        if (!window.FormData || !window.fetch) return;
        ev.preventDefault();
        var body = new FormData(form);
        body.set(input.name, pending, pending.name || "pasted.png");
        fetch(form.action, {
          method: form.method || "post", body: body, credentials: "same-origin",
        }).then(function (res) {
          window.location.href = res.url || form.action;
        }).catch(function () {
          // Put the requirement back rather than leave a form that
          // looks ready and is not.
          input.setAttribute("required", "");
          pending = null;
        });
      });
    }

    // Paste anywhere on the settings page, not only on the editor: a
    // person who has copied an image has nowhere obvious to aim, and
    // the page has exactly one place an image could go. Ignored while
    // a text field has focus, where a paste means the text.
    //
    // This is the DESKTOP half. It needs a paste gesture that reaches
    // the document, which a keyboard shortcut is and a phone is not.
    document.addEventListener("paste", function (ev) {
      var t = ev.target;
      if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName || ""))) return;
      if (!ev.clipboardData) return;
      if (take(ev.clipboardData.files)) ev.preventDefault();
    });

    // The PHONE half. iOS has no paste shortcut: pasting is long-press
    // then Paste, and that menu only appears over something editable.
    // A document-level listener therefore never fires there — not
    // rarely, never — so a phone needs a target to paste INTO.
    //
    // It is created here rather than shipped in the markup because
    // without script it would be a box that does nothing, which is the
    // rule every other enhancement on this page follows.
    var target = edit.querySelector("[data-hook=avatar-paste]");
    if (target) {
      target.hidden = false;
      target.setAttribute("contenteditable", "true");

      // iOS often does not put a file on clipboardData at all: it
      // pastes the image as MARKUP, an <img> with a data: or blob:
      // source. So when there is no file, let the paste land and then
      // take the image back out of the box.
      //
      // Through a canvas rather than fetch(): connect-src is 'self',
      // which a fetch of a blob: URL does not satisfy, and the canvas
      // is not blocked because the image is same-origin or inline.
      // Re-encoding also drops whatever metadata the photo carried,
      // which is worth having on a picture about to be published.
      function harvest() {
        var img = target.querySelector("img");
        if (!img) { target.innerHTML = ""; return; }
        var done = function () {
          var canvas = document.createElement("canvas");
          canvas.width = img.naturalWidth || img.width;
          canvas.height = img.naturalHeight || img.height;
          if (!canvas.width || !canvas.height) { target.innerHTML = ""; return; }
          try {
            canvas.getContext("2d").drawImage(img, 0, 0);
            canvas.toBlob(function (blob) {
              target.innerHTML = "";
              if (!blob) return;
              var file = new File([blob], "pasted.png", { type: blob.type || "image/png" });
              take([file]);
            }, "image/png");
          } catch (e) { target.innerHTML = ""; }
        };
        if (img.complete) done(); else img.addEventListener("load", done);
      }

      target.addEventListener("paste", function (ev) {
        if (ev.clipboardData && take(ev.clipboardData.files)) {
          ev.preventDefault();
          target.innerHTML = "";
          return;
        }
        setTimeout(harvest, 0);
      });
      // Never a place to type. Anything that is not an image goes
      // straight back out, so the box cannot become a text field
      // somebody has to clear by hand.
      target.addEventListener("input", function () {
        if (!target.querySelector("img")) target.innerHTML = "";
      });
    }

    ["dragenter", "dragover"].forEach(function (name) {
      edit.addEventListener(name, function (ev) {
        ev.preventDefault();
        edit.setAttribute("data-over", "");
      });
    });
    ["dragleave", "drop"].forEach(function (name) {
      edit.addEventListener(name, function () { edit.removeAttribute("data-over"); });
    });
    edit.addEventListener("drop", function (ev) {
      if (ev.dataTransfer && take(ev.dataTransfer.files)) ev.preventDefault();
    });
  })();
})();