// 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 = "";
slot.innerHTML = original;
edit.removeAttribute("data-preview");
undo.hidden = true;
});
}
input.addEventListener("change", function () {
if (input.files && input.files[0]) show(input.files[0]);
});
// Fill the picker from a paste or a drop. A browser without
// DataTransfer keeps the picker as it was and the person chooses
// the file, which is what the page does with no script at all.
function take(files) {
if (!files || !files.length || !window.DataTransfer) 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;
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
} catch (e) { return false; }
show(file);
return true;
}
// 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();
});
})();
})();