diff --git a/public/index.html b/public/index.html index 02e97af..a449f4e 100644 --- a/public/index.html +++ b/public/index.html @@ -479,6 +479,74 @@ color: var(--accent); } + /* v3.4 — inline direct-manipulation hooks on hero card preview */ + .block-body .hero-svg-host { display: contents; } + .block-body svg [data-cas-target] { + cursor: pointer; + } + .block-body svg [data-cas-target]:hover { + opacity: 0.85; + } + .block-body svg [data-cas-target="name"]:hover, + .block-body svg [data-cas-target="subtitle"]:hover { + opacity: 1; + text-decoration: underline; + text-decoration-color: var(--accent); + text-decoration-thickness: 2px; + } + .block-body .hero-loading { + color: var(--muted); + font-size: 12px; + padding: 24px; + } + + .cas-popover { + position: fixed; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px; + min-width: 160px; + z-index: 100; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5); + } + .cas-popover .cas-popover-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); + padding: 4px 8px 6px; + } + .cas-popover button { + display: block; + width: 100%; + background: transparent; + border: none; + color: var(--text); + padding: 7px 10px; + font-size: 13px; + font-family: inherit; + border-radius: 4px; + cursor: pointer; + text-align: left; + } + .cas-popover button:hover { background: var(--surface); } + .cas-popover button.active { + background: var(--accent); + color: #0d1117; + font-weight: 600; + } + + .composer-inspector .field input.flash, + .composer-inspector .field select.flash { + animation: flashHighlight 1.2s ease-out; + } + @keyframes flashHighlight { + 0% { box-shadow: 0 0 0 2px var(--accent); } + 100% { box-shadow: 0 0 0 0 transparent; } + } + .composer-canvas { padding: 28px 32px; display: flex; @@ -1667,10 +1735,135 @@

Slot inspector

function persistAndRender() { saveComposerDraft(); + closeAllPopovers(); renderBlocks(); renderInspector(); } +// v3.4 — inline direct-manipulation +// +// Hero card blocks render as inline SVG so we can attach click handlers to +// elements marked with data-cas-target. bg → small popover with the 4 bg +// options. name / subtitle → focus the corresponding slot input in the right +// pane (full inline contenteditable on SVG is browser-flaky; +// click-to-jump is the v3.4 MVP, real text-in-canvas edit lives in v3.4.x). + +async function injectHeroSvg(container, slots, blockIndex) { + const url = buildCardUrlForCompose("hero", slots); + let svgText; + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`fetch ${res.status}`); + svgText = await res.text(); + } catch (err) { + container.innerHTML = `
preview unavailable: ${err.message}
`; + return; + } + // Bail if the block was removed or replaced while the request was in flight. + if (!container.isConnected) return; + const parsed = new DOMParser().parseFromString(svgText, "image/svg+xml"); + const svg = parsed.documentElement; + if (!svg || svg.nodeName.toLowerCase() !== "svg") { + container.innerHTML = `
preview unavailable: malformed SVG
`; + return; + } + container.innerHTML = ""; + container.appendChild(svg); + attachCasHandlers(svg, blockIndex); +} + +function attachCasHandlers(svg, blockIndex) { + svg.querySelectorAll("[data-cas-target]").forEach((target) => { + const slotName = target.getAttribute("data-cas-target"); + target.addEventListener("click", (e) => { + e.stopPropagation(); + selectBlock(blockIndex); + if (slotName === "bg") { + showBgPopover(target, blockIndex); + } else if (slotName === "name" || slotName === "subtitle") { + focusSlotInput(slotName); + } + }); + }); +} + +function showBgPopover(targetEl, blockIndex) { + closeAllPopovers(); + const block = composerState.blocks[blockIndex]; + if (!block || block.card_type !== "hero") return; + + const popover = document.createElement("div"); + popover.className = "cas-popover"; + const title = document.createElement("div"); + title.className = "cas-popover-title"; + title.textContent = "Background"; + popover.appendChild(title); + + const options = ["gradient", "wave", "grid", "particles"]; + const current = block.slots.bg; + for (const opt of options) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = opt; + if (opt === current) btn.classList.add("active"); + btn.addEventListener("click", () => { + block.slots.bg = opt; + closeAllPopovers(); + persistAndRender(); + }); + popover.appendChild(btn); + } + + // Position below the clicked region; clamp to viewport. + const rect = targetEl.getBoundingClientRect(); + document.body.appendChild(popover); + const pw = popover.offsetWidth; + const ph = popover.offsetHeight; + let left = rect.left + rect.width / 2 - pw / 2; + let top = rect.bottom + 8; + if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; + if (left < 8) left = 8; + if (top + ph > window.innerHeight - 8) top = rect.top - ph - 8; + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; + + // Defer outside-click registration so this same click doesn't immediately + // close the popover we just opened. + setTimeout(() => { + const dismiss = (e) => { + if (!popover.contains(e.target)) closeAllPopovers(); + }; + popover._dismiss = dismiss; + document.addEventListener("click", dismiss); + }, 0); +} + +function closeAllPopovers() { + document.querySelectorAll(".cas-popover").forEach((p) => { + if (p._dismiss) document.removeEventListener("click", p._dismiss); + p.remove(); + }); +} + +function focusSlotInput(slotName) { + const field = slotForm.querySelector(`label`); + // The slot inspector renders fields in declaration order. Find the input + // whose preceding label text matches the slot name. + const labels = slotForm.querySelectorAll("label"); + for (const lbl of labels) { + if (lbl.textContent.trim() === slotName) { + const input = lbl.parentElement.querySelector("input, select, textarea"); + if (input) { + input.focus(); + if (input.select) input.select(); + input.classList.add("flash"); + setTimeout(() => input.classList.remove("flash"), 1200); + } + return; + } + } +} + function buildCardUrlForCompose(cardType, slots, themeOverride) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(slots)) { @@ -1747,11 +1940,19 @@

Slot inspector

body.className = "block-body"; if (block.type === "card") { - const img = document.createElement("img"); - img.src = buildCardUrlForCompose(block.card_type, block.slots); - img.alt = labelText; - img.loading = "lazy"; - body.appendChild(img); + if (block.card_type === "hero") { + // v3.4 — hero alone gets inline-SVG so click-to-edit hit regions + // can attach. Other card types still render as (no inline + // editing on them yet). + body.innerHTML = '
loading…
'; + injectHeroSvg(body, block.slots, index); + } else { + const img = document.createElement("img"); + img.src = buildCardUrlForCompose(block.card_type, block.slots); + img.alt = labelText; + img.loading = "lazy"; + body.appendChild(img); + } } else if (block.type === "markdown") { const ta = document.createElement("textarea"); ta.value = block.content;