bliss/bliss-cli/live-editor/slideout.eta
2026-08-07 18:40:04 -04:00

200 lines
9.5 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>