Created
January 4, 2026 06:12
-
-
Save jmcph4/f671ea9c2c5ff47e957b79b684eb4484 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Minimal DnD Designer</title> | |
| <style> | |
| :root { color-scheme: light dark; } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; margin: 0; font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } | |
| body { display: grid; grid-template-columns: 240px 1fr; } | |
| /* Sidebar */ | |
| #sidebar { | |
| border-right: 1px solid color-mix(in oklab, CanvasText 20%, Canvas 80%); | |
| padding: 12px; | |
| display: grid; | |
| gap: 12px; | |
| align-content: start; | |
| background: color-mix(in oklab, Canvas 92%, CanvasText 8%); | |
| } | |
| #sidebar h1 { | |
| font-size: 13px; | |
| margin: 0; | |
| letter-spacing: .04em; | |
| text-transform: uppercase; | |
| opacity: .8; | |
| } | |
| .palette { display: grid; gap: 8px; } | |
| .tile { | |
| border: 1px solid color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| border-radius: 10px; | |
| padding: 10px 10px; | |
| background: Canvas; | |
| cursor: grab; | |
| user-select: none; | |
| display: grid; | |
| gap: 6px; | |
| } | |
| .tile:active { cursor: grabbing; } | |
| .tile small { opacity: .7; } | |
| #actions { display: grid; gap: 8px; } | |
| button { | |
| border: 1px solid color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| border-radius: 10px; | |
| padding: 10px; | |
| background: Canvas; | |
| cursor: pointer; | |
| } | |
| button:hover { background: color-mix(in oklab, Canvas 90%, CanvasText 10%); } | |
| #hint { | |
| border: 1px dashed color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| border-radius: 10px; | |
| padding: 10px; | |
| opacity: .85; | |
| } | |
| kbd { | |
| font: 12px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; | |
| border: 1px solid color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| padding: 2px 6px; | |
| border-radius: 8px; | |
| background: color-mix(in oklab, Canvas 88%, CanvasText 12%); | |
| } | |
| /* Canvas */ | |
| #main { | |
| display: grid; | |
| grid-template-rows: auto 1fr; | |
| min-width: 0; | |
| min-height: 0; | |
| } | |
| #topbar { | |
| padding: 10px 12px; | |
| border-bottom: 1px solid color-mix(in oklab, CanvasText 20%, Canvas 80%); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| justify-content: space-between; | |
| background: Canvas; | |
| } | |
| #status { | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; | |
| font-size: 12px; | |
| opacity: .8; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 70ch; | |
| } | |
| #canvasWrap { padding: 12px; min-height: 0; } | |
| #canvas { | |
| position: relative; | |
| height: 100%; | |
| min-height: 520px; | |
| border: 1px solid color-mix(in oklab, CanvasText 20%, Canvas 80%); | |
| border-radius: 14px; | |
| background: color-mix(in oklab, Canvas 96%, CanvasText 4%); | |
| overflow: hidden; | |
| } | |
| #canvas.dragover { | |
| outline: 2px solid color-mix(in oklab, CanvasText 35%, Canvas 65%); | |
| outline-offset: -2px; | |
| } | |
| /* Elements on canvas */ | |
| .el { | |
| position: absolute; | |
| border: 1px solid transparent; | |
| border-radius: 12px; | |
| padding: 10px 12px; | |
| background: Canvas; | |
| box-shadow: 0 1px 0 color-mix(in oklab, CanvasText 14%, Canvas 86%); | |
| user-select: none; | |
| cursor: grab; | |
| min-width: 80px; | |
| min-height: 44px; | |
| } | |
| .el:active { cursor: grabbing; } | |
| .el.selected { | |
| border-color: color-mix(in oklab, CanvasText 40%, Canvas 60%); | |
| box-shadow: 0 0 0 2px color-mix(in oklab, CanvasText 18%, Canvas 82%); | |
| } | |
| .el .label { pointer-events: none; } | |
| /* Resizer handle */ | |
| .resizer { | |
| position: absolute; | |
| width: 12px; | |
| height: 12px; | |
| right: 6px; | |
| bottom: 6px; | |
| border-radius: 4px; | |
| border: 1px solid color-mix(in oklab, CanvasText 30%, Canvas 70%); | |
| background: color-mix(in oklab, Canvas 80%, CanvasText 20%); | |
| cursor: nwse-resize; | |
| opacity: .85; | |
| } | |
| .el:not(.selected) .resizer { display: none; } | |
| /* Element variants */ | |
| .el[data-type="button"] { padding: 0; overflow: hidden; } | |
| .el[data-type="button"] button { | |
| width: 100%; | |
| height: 100%; | |
| border: 0; | |
| border-radius: 12px; | |
| background: color-mix(in oklab, Canvas 80%, CanvasText 20%); | |
| cursor: inherit; | |
| font: inherit; | |
| padding: 10px 12px; | |
| } | |
| .el[data-type="image"] { padding: 0; overflow: hidden; } | |
| .el[data-type="image"] .imgph { | |
| width: 100%; height: 100%; | |
| display: grid; | |
| place-items: center; | |
| background: linear-gradient(135deg, | |
| color-mix(in oklab, Canvas 88%, CanvasText 12%), | |
| color-mix(in oklab, Canvas 96%, CanvasText 4%)); | |
| font-size: 12px; | |
| opacity: .8; | |
| border-radius: 12px; | |
| } | |
| /* Modal-ish editor */ | |
| dialog { | |
| border: 1px solid color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| border-radius: 14px; | |
| padding: 0; | |
| max-width: 520px; | |
| width: calc(100vw - 32px); | |
| background: Canvas; | |
| } | |
| dialog::backdrop { background: rgba(0,0,0,.35); } | |
| .dlg { | |
| padding: 12px; | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .dlg header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| } | |
| .dlg header b { font-size: 13px; letter-spacing: .04em; text-transform: uppercase; opacity: .8; } | |
| .row { display: grid; gap: 6px; } | |
| .row label { font-size: 12px; opacity: .8; } | |
| .row input, .row textarea, .row select { | |
| width: 100%; | |
| border-radius: 10px; | |
| border: 1px solid color-mix(in oklab, CanvasText 25%, Canvas 75%); | |
| padding: 10px; | |
| background: Canvas; | |
| color: CanvasText; | |
| font: inherit; | |
| } | |
| .row textarea { min-height: 90px; resize: vertical; } | |
| .dlg footer { display: flex; justify-content: flex-end; gap: 8px; } | |
| /* Mobile */ | |
| @media (max-width: 880px) { | |
| body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } | |
| #sidebar { border-right: 0; border-bottom: 1px solid color-mix(in oklab, CanvasText 20%, Canvas 80%); grid-auto-flow: column; overflow-x: auto; } | |
| #sidebar .palette { grid-auto-flow: column; grid-template-columns: repeat(4, 200px); } | |
| #actions { grid-auto-flow: column; grid-template-columns: repeat(2, 1fr); } | |
| #hint { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <aside id="sidebar"> | |
| <h1>Palette</h1> | |
| <div class="palette"> | |
| <div class="tile" draggable="true" data-type="text" data-default='{"text":"Text","w":180,"h":56}'> | |
| <div><b>Text</b></div> | |
| <small>Drag into canvas</small> | |
| </div> | |
| <div class="tile" draggable="true" data-type="button" data-default='{"text":"Button","w":160,"h":52}'> | |
| <div><b>Button</b></div> | |
| <small>Clickable-looking</small> | |
| </div> | |
| <div class="tile" draggable="true" data-type="box" data-default='{"text":"Box","w":200,"h":120}'> | |
| <div><b>Box</b></div> | |
| <small>Container-ish</small> | |
| </div> | |
| <div class="tile" draggable="true" data-type="image" data-default='{"text":"Image","w":220,"h":140}'> | |
| <div><b>Image</b></div> | |
| <small>Placeholder block</small> | |
| </div> | |
| </div> | |
| <div id="actions"> | |
| <button id="exportBtn" title="Export a single HTML file">Export HTML</button> | |
| <button id="clearBtn" title="Clear the canvas">Clear</button> | |
| </div> | |
| <div id="hint"> | |
| <div style="display:grid; gap:6px"> | |
| <div><kbd>Click</kbd> select</div> | |
| <div><kbd>Drag</kbd> move</div> | |
| <div><kbd>⇧ Shift</kbd> snap</div> | |
| <div><kbd>Del</kbd> delete</div> | |
| <div><kbd>Enter</kbd> edit text</div> | |
| </div> | |
| </div> | |
| </aside> | |
| <main id="main"> | |
| <div id="topbar"> | |
| <div id="status">Drop elements into the canvas.</div> | |
| <div style="display:flex; gap:8px; align-items:center;"> | |
| <span style="opacity:.8; font-size:12px;">Grid: 8px (hold Shift)</span> | |
| </div> | |
| </div> | |
| <div id="canvasWrap"> | |
| <div id="canvas" aria-label="Designer canvas"></div> | |
| </div> | |
| </main> | |
| <dialog id="editor"> | |
| <form method="dialog" class="dlg" id="editorForm"> | |
| <header> | |
| <b>Edit</b> | |
| <button value="cancel" type="submit" style="padding:8px 10px;">✕</button> | |
| </header> | |
| <div class="row"> | |
| <label for="edText">Text</label> | |
| <textarea id="edText" name="text" placeholder="Label..."></textarea> | |
| </div> | |
| <div class="row"> | |
| <label for="edBg">Background</label> | |
| <input id="edBg" name="bg" type="text" placeholder="e.g. Canvas, #111, rgba(...)" /> | |
| </div> | |
| <div class="row"> | |
| <label for="edFg">Text color</label> | |
| <input id="edFg" name="fg" type="text" placeholder="e.g. CanvasText, #fff" /> | |
| </div> | |
| <div class="row"> | |
| <label for="edRadius">Radius (px)</label> | |
| <input id="edRadius" name="radius" type="number" min="0" step="1" /> | |
| </div> | |
| <footer> | |
| <button value="cancel" type="submit">Cancel</button> | |
| <button value="ok" type="submit" id="saveEd">Save</button> | |
| </footer> | |
| </form> | |
| </dialog> | |
| <dialog id="exportDlg"> | |
| <div class="dlg"> | |
| <header> | |
| <b>Export</b> | |
| <button id="closeExport" style="padding:8px 10px;">✕</button> | |
| </header> | |
| <div class="row"> | |
| <label>HTML (save as .html)</label> | |
| <textarea id="exportOut" spellcheck="false"></textarea> | |
| </div> | |
| <footer> | |
| <button id="copyExport">Copy</button> | |
| </footer> | |
| </div> | |
| </dialog> | |
| <script> | |
| (() => { | |
| const canvas = document.getElementById("canvas"); | |
| const status = document.getElementById("status"); | |
| const exportBtn = document.getElementById("exportBtn"); | |
| const clearBtn = document.getElementById("clearBtn"); | |
| const editor = document.getElementById("editor"); | |
| const edText = document.getElementById("edText"); | |
| const edBg = document.getElementById("edBg"); | |
| const edFg = document.getElementById("edFg"); | |
| const edRadius = document.getElementById("edRadius"); | |
| const exportDlg = document.getElementById("exportDlg"); | |
| const exportOut = document.getElementById("exportOut"); | |
| const closeExport = document.getElementById("closeExport"); | |
| const copyExport = document.getElementById("copyExport"); | |
| const GRID = 8; | |
| let selected = null; | |
| let dragState = null; // { id, startX, startY, origLeft, origTop, mode: "move"|"resize" } | |
| let newDrop = null; // temporary during drag from palette | |
| let zCounter = 1; | |
| const setStatus = (s) => (status.textContent = s); | |
| const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); | |
| const snap = (v) => Math.round(v / GRID) * GRID; | |
| const canvasRect = () => canvas.getBoundingClientRect(); | |
| function deselect() { | |
| if (selected) selected.classList.remove("selected"); | |
| selected = null; | |
| } | |
| function select(el) { | |
| if (selected === el) return; | |
| deselect(); | |
| selected = el; | |
| if (selected) { | |
| selected.classList.add("selected"); | |
| selected.style.zIndex = String(++zCounter); | |
| setStatus(`Selected: ${selected.dataset.type} (#${selected.dataset.id})`); | |
| } else { | |
| setStatus("Drop elements into the canvas."); | |
| } | |
| } | |
| function mkId() { | |
| return Math.random().toString(16).slice(2, 10); | |
| } | |
| function mkElement({ type, text, x, y, w, h }) { | |
| const el = document.createElement("div"); | |
| el.className = "el"; | |
| el.dataset.type = type; | |
| el.dataset.id = mkId(); | |
| el.style.left = `${x}px`; | |
| el.style.top = `${y}px`; | |
| el.style.width = `${w}px`; | |
| el.style.height = `${h}px`; | |
| el.style.zIndex = String(++zCounter); | |
| // content | |
| if (type === "button") { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.textContent = text ?? "Button"; | |
| el.appendChild(btn); | |
| } else if (type === "image") { | |
| const ph = document.createElement("div"); | |
| ph.className = "imgph"; | |
| ph.textContent = text ?? "Image"; | |
| el.appendChild(ph); | |
| } else { | |
| const label = document.createElement("div"); | |
| label.className = "label"; | |
| label.textContent = text ?? (type === "box" ? "Box" : "Text"); | |
| el.appendChild(label); | |
| } | |
| const rz = document.createElement("div"); | |
| rz.className = "resizer"; | |
| rz.title = "Resize"; | |
| el.appendChild(rz); | |
| // minimal defaults | |
| if (type === "box") { | |
| el.style.background = "color-mix(in oklab, Canvas 85%, CanvasText 15%)"; | |
| } | |
| // events | |
| el.addEventListener("pointerdown", (e) => onPointerDown(e, el)); | |
| el.addEventListener("dblclick", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| openEditor(el); | |
| }); | |
| return el; | |
| } | |
| function getElText(el) { | |
| const t = el.dataset.type; | |
| if (t === "button") return el.querySelector("button")?.textContent ?? ""; | |
| if (t === "image") return el.querySelector(".imgph")?.textContent ?? ""; | |
| return el.querySelector(".label")?.textContent ?? ""; | |
| } | |
| function setElText(el, text) { | |
| const t = el.dataset.type; | |
| if (t === "button") el.querySelector("button").textContent = text; | |
| else if (t === "image") el.querySelector(".imgph").textContent = text; | |
| else el.querySelector(".label").textContent = text; | |
| } | |
| function openEditor(el) { | |
| select(el); | |
| edText.value = getElText(el); | |
| // pull styles | |
| edBg.value = el.style.background || ""; | |
| edFg.value = el.style.color || ""; | |
| const r = (el.style.borderRadius || "").replace("px", ""); | |
| edRadius.value = r ? Number(r) : 12; | |
| editor.returnValue = ""; | |
| editor.showModal(); | |
| const onClose = () => { | |
| editor.removeEventListener("close", onClose); | |
| if (editor.returnValue !== "ok") return; | |
| setElText(el, edText.value); | |
| if (edBg.value.trim()) el.style.background = edBg.value.trim(); | |
| else el.style.background = ""; | |
| if (edFg.value.trim()) el.style.color = edFg.value.trim(); | |
| else el.style.color = ""; | |
| const rad = Number(edRadius.value); | |
| if (Number.isFinite(rad)) el.style.borderRadius = `${rad}px`; | |
| }; | |
| editor.addEventListener("close", onClose); | |
| } | |
| function onPointerDown(e, el) { | |
| // Only left click / primary | |
| if (e.button !== 0) return; | |
| const target = e.target; | |
| const mode = target.classList.contains("resizer") ? "resize" : "move"; | |
| select(el); | |
| el.setPointerCapture(e.pointerId); | |
| const rect = el.getBoundingClientRect(); | |
| dragState = { | |
| id: el.dataset.id, | |
| mode, | |
| startX: e.clientX, | |
| startY: e.clientY, | |
| origLeft: parseFloat(el.style.left) || 0, | |
| origTop: parseFloat(el.style.top) || 0, | |
| origW: parseFloat(el.style.width) || rect.width, | |
| origH: parseFloat(el.style.height) || rect.height, | |
| }; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| function onPointerMove(e) { | |
| if (!dragState || !selected || selected.dataset.id !== dragState.id) return; | |
| const shift = e.shiftKey; | |
| const cr = canvasRect(); | |
| const maxLeft = cr.width - 20; | |
| const maxTop = cr.height - 20; | |
| if (dragState.mode === "move") { | |
| let dx = e.clientX - dragState.startX; | |
| let dy = e.clientY - dragState.startY; | |
| let left = dragState.origLeft + dx; | |
| let top = dragState.origTop + dy; | |
| if (shift) { left = snap(left); top = snap(top); } | |
| left = clamp(left, 0, maxLeft); | |
| top = clamp(top, 0, maxTop); | |
| selected.style.left = `${left}px`; | |
| selected.style.top = `${top}px`; | |
| setStatus(`Move: x=${Math.round(left)} y=${Math.round(top)} (Shift=snap)`); | |
| } else { | |
| let dw = e.clientX - dragState.startX; | |
| let dh = e.clientY - dragState.startY; | |
| let w = dragState.origW + dw; | |
| let h = dragState.origH + dh; | |
| if (shift) { w = snap(w); h = snap(h); } | |
| w = clamp(w, 60, cr.width - dragState.origLeft); | |
| h = clamp(h, 44, cr.height - dragState.origTop); | |
| selected.style.width = `${w}px`; | |
| selected.style.height = `${h}px`; | |
| setStatus(`Resize: w=${Math.round(w)} h=${Math.round(h)} (Shift=snap)`); | |
| } | |
| } | |
| function onPointerUp(e) { | |
| if (!dragState) return; | |
| dragState = null; | |
| if (selected) setStatus(`Selected: ${selected.dataset.type} (#${selected.dataset.id})`); | |
| } | |
| // canvas background click | |
| canvas.addEventListener("pointerdown", (e) => { | |
| if (e.target === canvas) deselect(); | |
| }); | |
| // global pointer move/up | |
| window.addEventListener("pointermove", onPointerMove); | |
| window.addEventListener("pointerup", onPointerUp); | |
| // palette drag and drop | |
| document.querySelectorAll(".tile").forEach((tile) => { | |
| tile.addEventListener("dragstart", (e) => { | |
| const type = tile.dataset.type; | |
| const def = JSON.parse(tile.dataset.default || "{}"); | |
| const payload = { type, ...def }; | |
| e.dataTransfer.setData("application/json", JSON.stringify(payload)); | |
| e.dataTransfer.effectAllowed = "copy"; | |
| newDrop = payload; | |
| setStatus(`Dragging: ${type}`); | |
| }); | |
| tile.addEventListener("dragend", () => { | |
| newDrop = null; | |
| canvas.classList.remove("dragover"); | |
| if (!selected) setStatus("Drop elements into the canvas."); | |
| }); | |
| }); | |
| canvas.addEventListener("dragover", (e) => { | |
| e.preventDefault(); | |
| canvas.classList.add("dragover"); | |
| e.dataTransfer.dropEffect = "copy"; | |
| }); | |
| canvas.addEventListener("dragleave", () => { | |
| canvas.classList.remove("dragover"); | |
| }); | |
| canvas.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| canvas.classList.remove("dragover"); | |
| let payload = null; | |
| try { | |
| payload = JSON.parse(e.dataTransfer.getData("application/json") || "null"); | |
| } catch {} | |
| if (!payload) payload = newDrop; | |
| if (!payload) return; | |
| const cr = canvasRect(); | |
| const x = clamp(e.clientX - cr.left - (payload.w ?? 160) / 2, 0, cr.width - 40); | |
| const y = clamp(e.clientY - cr.top - (payload.h ?? 60) / 2, 0, cr.height - 40); | |
| const el = mkElement({ | |
| type: payload.type, | |
| text: payload.text, | |
| x: Math.round(x), | |
| y: Math.round(y), | |
| w: payload.w ?? 160, | |
| h: payload.h ?? 60, | |
| }); | |
| canvas.appendChild(el); | |
| select(el); | |
| setStatus(`Added: ${payload.type}`); | |
| }); | |
| // keyboard shortcuts | |
| window.addEventListener("keydown", (e) => { | |
| if (editor.open || exportDlg.open) return; | |
| if (e.key === "Delete" || e.key === "Backspace") { | |
| if (selected) { | |
| const id = selected.dataset.id; | |
| selected.remove(); | |
| selected = null; | |
| setStatus(`Deleted: #${id}`); | |
| } | |
| } else if (e.key === "Enter") { | |
| if (selected) openEditor(selected); | |
| } else if (e.key === "Escape") { | |
| deselect(); | |
| } | |
| }); | |
| // export | |
| function serializeCanvas() { | |
| const els = [...canvas.querySelectorAll(".el")].map((el) => { | |
| const type = el.dataset.type; | |
| const x = parseFloat(el.style.left) || 0; | |
| const y = parseFloat(el.style.top) || 0; | |
| const w = parseFloat(el.style.width) || 160; | |
| const h = parseFloat(el.style.height) || 60; | |
| const text = getElText(el); | |
| const bg = el.style.background || ""; | |
| const fg = el.style.color || ""; | |
| const radius = el.style.borderRadius || ""; | |
| return { type, x, y, w, h, text, bg, fg, radius }; | |
| }); | |
| const cw = canvas.clientWidth; | |
| const ch = canvas.clientHeight; | |
| return { cw, ch, els }; | |
| } | |
| function exportHTML() { | |
| const { cw, ch, els } = serializeCanvas(); | |
| const esc = (s) => | |
| String(s ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """); | |
| const elHtml = els | |
| .map((e) => { | |
| const baseStyle = [ | |
| `position:absolute`, | |
| `left:${e.x}px`, | |
| `top:${e.y}px`, | |
| `width:${e.w}px`, | |
| `height:${e.h}px`, | |
| `border-radius:${e.radius || "12px"}`, | |
| `overflow:hidden`, | |
| ]; | |
| if (e.bg) baseStyle.push(`background:${e.bg}`); | |
| if (e.fg) baseStyle.push(`color:${e.fg}`); | |
| if (e.type === "button") { | |
| return ` | |
| <div class="el" style="${baseStyle.join(";")}"> | |
| <button style="width:100%;height:100%;border:0;border-radius:inherit;background:color-mix(in oklab, Canvas 80%, CanvasText 20%);font:inherit;padding:10px 12px;"> | |
| ${esc(e.text)} | |
| </button> | |
| </div>`; | |
| } | |
| if (e.type === "image") { | |
| return ` | |
| <div class="el" style="${baseStyle.join(";")}"> | |
| <div style="width:100%;height:100%;display:grid;place-items:center;background:linear-gradient(135deg,color-mix(in oklab, Canvas 88%, CanvasText 12%),color-mix(in oklab, Canvas 96%, CanvasText 4%));opacity:.85;font-size:12px;"> | |
| ${esc(e.text)} | |
| </div> | |
| </div>`; | |
| } | |
| const inner = esc(e.text); | |
| const extra = | |
| e.type === "box" | |
| ? `background:${e.bg || "color-mix(in oklab, Canvas 85%, CanvasText 15%)"};` | |
| : ""; | |
| return ` | |
| <div class="el" style="${baseStyle.join(";")};padding:10px 12px;${extra}">${inner}</div>`; | |
| }) | |
| .join("\n"); | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Export</title> | |
| <style> | |
| :root { color-scheme: light dark; } | |
| html, body { height: 100%; margin: 0; font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } | |
| body { display:grid; place-items:center; background: color-mix(in oklab, Canvas 96%, CanvasText 4%); } | |
| #canvas { | |
| position: relative; | |
| width: ${Math.round(cw)}px; | |
| height: ${Math.round(ch)}px; | |
| max-width: calc(100vw - 24px); | |
| max-height: calc(100vh - 24px); | |
| border: 1px solid color-mix(in oklab, CanvasText 20%, Canvas 80%); | |
| border-radius: 14px; | |
| background: Canvas; | |
| overflow:hidden; | |
| } | |
| .el { box-shadow: 0 1px 0 color-mix(in oklab, CanvasText 14%, Canvas 86%); } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="canvas"> | |
| ${elHtml} | |
| </div> | |
| </body> | |
| </html>`; | |
| } | |
| exportBtn.addEventListener("click", () => { | |
| exportOut.value = exportHTML(); | |
| exportDlg.showModal(); | |
| exportOut.focus(); | |
| exportOut.select(); | |
| }); | |
| closeExport.addEventListener("click", () => exportDlg.close()); | |
| copyExport.addEventListener("click", async () => { | |
| try { | |
| await navigator.clipboard.writeText(exportOut.value); | |
| copyExport.textContent = "Copied"; | |
| setTimeout(() => (copyExport.textContent = "Copy"), 900); | |
| } catch { | |
| copyExport.textContent = "Copy failed"; | |
| setTimeout(() => (copyExport.textContent = "Copy"), 900); | |
| } | |
| }); | |
| clearBtn.addEventListener("click", () => { | |
| canvas.querySelectorAll(".el").forEach((n) => n.remove()); | |
| deselect(); | |
| setStatus("Cleared."); | |
| }); | |
| // initial | |
| setStatus("Drop elements into the canvas."); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment