200 lines
9.5 KiB
Text
200 lines
9.5 KiB
Text
<aside id="bliss-live-editor" data-bliss-ui
|
||
class="fixed inset-y-0 left-0 z-[2147483646] w-[min(28rem,90vw)] overflow-auto border-r-4 border-black bg-white text-black shadow-[8px_0_0_#000]"
|
||
style="font-family:ui-sans-serif,system-ui">
|
||
<header class="sticky top-0 flex items-center justify-between border-b-4 border-double border-black bg-white p-3">
|
||
<div><div class="text-lg font-black tracking-tight">Live Page</div><div class="text-xs">Objects currently on this card</div></div>
|
||
<button type="button" data-bliss-close class="h-7 w-7 border-2 border-black bg-white text-lg font-black leading-none shadow-[2px_2px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">×</button>
|
||
</header>
|
||
<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>
|
||
<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>
|
||
</aside>
|
||
<script>
|
||
(function () {
|
||
const root = document.getElementById("bliss-live-editor");
|
||
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 editor = root.querySelector("[data-bliss-editor]");
|
||
root.querySelector("[data-bliss-close]").onclick = () =>
|
||
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) {
|
||
const button = document.createElement("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.textContent = label + (count > 1 ? ` (${count})` : "");
|
||
button.onmouseenter = () => highlight(kind, id, null, true);
|
||
button.onmouseleave = () => highlight(kind, id, null, false);
|
||
if (kind !== "structure") button.onclick = () => openEditor(kind, id);
|
||
return button;
|
||
}
|
||
|
||
function renderTree(items) {
|
||
tree.innerHTML = "";
|
||
const structures = new Map();
|
||
for (const item of items) {
|
||
if (!structures.has(item.structureId)) structures.set(item.structureId, { name: item.structureName, routes: new Map(), templates: new Map(), count: 0 });
|
||
const structure = structures.get(item.structureId);
|
||
structure.count++;
|
||
if (!structure.routes.has(item.routeId)) structure.routes.set(item.routeId, { name: item.routeName, count: 0 });
|
||
structure.routes.get(item.routeId).count++;
|
||
if (item.templateId) {
|
||
if (!structure.templates.has(item.templateId)) structure.templates.set(item.templateId, { name: item.templateName, count: 0 });
|
||
structure.templates.get(item.templateId).count++;
|
||
}
|
||
}
|
||
for (const [structureId, structure] of structures) {
|
||
const group = document.createElement("details");
|
||
group.open = true;
|
||
const heading = document.createElement("summary");
|
||
heading.className = "cursor-pointer list-none font-semibold";
|
||
heading.append(sourceButton(structure.name, "structure", structureId, structure.count));
|
||
group.append(heading);
|
||
for (const [label, kind, entries] of [["Routes", "route", structure.routes], ["Templates", "template", structure.templates]]) {
|
||
if (!entries.size) continue;
|
||
const section = document.createElement("details");
|
||
section.open = true;
|
||
section.className = "ml-3";
|
||
const summary = document.createElement("summary");
|
||
summary.className = "cursor-pointer py-1 text-xs font-bold uppercase tracking-widest";
|
||
summary.textContent = label;
|
||
section.append(summary);
|
||
for (const [id, entry] of entries) section.append(sourceButton(entry.name, kind, id, entry.count));
|
||
group.append(section);
|
||
}
|
||
tree.append(group);
|
||
}
|
||
}
|
||
|
||
// 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"));
|
||
})();
|
||
</script>
|