diff options
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 138 |
1 files changed, 129 insertions, 9 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index d5642a9..3f5d862 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -83,6 +83,13 @@ .tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all; max-height: 20em; overflow: auto; } .note { color: var(--muted); font-style: italic; } + .editor { margin: 2px 0 4px; } + .tree details > .editor { margin-left: 1.1em; } + .editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg); + background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; } + .editor button { font: inherit; } + .tree details.editing > :not(summary):not(.editor) { display: none; } + .tree .leaf[title] { cursor: text; } table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line); white-space: nowrap; } @@ -580,18 +587,128 @@ async function readStore(path, shallow) { // branch's entries are kept across reads, so what's open inside stays open. // An empty map, list or text: shown as it is, nothing to open. const EMPTY = { map: "{}", list: "[]", text: "\"\"" }; +const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" }; + +// --- Editing: a double click on an entry turns it into a field holding its contents; +// saved with PUT /store/<path> (the whole value, objects too) --- +// A string is edited as text (sent as text/plain), anything else as JSON; "JSON" +// switches. `done(saved)` when saved or cancelled. +function editor(path, value, done) { + const message = el("span", { class: "note" }); + const asText = typeof value === "string"; + const text = asText ? value : JSON.stringify(value, null, 2); + const long = text.includes("\n") || text.length > 60 || (value !== null && typeof value === "object"); + const field = long + ? el("textarea", { rows: String(Math.min(20, text.split("\n").length + 1)), spellcheck: "false" }) + : el("input", { type: "text", spellcheck: "false" }); + field.value = text; + const json = el("input", { type: "checkbox" }); + json.checked = !asText; + json.addEventListener("change", () => { + if (json.checked) { field.value = JSON.stringify(field.value); return; } + try { + const parsed = JSON.parse(field.value); + if (typeof parsed !== "string") throw new Error("not a string"); + field.value = parsed; + } catch { + json.checked = true; + message.textContent = "Only a JSON string can be edited as text."; + } + }); + const save = el("button", { type: "button" }, "Save"); + const cancel = el("button", { type: "button" }, "Cancel"); + const submit = async () => { + const body = field.value; + if (json.checked) { + try { JSON.parse(body); } catch (error) { + message.textContent = "Not JSON: " + error.message; return; + } + } + save.disabled = true; + message.textContent = "Saving…"; + try { + const response = await fetch(storeUrl(path, false), { method: "PUT", body, + headers: { "Content-Type": json.checked ? "application/json" : "text/plain" } }); + if (!response.ok) { + throw new Error("HTTP " + response.status + (response.status === 403 ? ", not allowed" : "")); + } + done(true); + } catch (error) { + save.disabled = false; + message.textContent = "Not saved: " + error.message; + } + }; + save.addEventListener("click", submit); + cancel.addEventListener("click", () => done(false)); + field.addEventListener("keydown", event => { + if (event.key === "Escape") { event.preventDefault(); done(false); } + // Enter saves a line; Ctrl or ⌘ and Enter saves an area. + else if (event.key === "Enter" && (!long || event.ctrlKey || event.metaKey)) { + event.preventDefault(); submit(); + } + }); + setTimeout(() => field.focus()); + return el("div", { class: "editor" }, field, + el("div", {}, save, " ", cancel, " ", el("label", {}, json, " JSON"), " ", + el("span", { class: "note" }, long ? "Ctrl+Enter saves, Esc cancels" : "Enter saves, Esc cancels"), + " ", message)); +} +// Store paths name keys below the root; the root itself isn't written whole. +const editable = path => path.length > 0 && addressable(path); +// After an edit: what changed is read again (the node's `document` event follows). +function edited(path, saved) { + release(); + if (saved) documentChanged({ paths: [path] }); +} +function editableLeaf(key, shown, value, path) { + const leaf = el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), shown); + if (!editable(path)) return leaf; + leaf.title = "Double-click to edit"; + leaf.addEventListener("dblclick", event => { + if (leaf.querySelector(".editor")) return; + event.preventDefault(); + window.getSelection().removeAllRanges(); + const before = [...leaf.childNodes]; + leaf.replaceChildren(before[0], editor(path, value, saved => { + leaf.replaceChildren(...before); + edited(path, saved); + })); + }); + return leaf; +} + function documentEntry(key, summary, path) { if (summary.size === 0 && summary.type in EMPTY) { - return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), - EMPTY[summary.type]); + return editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path); } if ("value" in summary) { - return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), - JSON.stringify(summary.value)); + return editableLeaf(key, JSON.stringify(summary.value), summary.value, path); } const details = el("details", {}); const described = document.createTextNode(describe(summary)); - details.append(el("summary", {}, el("span", { class: "key" }, key), " ", described)); + const head = el("summary", {}, el("span", { class: "key" }, key), " ", described); + details.append(head); + // A double click edits the whole of it: read, then shown in a field. + if (editable(path)) { + head.title = "Double-click to edit"; + head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); }); + head.addEventListener("dblclick", async event => { + event.preventDefault(); + if (details.querySelector(":scope > .editor")) return; + let value; + try { value = await readStore(path, false); } catch (error) { + described.textContent = describe(summary) + " (not read: " + error.message + ")"; + return; + } + details.open = true; + details.classList.add("editing"); + head.after(editor(path, value, saved => { + details.querySelector(":scope > .editor").remove(); + details.classList.remove("editing"); + edited(path, saved); + })); + }); + } // A newer summary from the parent: a branch of the same kind stays as it is. details.updated = newer => { summary = newer; @@ -671,9 +788,10 @@ function documentChanged(change) { document.getElementById("document").replaceChildren( documentEntry("store", { type: "map", size: "…" }, [])); -// A box is busy while text in it is selected: drawing it anew would lose the selection, -// so it waits (`held`) until the selection is gone. +// A box is busy while text in it is selected, or something in it is being edited: +// drawing it anew would lose that, so it waits (`held`) until it's done. function busy(box) { + if (box.querySelector(".editor")) return true; const selection = window.getSelection(); if (!selection || selection.isCollapsed || !selection.rangeCount) return false; return box.contains(selection.getRangeAt(0).commonAncestorContainer); @@ -689,14 +807,16 @@ function render() { if (busy(document.getElementById(box))) held = true; else draw(); } } -document.addEventListener("selectionchange", () => { +// What waited for a box to be free: drawn, or applied, now. +function release() { if (held) render(); if (documentPending !== null && !busy(document.getElementById("document-box"))) { const change = documentPending; documentPending = null; documentChanged(change); } -}); +} +document.addEventListener("selectionchange", release); // A click beside the nodes: back to this node. const graph = document.getElementById("graph"); |
