From dee32092163bddddab11d6d6c282851643a75acb Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 01:26:15 +0000 Subject: dashboard: an entry's menu in the document box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document box edited an entry by a double-click and added to a map or list by the `+` on its row; a key couldn't be renamed, and a list's `+` always asked for an index. A right-click on an entry, or a finger held on it half a second, opens its menu: `Edit value` (as the double-click), `Rename key` for a map's key, and on a map `Add a key`, on a list `Append an item` or `Insert an item at…`. Renaming reads the value, writes it under the new key and deletes the old one (two writes, the value as JSON; a key that exists already isn't overwritten). Inside a field being edited, and where an entry offers nothing, the browser's own menu shows. --- src/api/dashboard/dashboard.html | 217 ++++++++++++++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index c99d773..6df3300 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -241,14 +241,17 @@ .legend .node.unreachable::before { width: 6px; height: 6px; background: #000; border: 4px solid var(--muted); } /* A node's menu (right-click): subscribe, its dashboard. */ - #node-menu, #user-menu { position: fixed; z-index: 10; background: var(--panel); + #node-menu, #user-menu, #entry-menu { position: fixed; z-index: 10; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 0; box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; } - #node-menu[hidden], #user-menu[hidden] { display: none; } - #node-menu button, #user-menu button { display: block; width: 100%; text-align: left; + #node-menu[hidden], #user-menu[hidden], #entry-menu[hidden] { display: none; } + /* A finger held on an entry opens its menu (`withMenu`), not the browser's callout. */ + #document-box summary, #document-box .leaf { -webkit-touch-callout: none; } + #node-menu button, #user-menu button, #entry-menu button { display: block; width: 100%; text-align: left; font: inherit; padding: 4px 12px; background: none; border: none; color: var(--fg); cursor: pointer; } - #node-menu button:hover:not(:disabled), #user-menu button:hover { background: var(--bg); } + #node-menu button:hover:not(:disabled), #user-menu button:hover, + #entry-menu button:hover { background: var(--bg); } #node-menu button:disabled, #user-menu button[aria-disabled="true"] { color: var(--muted); } #node-menu button:disabled { cursor: default; } #who { font: inherit; background: none; border: none; color: var(--muted); cursor: pointer; } @@ -331,6 +334,7 @@ +
@@ -1745,13 +1749,21 @@ const editing = element => element.querySelector(".editor") !== null; // end), without writing it whole: a "+" on the row, the field below it. `known()`: a // map's keys as last read, so an existing one isn't overwritten. function addButton(kind, path, known, place) { - const map = kind === "map"; const button = el("button", { type: "button", class: "add", - title: map ? "Add a key" : "Insert an item" }, "+"); + title: kind === "map" ? "Add a key" : "Insert an item" }, "+"); button.addEventListener("click", event => { // Not a click on the row: it would open or close it. event.preventDefault(); event.stopPropagation(); + adding(kind, path, known, place, "-1"); + }); + return button; +} +// The field for adding to `path` (`addButton`), below its row: a list's index as +// `index` has it at first ("-1": at the end; "": asked for). +function adding(kind, path, known, place, index) { + const map = kind === "map"; + { const area = place(); if (editing(area)) return; const form = editor(path, "", async (saved, response) => { @@ -1767,7 +1779,7 @@ function addButton(kind, path, known, place) { }, { extra: map ? { label: "key", initial: "" } - : { label: "index", initial: "-1", hint: "where: 0 is the first; -1 the end" }, + : { label: "index", initial: index, hint: "where: 0 is the first; -1 the end" }, check: extra => { form.dataset.extra = extra; return map @@ -1781,8 +1793,140 @@ function addButton(kind, path, known, place) { withToken({ method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } })), }); area.prepend(form); + } +} + +// --- An entry's menu (right-click, or a finger held on it): edit its value, rename its +// key (a map's), add to it (a map: a key; a list: an item at the end, or at an index). +// Each as the double-click and the "+" do. --- +function entryMenu(x, y, items) { + const menu = document.getElementById("entry-menu"); + menu.replaceChildren(...items.map(([label, act]) => { + const button = el("button", { type: "button", role: "menuitem" }, label); + button.addEventListener("click", () => { menu.hidden = true; act(); }); + return button; + })); + if (items.length === 0) return; + menu.hidden = false; + menu.style.left = Math.max(8, Math.min(x, innerWidth - menu.offsetWidth - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, innerHeight - menu.offsetHeight - 8)) + "px"; +} +// `row` opens a menu of `items()` on a right-click, or a finger held on it half a second +// (the click that ends the touch then neither opens nor closes the row). +const HOLD_MS = 500; +function withMenu(row, items) { + row.addEventListener("contextmenu", event => { + if (event.target.closest(".editor, .renamer")) return; + // Nothing to offer: the browser's own menu. + const offered = items(); + if (offered.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + entryMenu(event.clientX, event.clientY, offered); }); - return button; + let held = null, swallow = false; + row.addEventListener("pointerdown", event => { + if (event.pointerType !== "touch" || event.target.closest(".editor, .renamer")) return; + const { clientX: x, clientY: y } = event; + held = setTimeout(() => { + held = null; + const offered = items(); + if (offered.length === 0) return; + swallow = true; + entryMenu(x, y, offered); + }, HOLD_MS); + const cancel = moved => { + if (moved && Math.hypot(moved.clientX - x, moved.clientY - y) < 10) return; + clearTimeout(held); + held = null; + row.removeEventListener("pointermove", cancel); + }; + row.addEventListener("pointermove", cancel); + row.addEventListener("pointerup", () => cancel(), { once: true }); + row.addEventListener("pointercancel", () => cancel(), { once: true }); + }); + row.addEventListener("click", event => { + if (!swallow) return; + swallow = false; + event.preventDefault(); + event.stopPropagation(); + }, true); +} +// Renaming a map's key: a field below its row, the key in it. Its value is read, written +// under the new key (PUT), and the old key deleted (DELETE): two writes, the value as +// JSON. `siblings()`: the map's keys as last read, so none is overwritten. +function renaming(path, siblings, row) { + if (row.parentNode.querySelector(":scope > .renamer")) return; + const old = path[path.length - 1], parent = path.slice(0, -1); + const field = el("input", { type: "text", spellcheck: "false", class: "extra" }); + field.value = old; + const message = el("span", { class: "note" }); + const fail = text => { message.className = "error"; message.textContent = text; }; + const go = el("button", { type: "button" }, "Rename"); + const cancel = el("button", { type: "button" }, "Cancel"); + const form = el("div", { class: "editor renamer" }, field, + el("div", {}, go, " ", cancel, " ", + el("span", { class: "note" }, "Enter renames, Esc cancels"), " ", message)); + const close = renamed => { + form.remove(); + edited(parent, renamed, renamed ? [...parent, field.value] : parent); + }; + const submit = async () => { + const to = field.value; + const problem = to === "" ? "A key, please." + : to.includes("/") ? "A key with \"/\" can't be named in a path." + : to === old ? "The same key." + : siblings().includes(to) ? "\"" + to + "\" exists already." + : null; + if (problem) { fail(problem); return; } + go.disabled = true; + message.className = "note"; + message.textContent = "Renaming…"; + const why = async response => { + let why = response.status === 403 ? "not allowed" : ""; + try { why = (await response.json()).error || why; } catch { /* no body */ } + return "HTTP " + response.status + (why ? ": " + why : ""); + }; + try { + const value = await readStore(path, false); + const put = await fetch(storeUrl([...parent, to], false), withToken({ method: "PUT", + body: JSON.stringify(value), headers: { "Content-Type": "application/json" } })); + if (!put.ok) throw new Error("Not renamed: " + await why(put)); + const removed = await fetch(storeUrl(path, false), withToken({ method: "DELETE" })); + if (!removed.ok) { + throw new Error("Written under \"" + to + "\", but \"" + old + "\" not deleted: " + + await why(removed)); + } + close(true); + } catch (error) { + go.disabled = false; + fail(error.message); + } + }; + go.addEventListener("click", submit); + cancel.addEventListener("click", () => close(false)); + field.addEventListener("keydown", event => { + if (event.key === "Escape") { event.preventDefault(); close(false); } + else if (event.key === "Enter") { event.preventDefault(); submit(); } + }); + row.after(form); + setTimeout(() => { field.focus(); field.select(); }); +} +// The items of an entry's menu: `edit`, if it can be edited; renaming, if it is a map's +// key (`parentKind`); adding to it, if it is a map or list (`kind`) that can be written +// in (`place`: where the field goes; `known`: its keys). +function entryItems({ path, kind, parentKind, siblings, row, edit, known, place }) { + const items = []; + if (edit) items.push(["Edit value", edit]); + if (parentKind === "map" && editable(path)) { + items.push(["Rename key", () => renaming(path, siblings, row)]); + } + if (place && kind === "map") items.push(["Add a key", () => adding("map", path, known, place, "")]); + if (place && kind === "list") { + items.push(["Append an item", () => adding("list", path, known, place, "-1")], + ["Insert an item at…", () => adding("list", path, known, place, "")]); + } + return items; } // In the protocol's data (`_continuum`): node IDs, times and sizes glossed (`gloss`). @@ -1794,16 +1938,19 @@ function editableLeaf(key, shown, value, path) { ...(glosses ? glossed(aliasOf(key)) : []), ": "), shown, ...(glosses ? glossed(gloss(key, value)) : [])); if (!editable(path)) return leaf; - leaf.title = "Double-click to edit"; - leaf.addEventListener("dblclick", event => { + leaf.title = "Double-click to edit, right-click for more"; + leaf.edit = () => { if (editing(leaf)) return; - event.preventDefault(); window.getSelection().removeAllRanges(); const before = [...leaf.childNodes]; leaf.replaceChildren(before[0], editor(path, value, saved => { leaf.replaceChildren(...before); edited(path, saved); })); + }; + leaf.addEventListener("dblclick", event => { + event.preventDefault(); + leaf.edit(); }); return leaf; } @@ -1824,19 +1971,29 @@ function glow(entry) { // One entry: a short scalar as it is; anything else a branch read when opened. A // branch's entries are kept across reads, so what's open inside stays open. -function documentEntry(key, summary, path) { +// `parentKind`: what it is in (`map`, `list`); `siblings()`: the keys there. +function documentEntry(key, summary, path, parentKind = null, siblings = () => []) { if (summary.size === 0 && summary.type in EMPTY) { const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path); // An empty map or list takes its first entry here: "+" on its row, the field below. if ((summary.type === "map" || summary.type === "list") && editable(path)) { const below = el("div", { class: "nested" }); leaf.append(" ", addButton(summary.type, path, () => [], () => below)); + withMenu(leaf, () => entryItems({ path, kind: summary.type, parentKind, siblings, + row: leaf, edit: leaf.edit, known: () => [], place: () => below })); return el("div", {}, leaf, below); } + if (leaf.edit) { + withMenu(leaf, () => entryItems({ path, parentKind, siblings, row: leaf, edit: leaf.edit })); + } return leaf; } if ("value" in summary) { - return editableLeaf(key, JSON.stringify(summary.value), summary.value, path); + const leaf = editableLeaf(key, JSON.stringify(summary.value), summary.value, path); + if (leaf.edit) { + withMenu(leaf, () => entryItems({ path, parentKind, siblings, row: leaf, edit: leaf.edit })); + } + return leaf; } const details = el("details", {}); const described = document.createTextNode(describe(summary)); @@ -1844,12 +2001,16 @@ function documentEntry(key, summary, path) { ...(glossedPath(path) ? glossed(aliasOf(key)) : []), " ", described); details.append(head); // A double click edits the whole of it: read, then shown in a field. + let edit = null; if (editable(path)) { - head.title = "Double-click to edit"; + head.title = "Double-click to edit, right-click for more"; head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); }); - head.addEventListener("dblclick", async event => { + head.addEventListener("dblclick", event => { event.preventDefault(); - if (details.querySelector(":scope > .editor")) return; + edit(); + }); + edit = async () => { + if (details.querySelector(":scope > .editor:not(.renamer)")) return; let value; try { value = await readStore(path, false); } catch (error) { described.textContent = describe(summary) + " (not read: " + error.message + ")"; @@ -1858,11 +2019,11 @@ function documentEntry(key, summary, path) { details.open = true; details.classList.add("editing"); head.after(editor(path, value, saved => { - details.querySelector(":scope > .editor").remove(); + details.querySelector(":scope > .editor:not(.renamer)").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 => { @@ -1878,12 +2039,16 @@ function documentEntry(key, summary, path) { const children = new Map(); const browsed = summary.type === "map" || summary.type === "list"; // Adding: on the row, the field just below it (the branch opened for it). - if (browsed && (editable(path) || path.length === 0)) { - head.append(" ", addButton(summary.type, path, () => [...children.keys()], () => { - details.open = true; - return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) }; - })); + const writable = browsed && (editable(path) || path.length === 0); + const place = () => { + details.open = true; + return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) }; + }; + if (writable) { + head.append(" ", addButton(summary.type, path, () => [...children.keys()], place)); } + withMenu(head, () => entryItems({ path, kind: summary.type, parentKind, siblings, row: head, + edit, known: () => [...children.keys()], place: writable ? place : null })); let read = false; // Read once at least: what's new or changed from then on breathes (`glow`). let filled = false; @@ -1916,7 +2081,8 @@ function documentEntry(key, summary, path) { glow(known.element); continue; } - const element = documentEntry(k, newer, [...path, k]); + const element = documentEntry(k, newer, [...path, k], summary.type, + () => [...children.keys()]); if (known) known.element.replaceWith(element); children.set(k, { element, summary: newer }); if (filled) glow(element); @@ -2629,7 +2795,7 @@ function nodeMenu(id, x, y) { menu.style.top = Math.min(y, innerHeight - menu.offsetHeight - 8) + "px"; } addEventListener("pointerdown", event => { - for (const id of ["node-menu", "user-menu"]) { + for (const id of ["node-menu", "user-menu", "entry-menu"]) { const menu = document.getElementById(id); if (!menu.hidden && !menu.contains(event.target) && event.target.id !== "who") { menu.hidden = true; @@ -2640,6 +2806,7 @@ addEventListener("keydown", event => { if (event.key !== "Escape") return; document.getElementById("node-menu").hidden = true; document.getElementById("user-menu").hidden = true; + document.getElementById("entry-menu").hidden = true; }); // A node's dashboard, where it publishes one (`--dashboard-url`, in the member list), -- cgit v1.3.1