inspector updates
This commit is contained in:
parent
324f4abd4b
commit
dc947d16b9
2 changed files with 178 additions and 18 deletions
|
|
@ -7,33 +7,61 @@
|
||||||
</header>
|
</header>
|
||||||
<div class="grid min-h-[calc(100vh-61px)] grid-rows-[auto_1fr]">
|
<div class="grid min-h-[calc(100vh-61px)] grid-rows-[auto_1fr]">
|
||||||
<nav data-bliss-tree class="border-b-4 border-double border-black p-2"></nav>
|
<nav data-bliss-tree class="border-b-4 border-double border-black p-2"></nav>
|
||||||
<section data-bliss-editor class="min-h-0 p-3 text-sm">Select a route or template to edit it.</section>
|
<section data-bliss-editor class="min-h-0 p-3 text-sm">Select a route or template to edit it, or use the ✏️ 📄 📋 👯 buttons on the page.</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.getElementById("bliss-live-editor");
|
const root = document.getElementById("bliss-live-editor");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
// Re-running this script (the slideout is remounted on every open) must not
|
||||||
|
// pile up document listeners or leave a stale overlay behind.
|
||||||
|
if (window.__blissLive) window.__blissLive();
|
||||||
|
const cleanups = [];
|
||||||
|
const on = (target, type, handler, opts) => {
|
||||||
|
target.addEventListener(type, handler, opts);
|
||||||
|
cleanups.push(() => target.removeEventListener(type, handler, opts));
|
||||||
|
};
|
||||||
|
window.__blissLive = () => { for (const c of cleanups.splice(0)) c(); window.__blissLive = null; };
|
||||||
|
|
||||||
const tree = root.querySelector("[data-bliss-tree]");
|
const tree = root.querySelector("[data-bliss-tree]");
|
||||||
const editor = root.querySelector("[data-bliss-editor]");
|
const editor = root.querySelector("[data-bliss-editor]");
|
||||||
root.querySelector("[data-bliss-close]").onclick = () =>
|
root.querySelector("[data-bliss-close]").onclick = () =>
|
||||||
document.dispatchEvent(new CustomEvent("bliss:inspector-close"));
|
document.dispatchEvent(new CustomEvent("bliss:inspector-close"));
|
||||||
|
|
||||||
|
const highlight = (kind, id, method, state) =>
|
||||||
|
document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, method, on: state } }));
|
||||||
|
|
||||||
|
function openEditor(kind, id) {
|
||||||
|
editor.textContent = "Loading…";
|
||||||
|
htmx.ajax("GET", `/_bliss/editor/${kind}/${id}`, { target: editor, swap: "innerHTML" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone runs through the workshop's own modal, dropped into a full-screen host
|
||||||
|
// we manage so it stacks above the slideout and closes on backdrop click.
|
||||||
|
function openModal(url) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.dataset.blissUi = "";
|
||||||
|
host.style.cssText = "position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,.45)";
|
||||||
|
host.onclick = (event) => { if (event.target === host) host.remove(); };
|
||||||
|
on(document, "bliss:inspector-close", () => host.remove());
|
||||||
|
document.body.append(host);
|
||||||
|
htmx.ajax("GET", url, { target: host, swap: "innerHTML" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T R E E -- the list of everything the current page rendered.
|
||||||
function sourceButton(label, kind, id, count) {
|
function sourceButton(label, kind, id, count) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "block w-full border border-transparent px-2 py-1 text-left hover:border-black hover:bg-black hover:text-white";
|
button.className = "block w-full border border-transparent px-2 py-1 text-left hover:border-black hover:bg-black hover:text-white";
|
||||||
button.textContent = label + (count > 1 ? ` (${count})` : "");
|
button.textContent = label + (count > 1 ? ` (${count})` : "");
|
||||||
button.onmouseenter = () => document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, on: true } }));
|
button.onmouseenter = () => highlight(kind, id, null, true);
|
||||||
button.onmouseleave = () => document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, on: false } }));
|
button.onmouseleave = () => highlight(kind, id, null, false);
|
||||||
if (kind !== "structure") button.onclick = () => {
|
if (kind !== "structure") button.onclick = () => openEditor(kind, id);
|
||||||
editor.textContent = "Loading…";
|
|
||||||
htmx.ajax("GET", `/_bliss/editor/${kind}/${id}`, { target: editor, swap: "innerHTML" });
|
|
||||||
};
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(items) {
|
function renderTree(items) {
|
||||||
tree.innerHTML = "";
|
tree.innerHTML = "";
|
||||||
const structures = new Map();
|
const structures = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|
@ -70,7 +98,103 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("bliss:inventory", (event) => render(event.detail));
|
// O V E R L A Y -- little edit/copy/clone toolbars drawn on the page itself,
|
||||||
|
// one per rendered element, anchored to its top-left corner.
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.dataset.blissUi = "";
|
||||||
|
layer.style.cssText = "position:fixed;inset:0;z-index:2147483640;pointer-events:none";
|
||||||
|
root.parentNode.append(layer); // lives inside the inspector root, so it is torn down on close
|
||||||
|
const bars = new Map();
|
||||||
|
|
||||||
|
function sourceEls() {
|
||||||
|
return [...document.querySelectorAll("[data-bliss-structure-id],[data-bliss-template-id]")]
|
||||||
|
.filter((element) => !element.closest("[data-bliss-ui]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function templatesOf(element) {
|
||||||
|
try { const list = JSON.parse(element.dataset.blissTemplates || "[]"); if (list.length) return list; } catch (_) {}
|
||||||
|
return element.dataset.blissTemplateId
|
||||||
|
? [{ id: element.dataset.blissTemplateId, name: element.dataset.blissTemplateName }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconButton(glyph, title, kind, id, method, action) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = glyph;
|
||||||
|
button.title = title;
|
||||||
|
button.style.cssText = "pointer-events:auto;border:2px solid #000;background:#fff;border-radius:6px;min-width:20px;height:20px;padding:0 2px;font-size:11px;line-height:16px;cursor:pointer;box-shadow:1px 1px 0 #000";
|
||||||
|
if (kind) {
|
||||||
|
button.onmouseenter = () => highlight(kind, id, method, true);
|
||||||
|
button.onmouseleave = () => highlight(kind, id, method, false);
|
||||||
|
}
|
||||||
|
button.onclick = (event) => { event.preventDefault(); event.stopPropagation(); action(button); };
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBar(element) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.dataset.blissUi = "";
|
||||||
|
wrap.style.cssText = "position:absolute;display:flex;gap:3px;pointer-events:none";
|
||||||
|
if (element.dataset.blissRouteId) {
|
||||||
|
wrap.append(iconButton("✏️", "Edit route " + (element.dataset.blissRouteName || ""),
|
||||||
|
"route", element.dataset.blissRouteId, element.dataset.blissMethod,
|
||||||
|
() => openEditor("route", element.dataset.blissRouteId)));
|
||||||
|
}
|
||||||
|
for (const template of templatesOf(element)) {
|
||||||
|
wrap.append(iconButton("📄", "Edit template " + (template.name || ""),
|
||||||
|
"template", template.id, null, () => openEditor("template", template.id)));
|
||||||
|
}
|
||||||
|
if (element.dataset.blissCopy) {
|
||||||
|
wrap.append(iconButton("📋", "Copy embed snippet", null, null, null, async (button) => {
|
||||||
|
try { await navigator.clipboard.writeText(element.dataset.blissCopy); button.textContent = "✅"; }
|
||||||
|
catch (_) { button.textContent = "❌"; }
|
||||||
|
setTimeout(() => (button.textContent = "📋"), 1000);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (element.dataset.blissClone) {
|
||||||
|
wrap.append(iconButton("👯", "Clone structure", null, null, null,
|
||||||
|
() => openModal(element.dataset.blissClone)));
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(element, wrap) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (!rect.width && !rect.height) { wrap.style.display = "none"; return; }
|
||||||
|
wrap.style.display = "flex";
|
||||||
|
// Keep the toolbar clear of the slideout: the page root (<body>) is not
|
||||||
|
// itself shifted, so its top-left would otherwise hide behind the panel.
|
||||||
|
const guard = root.getBoundingClientRect().right + 2;
|
||||||
|
const top = rect.top - 24;
|
||||||
|
wrap.style.left = Math.max(guard, rect.left + 2) + "px";
|
||||||
|
wrap.style.top = (top < 2 ? rect.top + 2 : top) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBars() {
|
||||||
|
const live = new Set();
|
||||||
|
for (const element of sourceEls()) {
|
||||||
|
live.add(element);
|
||||||
|
let wrap = bars.get(element);
|
||||||
|
if (!wrap || !wrap.isConnected) { wrap = buildBar(element); bars.set(element, wrap); layer.append(wrap); }
|
||||||
|
place(element, wrap);
|
||||||
|
}
|
||||||
|
for (const [element, wrap] of bars) {
|
||||||
|
if (!live.has(element)) { wrap.remove(); bars.delete(element); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
function reposition() {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(() => { for (const [element, wrap] of bars) place(element, wrap); });
|
||||||
|
}
|
||||||
|
|
||||||
|
on(window, "scroll", reposition, true);
|
||||||
|
on(window, "resize", reposition);
|
||||||
|
on(document, "bliss:inventory", (event) => { renderTree(event.detail); syncBars(); });
|
||||||
|
on(document, "bliss:inspector-close", () => window.__blissLive && window.__blissLive());
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent("bliss:inventory-request"));
|
document.dispatchEvent(new CustomEvent("bliss:inventory-request"));
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@
|
||||||
function contextIdFor(element, templateId) {
|
function contextIdFor(element, templateId) {
|
||||||
try {
|
try {
|
||||||
const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}");
|
const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}");
|
||||||
if (Object.hasOwn(ids, String(templateId))) return ids[String(templateId)];
|
if (Object.hasOwn(ids, String(templateId)))
|
||||||
|
return ids[String(templateId)];
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return element.dataset.blissTemplateContextId || null;
|
return element.dataset.blissTemplateContextId || null;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +117,8 @@
|
||||||
|
|
||||||
function syncAttributes(element, next) {
|
function syncAttributes(element, next) {
|
||||||
for (const attribute of [...element.attributes]) {
|
for (const attribute of [...element.attributes]) {
|
||||||
if (!next.hasAttribute(attribute.name)) element.removeAttribute(attribute.name);
|
if (!next.hasAttribute(attribute.name))
|
||||||
|
element.removeAttribute(attribute.name);
|
||||||
}
|
}
|
||||||
for (const attribute of [...next.attributes]) {
|
for (const attribute of [...next.attributes]) {
|
||||||
if (element.getAttribute(attribute.name) !== attribute.value) {
|
if (element.getAttribute(attribute.name) !== attribute.value) {
|
||||||
|
|
@ -144,7 +146,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.nodeType !== Node.ELEMENT_NODE) {
|
if (current.nodeType !== Node.ELEMENT_NODE) {
|
||||||
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
if (current.nodeValue !== next.nodeValue)
|
||||||
|
current.nodeValue = next.nodeValue;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +181,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of currentChildren) {
|
for (const child of currentChildren) {
|
||||||
if (!used.has(child) && child.isConnected && !keepLiveNode(child)) child.remove();
|
if (!used.has(child) && child.isConnected && !keepLiveNode(child))
|
||||||
|
child.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,7 +214,8 @@
|
||||||
// A page root must be fetched as a document. Asking for an HTMX
|
// A page root must be fetched as a document. Asking for an HTMX
|
||||||
// fragment here used to parse an empty <body> and erase the page.
|
// fragment here used to parse an empty <body> and erase the page.
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
if (!response.ok)
|
||||||
|
throw new Error(`GET ${url} returned ${response.status}`);
|
||||||
await replaceBody(await response.text());
|
await replaceBody(await response.text());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +235,11 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTemplateElement(element, templateId, previousRoots = []) {
|
async function refreshTemplateElement(
|
||||||
|
element,
|
||||||
|
templateId,
|
||||||
|
previousRoots = [],
|
||||||
|
) {
|
||||||
const response = await fetch("/_bliss/render-template", {
|
const response = await fetch("/_bliss/render-template", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -244,7 +253,24 @@
|
||||||
throw new Error(`Template ${templateId} returned ${response.status}`);
|
throw new Error(`Template ${templateId} returned ${response.status}`);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
if (element === document.body) return replaceBody(html, { morph: true });
|
// The render-template endpoint re-stamps template provenance but not the
|
||||||
|
// route/structure provenance (that comes from the route pipeline). Carry it
|
||||||
|
// over from the element being replaced so the inspector overlay and tree
|
||||||
|
// stay complete after a live template edit, instead of losing the element's
|
||||||
|
// route/copy/clone identity.
|
||||||
|
const provenance = [...element.attributes]
|
||||||
|
.filter((attribute) => attribute.name.startsWith("data-bliss-"))
|
||||||
|
.map((attribute) => ({ name: attribute.name, value: attribute.value }));
|
||||||
|
const carry = (target) => {
|
||||||
|
for (const { name, value } of provenance) {
|
||||||
|
if (!target.hasAttribute(name)) target.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (element === document.body) {
|
||||||
|
await replaceBody(html, { morph: true });
|
||||||
|
carry(document.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fragment = document.createElement("template");
|
const fragment = document.createElement("template");
|
||||||
fragment.innerHTML = html;
|
fragment.innerHTML = html;
|
||||||
// A template may legally have several top-level nodes. They share one
|
// A template may legally have several top-level nodes. They share one
|
||||||
|
|
@ -253,6 +279,7 @@
|
||||||
for (const root of previousRoots) {
|
for (const root of previousRoots) {
|
||||||
if (root !== element && root.isConnected) root.remove();
|
if (root !== element && root.isConnected) root.remove();
|
||||||
}
|
}
|
||||||
|
for (const root of fragment.content.children) carry(root);
|
||||||
element.replaceWith(fragment.content);
|
element.replaceWith(fragment.content);
|
||||||
htmx.process(document.body);
|
htmx.process(document.body);
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +355,11 @@
|
||||||
[data-bliss-structure-id], [data-bliss-template-id] {
|
[data-bliss-structure-id], [data-bliss-template-id] {
|
||||||
transition: outline-color 80ms linear, background-color 80ms linear;
|
transition: outline-color 80ms linear, background-color 80ms linear;
|
||||||
}
|
}
|
||||||
|
html.bliss-inspector-open [data-bliss-structure-id],
|
||||||
|
html.bliss-inspector-open [data-bliss-template-id] {
|
||||||
|
outline: 1px dashed rgba(255, 0, 168, .5);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
.bliss-source-highlight {
|
.bliss-source-highlight {
|
||||||
outline: 4px solid #ff00a8 !important;
|
outline: 4px solid #ff00a8 !important;
|
||||||
outline-offset: 3px !important;
|
outline-offset: 3px !important;
|
||||||
|
|
@ -346,8 +378,12 @@
|
||||||
document.body.appendChild(trigger);
|
document.body.appendChild(trigger);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("bliss:highlight", (event) => highlight(event.detail));
|
document.addEventListener("bliss:highlight", (event) =>
|
||||||
document.addEventListener("bliss:source-saved", (event) => refreshSources(event.detail));
|
highlight(event.detail),
|
||||||
|
);
|
||||||
|
document.addEventListener("bliss:source-saved", (event) =>
|
||||||
|
refreshSources(event.detail),
|
||||||
|
);
|
||||||
document.addEventListener("bliss:inventory-request", publishInventory);
|
document.addEventListener("bliss:inventory-request", publishInventory);
|
||||||
document.addEventListener("bliss:inspector-close", hide);
|
document.addEventListener("bliss:inspector-close", hide);
|
||||||
document.addEventListener("htmx:afterSwap", () => {
|
document.addEventListener("htmx:afterSwap", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue