Skip to content

Instantly share code, notes, and snippets.

@jmcph4
Created January 4, 2026 06:12
Show Gist options
  • Select an option

  • Save jmcph4/f671ea9c2c5ff47e957b79b684eb4484 to your computer and use it in GitHub Desktop.

Select an option

Save jmcph4/f671ea9c2c5ff47e957b79b684eb4484 to your computer and use it in GitHub Desktop.
<!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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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