269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
// The flowRS app shell's one script. The page draws without it; this only brings in the
|
|
// next frame as the program prints, switches the skin, runs the tabs, and sends a change a
|
|
// person makes to a control the program takes while it runs. It sets no innerHTML and no
|
|
// style: a frame is parsed by DOMParser (the server escaped every value in it) and moved
|
|
// in as nodes, every look is a class, and a slider's fill is an SVG attribute. It talks to
|
|
// no one but the server that sent it.
|
|
(() => {
|
|
"use strict";
|
|
const page = document.querySelector("[data-testid=app-shell]");
|
|
const app = document.getElementById("frs-app");
|
|
const status = document.querySelector("[data-testid=app-status]");
|
|
if (!page || !app) return;
|
|
let version = Number(page.getAttribute("data-version")) || 0;
|
|
|
|
// Which <details> are open and what has focus, so a new frame keeps both.
|
|
const keep = () => ({
|
|
open: [...app.querySelectorAll("details")].map((d) => d.open),
|
|
focus: document.activeElement && app.contains(document.activeElement) ? document.activeElement.id : null,
|
|
});
|
|
const restore = (k) => {
|
|
app.querySelectorAll("details").forEach((d, i) => {
|
|
if (k.open[i]) d.open = true;
|
|
});
|
|
if (k.focus) {
|
|
const el = document.getElementById(k.focus);
|
|
if (el) el.focus({ preventScroll: true });
|
|
}
|
|
};
|
|
|
|
// The control a person is using right now (focused, or being dragged) stays as it is
|
|
// across a new frame: the frame's copy of it is swapped for the one in use.
|
|
const inUse = () => {
|
|
const el = document.activeElement;
|
|
const field = el && app.contains(el) && el.closest("[data-frs-control]");
|
|
if (!field) return null;
|
|
const same = [...app.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === field.getAttribute("data-frs-control"));
|
|
return { field, target: field.getAttribute("data-frs-control"), nth: same.indexOf(field) };
|
|
};
|
|
|
|
const swap = (html) => {
|
|
const k = keep();
|
|
const using = inUse();
|
|
const doc = new DOMParser().parseFromString("<div>" + html + "</div>", "text/html");
|
|
const next = doc.body.firstElementChild;
|
|
if (using) {
|
|
const same = [...next.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === using.target);
|
|
const twin = same[using.nth];
|
|
if (twin) twin.replaceWith(using.field);
|
|
}
|
|
app.replaceChildren(...next.childNodes);
|
|
restore(k);
|
|
};
|
|
|
|
// ---- Live controls --------------------------------------------------------------
|
|
// A change goes to the shell (POST /control); the shell checks it against the control's
|
|
// kind and range and sends it to the program, which applies it at its next pass. A
|
|
// refused entry stays in the field it was typed in, marked invalid, beside what the
|
|
// program goes on using, until the person edits it; a control that cannot hold a refused
|
|
// value (a slider, a switch, a select) goes back to the value it holds.
|
|
const fieldOf = (el) => el && el.closest && el.closest("[data-frs-control]");
|
|
// The field's typed entry: a number or text field (never a slider).
|
|
const entryOf = (field) => field.querySelector('input:not([type="range"]), textarea');
|
|
const unitOf = (field) => {
|
|
const u = field.querySelector(".fr-number-unit");
|
|
return u ? u.textContent : "";
|
|
};
|
|
const say = (field, why) => {
|
|
const err = field.querySelector(".fr-field-error");
|
|
if (err) {
|
|
err.textContent = why || "";
|
|
err.hidden = !why;
|
|
}
|
|
field.classList.toggle("fr-field-invalid", Boolean(why));
|
|
const entry = entryOf(field);
|
|
if (entry) entry.setAttribute("aria-invalid", why ? "true" : "false");
|
|
const hint = field.querySelector(".fr-field-hint");
|
|
if (hint && hint.hasAttribute("data-frs-hint")) {
|
|
const held = field.getAttribute("data-frs-value") || "";
|
|
const unit = unitOf(field);
|
|
hint.textContent = why
|
|
? "The program is using " + (held ? held + (unit ? "\u2009" + unit : "") : "nothing yet") + "."
|
|
: hint.getAttribute("data-frs-hint");
|
|
}
|
|
};
|
|
const show = (field, value) => {
|
|
const input = field.querySelector("input, select, textarea");
|
|
const sw = field.querySelector(".fr-toggle");
|
|
if (sw) {
|
|
const on = value === "true";
|
|
sw.setAttribute("aria-checked", String(on));
|
|
const state = sw.querySelector(".fr-toggle-state");
|
|
if (state) state.textContent = on ? sw.getAttribute("data-frs-on") : sw.getAttribute("data-frs-off");
|
|
return;
|
|
}
|
|
if (!input) return;
|
|
if (input.tagName === "SELECT" && input.getAttribute("data-frs-by") === "text") {
|
|
const i = [...input.options].findIndex((o) => o.text === value);
|
|
if (i >= 0) input.selectedIndex = i;
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
if (input.type === "range") slid(input);
|
|
};
|
|
// A slider's value beside its label, its fill and its spoken value, as it moves.
|
|
const slid = (input) => {
|
|
const field = fieldOf(input);
|
|
const lo = Number(input.min), hi = Number(input.max), v = Number(input.value);
|
|
const fill = field && field.querySelector(".frs-slider-fill");
|
|
if (fill && hi > lo) fill.setAttribute("width", ((v - lo) / (hi - lo)) * 100 + "%");
|
|
const unit = input.getAttribute("data-frs-unit") || "";
|
|
const words = unit ? v + "\u2009" + unit : String(v);
|
|
const out = field && field.querySelector(".fr-field-value");
|
|
if (out) out.textContent = words;
|
|
input.setAttribute("aria-valuetext", words);
|
|
};
|
|
const send = async (field, value) => {
|
|
const target = field.getAttribute("data-frs-control");
|
|
const nth = [...app.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === target).indexOf(field);
|
|
let said;
|
|
try {
|
|
const r = await fetch("/control", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-Frs-Control": "1" },
|
|
body: JSON.stringify({ control: target, value, field: Math.max(nth, 0) }),
|
|
});
|
|
said = await r.json();
|
|
} catch (_) {
|
|
said = { ok: false, why: "The app's server did not answer" };
|
|
}
|
|
if (said && said.ok) {
|
|
field.setAttribute("data-frs-value", String(said.value));
|
|
say(field, "");
|
|
return;
|
|
}
|
|
const why = (said && said.why) || "Not taken";
|
|
if (entryOf(field)) {
|
|
// Kept as typed, beside what the program goes on using.
|
|
say(field, why);
|
|
} else {
|
|
// A slider, a switch or a select holds only what it takes: back to it, and why in
|
|
// its hint.
|
|
show(field, field.getAttribute("data-frs-value") || "");
|
|
const hint = field.querySelector(".fr-field-hint");
|
|
if (hint) hint.textContent = why;
|
|
}
|
|
};
|
|
// What a control holds, as the page sends it.
|
|
const valueOf = (input) => {
|
|
if (input.type === "range") return Number(input.value);
|
|
if (input.tagName === "SELECT") {
|
|
return input.getAttribute("data-frs-by") === "index" ? Number(input.value) : input.options[input.selectedIndex].text;
|
|
}
|
|
return input.value;
|
|
};
|
|
document.addEventListener("input", (ev) => {
|
|
const field = fieldOf(ev.target);
|
|
if (!field || !app.contains(field)) return;
|
|
if (ev.target.type === "range") slid(ev.target);
|
|
// Editing a refused entry takes its error away: what is in the field is new.
|
|
else if (field.classList.contains("fr-field-invalid")) say(field, "");
|
|
});
|
|
document.addEventListener("change", (ev) => {
|
|
const field = fieldOf(ev.target);
|
|
if (field && app.contains(field)) send(field, valueOf(ev.target));
|
|
});
|
|
|
|
|
|
// One request waiting for the next frame at a time; the server answers when a new one
|
|
// is drawn (or after 25 s with the same one).
|
|
const poll = async () => {
|
|
for (;;) {
|
|
let r;
|
|
try {
|
|
r = await fetch("/frame?v=" + version, { cache: "no-store" });
|
|
} catch (_) {
|
|
if (status) status.textContent = "The app's server stopped";
|
|
return;
|
|
}
|
|
if (!r.ok) return;
|
|
const v = Number(r.headers.get("X-Frs-Version")) || version;
|
|
const state = r.headers.get("X-Frs-Status") || "";
|
|
const words = r.headers.get("X-Frs-Status-Words");
|
|
const html = await r.text();
|
|
if (v !== version) {
|
|
version = v;
|
|
page.setAttribute("data-version", String(v));
|
|
swap(html);
|
|
}
|
|
if (status && words) {
|
|
try {
|
|
status.textContent = JSON.parse(words);
|
|
} catch (_) {
|
|
// keep the words the page has
|
|
}
|
|
}
|
|
if (state === "finished" || state === "failed") return;
|
|
}
|
|
};
|
|
|
|
// The skin: every element that carries one, and the address, so a reload keeps it.
|
|
document.addEventListener("click", (ev) => {
|
|
const to = ev.target.closest && ev.target.closest("[data-skin-to]");
|
|
if (to) {
|
|
ev.preventDefault();
|
|
const skin = to.getAttribute("data-skin-to");
|
|
const back = skin === "paper" ? "instrument" : "paper";
|
|
document.querySelectorAll("[data-skin]").forEach((el) => el.setAttribute("data-skin", skin));
|
|
to.setAttribute("data-skin-to", back);
|
|
to.setAttribute("href", "?skin=" + back);
|
|
to.firstChild.textContent = back === "paper" ? "Light" : "Dark";
|
|
history.replaceState(null, "", "?skin=" + skin);
|
|
return;
|
|
}
|
|
const off = ev.target.closest && ev.target.closest("[aria-disabled=true]");
|
|
if (off) ev.preventDefault();
|
|
const field = fieldOf(ev.target);
|
|
if (field && app.contains(field)) {
|
|
const sw = ev.target.closest(".fr-toggle");
|
|
if (sw) {
|
|
const on = sw.getAttribute("aria-checked") !== "true";
|
|
show(field, String(on));
|
|
send(field, on);
|
|
return;
|
|
}
|
|
const bump = ev.target.closest("[data-frs-bump]");
|
|
if (bump) {
|
|
const input = field.querySelector("input");
|
|
const step = Number(input.getAttribute("data-frs-step")) || 1;
|
|
let v = (Number(input.value) || 0) + Number(bump.getAttribute("data-frs-bump")) * step;
|
|
const lo = input.getAttribute("data-frs-min"), hi = input.getAttribute("data-frs-max");
|
|
if (lo !== null) v = Math.max(v, Number(lo));
|
|
if (hi !== null) v = Math.min(v, Number(hi));
|
|
input.value = String(Math.round(v * 1e9) / 1e9);
|
|
send(field, input.value);
|
|
return;
|
|
}
|
|
}
|
|
const tab = ev.target.closest && ev.target.closest(".fr-tab");
|
|
if (tab) select(tab);
|
|
});
|
|
document.addEventListener("submit", (ev) => ev.preventDefault());
|
|
|
|
// Tabs (WAI-ARIA): one tab stop; arrows, Home and End move and select.
|
|
const select = (tab) => {
|
|
const list = tab.closest(".fr-tablist");
|
|
const tabs = [...list.querySelectorAll(".fr-tab")];
|
|
tabs.forEach((t) => {
|
|
const on = t === tab;
|
|
t.setAttribute("aria-selected", String(on));
|
|
t.setAttribute("tabindex", on ? "0" : "-1");
|
|
const panel = document.getElementById(t.getAttribute("aria-controls"));
|
|
if (panel) panel.hidden = !on;
|
|
});
|
|
};
|
|
document.addEventListener("keydown", (ev) => {
|
|
const tab = ev.target.closest && ev.target.closest(".fr-tab");
|
|
if (!tab) return;
|
|
const tabs = [...tab.closest(".fr-tablist").querySelectorAll(".fr-tab")];
|
|
const i = tabs.indexOf(tab);
|
|
const n = tabs.length;
|
|
const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[ev.key];
|
|
if (next === undefined) return;
|
|
ev.preventDefault();
|
|
select(tabs[next]);
|
|
tabs[next].focus();
|
|
});
|
|
|
|
poll();
|
|
})();
|