Open the folder your app lives in and pick an HTML file.
Everything you change is written back into that same file, so Claude Code
and this designer stay on the same source.
Select something in the preview.
v0.9
Changes
', i); i = e < 0 ? n : e + 3; continue; }
if (mode === 'html' && src.startsWith('', i); i = e < 0 ? n : e + 1; continue; }
const t = readTag(src, i);
if (!t) { i++; continue; }
if (t.name === 'script' && !t.closing) {
i = t.tagEnd;
if (!t.selfClose) { mode = 'js'; inScript = true; braceDepth = 0; ctx.length = 0; htmlStack = stack; stack = []; }
continue;
}
if (t.name === 'style' && !t.closing) { i = t.tagEnd; if (!t.selfClose) mode = 'css'; continue; }
if (t.name === 'script' || t.name === 'style') { i = t.end; continue; }
if (t.closing) { closeTag(t); i = t.end; continue; }
openTag(t);
i = t.tagEnd;
/* = 0) i = e;
}
continue;
}
i++;
}
/* text span for elements whose content is plain text */
for (const node of nodes) {
if (node.spanOk && node.closeStart > node.tagEnd) {
const inner = src.slice(node.tagEnd, node.closeStart);
node.textOnly = !inner.includes('<');
node.innerStart = node.tagEnd;
node.innerEnd = node.closeStart;
} else {
node.textOnly = false;
}
}
return { nodes, roots };
}
/* Insert a source id on every tag, for the preview copy only. The real file
is never instrumented — edits are applied to it by offset. */
function instrument(src, nodes) {
const out = [];
let last = src.length;
for (let k = nodes.length - 1; k >= 0; k--) {
const nd = nodes[k];
out.push(src.slice(nd.nameEnd, last));
out.push(' data-dsn="' + nd.id + '"');
last = nd.nameEnd;
}
out.push(src.slice(0, last));
return out.reverse().join('');
}
/* ── Raptool HTML Designer · source edit operations ───────────────────────────────
Every operation takes the file text plus a scanned node and returns new
file text. Offsets change after each edit, so the caller re-scans.
──────────────────────────────────────────────────────────────────────── */
function lineStart(src, pos) { return src.lastIndexOf('\n', pos - 1) + 1; }
function indentAt(src, pos) {
const bol = lineStart(src, pos);
return (/^[ \t]*/.exec(src.slice(bol, pos)) || [''])[0];
}
/* A node "owns its line" when nothing but whitespace precedes it. Those are
placed on their own line when moved; inline ones stay inline. */
function ownsLine(src, node) {
const bol = lineStart(src, node.start);
return /^[ \t]*$/.test(src.slice(bol, node.start));
}
function anchorOffset(node, pos) {
switch (pos) {
case 'before': return node.start;
case 'after': return node.end;
case 'firstChild': return node.tagEnd;
case 'lastChild': return node.closeStart > 0 ? node.closeStart : node.tagEnd;
default: return node.end;
}
}
/* Remove a node and the blank line it leaves behind. */
function cut(src, node) {
let s = node.start, e = node.end;
if (ownsLine(src, node)) {
s = lineStart(src, node.start);
if (src[e] === '\r') e++;
if (src[e] === '\n') e++;
}
return { text: src.slice(node.start, node.end), before: src.slice(0, s), after: src.slice(e), removed: e - s, at: s };
}
function insertText(src, offset, markup, block, indent) {
const piece = block ? indent + markup + '\n' : markup;
const at = block ? lineStart(src, offset) === offset ? offset : offset : offset;
if (block) {
/* place on a line of its own */
const ls = lineStart(src, offset);
const onOwnLine = /^[ \t]*$/.test(src.slice(ls, offset));
if (onOwnLine) return src.slice(0, ls) + indent + markup + '\n' + src.slice(ls);
return src.slice(0, offset) + '\n' + indent + markup + src.slice(offset);
}
return src.slice(0, at) + piece + src.slice(at);
}
function insertMarkup(src, markup, target, pos) {
const block = markup.length > 40 || ownsLine(src, target);
const indent = indentAt(src, target.start);
const off = anchorOffset(target, pos);
return insertText(src, off, markup, block, indent);
}
function deleteNode(src, node) {
const c = cut(src, node);
return c.before + c.after;
}
function duplicateNode(src, node) {
const text = src.slice(node.start, node.end);
return insertText(src, node.end, text, ownsLine(src, node), indentAt(src, node.start));
}
function moveNode(src, node, target, pos) {
if (target.start >= node.start && target.start < node.end) return null; /* into itself */
const off = anchorOffset(target, pos);
if (off > node.start && off < node.end) return null;
const text = src.slice(node.start, node.end);
const block = ownsLine(src, node);
const indent = indentAt(src, target.start);
const c = cut(src, node);
const stripped = c.before + c.after;
const adj = off > node.start ? off - c.removed : off;
return insertText(stripped, adj, text, block, indent);
}
/* attributes ------------------------------------------------------------- */
function attrRange(node, name) {
const re = new RegExp('(^|\\s)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
'\\s*=\\s*("[^"]*"|\'[^\']*\'|[^\\s>]+)', 'i');
const m = re.exec(node.attrText);
if (!m) return null;
const start = node.nameEnd + m.index + m[1].length;
return { start, end: start + m[0].length - m[1].length, value: m[2].replace(/^["']|["']$/g, '') };
}
function setAttr(src, node, name, value) {
const r = attrRange(node, name);
if (value === null || value === '') {
if (!r) return src;
let s = r.start, e = r.end;
while (s > node.nameEnd && /\s/.test(src[s - 1])) s--;
return src.slice(0, s) + src.slice(e);
}
const decl = name + '="' + String(value).replace(/"/g, '"') + '"';
if (r) return src.slice(0, r.start) + decl + src.slice(r.end);
return src.slice(0, node.nameEnd) + ' ' + decl + src.slice(node.nameEnd);
}
function getAttr(node, name) {
const r = attrRange(node, name);
return r ? r.value : '';
}
/* inline style merge ------------------------------------------------------ */
function parseStyle(text) {
const out = [];
String(text || '').split(';').forEach((d) => {
const i = d.indexOf(':');
if (i > 0) out.push([d.slice(0, i).trim(), d.slice(i + 1).trim()]);
});
return out;
}
function styleString(pairs) {
return pairs.filter(([k, v]) => k && v !== '').map(([k, v]) => k + ':' + v).join(';');
}
function setStyleProp(src, node, prop, value) {
const pairs = parseStyle(getAttr(node, 'style'));
const i = pairs.findIndex(([k]) => k.toLowerCase() === prop.toLowerCase());
if (value === '' || value == null) { if (i >= 0) pairs.splice(i, 1); }
else if (i >= 0) pairs[i][1] = value;
else pairs.push([prop, value]);
return setAttr(src, node, 'style', styleString(pairs));
}
/* content ----------------------------------------------------------------- */
function innerSource(src, node) {
return node.closeStart > 0 ? src.slice(node.tagEnd, node.closeStart) : '';
}
function setInnerSource(src, node, text) {
if (node.closeStart < 0) return src;
return src.slice(0, node.tagEnd) + text + src.slice(node.closeStart);
}
/* ═══ app ════════════════════════════════════════════════════════════ */
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const S = {
dir: null, fileHandle: null, fileName: '',
src: '', original: '', nodes: [], roots: [],
sel: -1, hover: -1, tool: 'select', savedOnce: false, diskStamp: 0,
hist: [], hi: -1, changes: [], leftTab: 'add', rightTab: 'element',
frameW: 1280, ready: false, classHints: {}, tokens: [], themeRange: null,
zoom: 1, /* preview scale: a device wider than the stage is shown smaller */
};
const HIST_MAX = 20;
const VERSION = '0.9';
let toastT;
function toast(msg, ms = 2200) {
const t = $('#toast'); t.textContent = msg; t.classList.add('on');
clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('on'), ms);
}
function toastActions(msg, actions, ms = 6000) {
const t = $('#toast'); t.textContent = msg;
for (const [label, fn] of actions) {
const b = document.createElement('button'); b.textContent = label;
b.onclick = () => { t.classList.remove('on'); fn(); };
t.appendChild(b);
}
t.classList.add('on');
clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('on'), ms);
}
function toastUndo(msg, ms = 5000) { toastActions(msg, [['Undo', undo]], ms); }
/* ── source pipeline ─────────────────────────────────────────────────── */
function rescan() {
const r = scanSource(S.src);
S.nodes = r.nodes; S.roots = r.roots;
buildLineIndex();
$('#stNodes').textContent = r.nodes.length.toLocaleString() + ' elements in source';
collectClassHints();
readTheme();
}
/* Inserted elements should look native, so copy the class attribute most
often used by that tag in this file. */
function collectClassHints() {
const tally = {};
for (const n of S.nodes) {
const c = (n.attrs.class || '').trim();
if (!c || c.includes('${')) continue;
(tally[n.tag] ||= {})[c] = (tally[n.tag][c] || 0) + 1;
}
S.classHints = {};
for (const tag in tally) {
S.classHints[tag] = Object.entries(tally[tag]).sort((a, b) => b[1] - a[1])[0][0];
}
}
/* A shim so an app that relies on localStorage still boots when the preview
runs from a file:// page, where storage can be unavailable. */
function preamble() {
const js = "(function(){try{var t='__dsn_t';localStorage.setItem(t,'1');localStorage.removeItem(t);}" +
"catch(e){var m={};try{Object.defineProperty(window,'localStorage',{configurable:true,value:{" +
"getItem:function(k){return Object.prototype.hasOwnProperty.call(m,k)?m[k]:null}," +
"setItem:function(k,v){m[k]=String(v)},removeItem:function(k){delete m[k]}," +
"clear:function(){m={}},key:function(i){return Object.keys(m)[i]||null}}});}catch(_){}}})();";
return '' + js + '';
}
function buildPreviewHtml() {
let html = instrument(S.src, S.nodes);
const m = /]*>/i.exec(html);
if (m) html = html.slice(0, m.index + m[0].length) + preamble() + html.slice(m.index + m[0].length);
else html = preamble() + html;
return html;
}
let pendingHash = null, pendingScroll = 0;
function reloadPreview(keepPlace = true) {
const f = $('#frame');
if (keepPlace && f.contentWindow) {
try { pendingHash = f.contentWindow.location.hash; pendingScroll = f.contentWindow.scrollY; } catch (e) {}
}
S.ready = false;
clearOverlay();
f.srcdoc = buildPreviewHtml();
}
$('#frame').addEventListener('load', () => {
const f = $('#frame');
let d;
try { d = f.contentDocument; } catch (e) { return; }
if (!d) return;
if (pendingHash) {
try { if (f.contentWindow.location.hash !== pendingHash) f.contentWindow.location.hash = pendingHash; } catch (e) {}
}
setTimeout(() => { try { f.contentWindow.scrollTo(0, pendingScroll); } catch (e) {} }, 60);
attachFrame(d);
S.ready = true;
refreshLayers();
syncOverlay();
});
/* ── history ─────────────────────────────────────────────────────────── */
function pushHistory(label, kind) {
S.hist.length = S.hi + 1;
S.hist.push({ src: S.src, label, kind });
if (S.hist.length > HIST_MAX) S.hist.shift();
S.hi = S.hist.length - 1;
S.changes.push({ label, kind, t: Date.now() });
updateChrome();
}
function undo() {
if (S.hi <= 0) return;
S.hi--; S.src = S.hist[S.hi].src;
S.changes.pop();
afterEdit('Undid ' + (S.hist[S.hi + 1].label || 'change'));
}
function redo() {
if (S.hi >= S.hist.length - 1) return;
S.hi++; S.src = S.hist[S.hi].src;
S.changes.push({ label: S.hist[S.hi].label, kind: S.hist[S.hi].kind, t: Date.now() });
afterEdit('Redid ' + (S.hist[S.hi].label || 'change'));
}
/* Apply an edit: keep the selection alive across the re-scan by remembering
where the element sits in the file. */
function applyEdit(newSrc, label, kind, keepOffset) {
if (newSrc == null || newSrc === S.src) return false;
S.src = newSrc;
pushHistory(label, kind || 'edit');
afterEdit(null, keepOffset);
return true;
}
function afterEdit(msg, keepOffset) {
const prev = S.sel >= 0 ? S.nodes[S.sel] : null;
const locate = keepOffset != null ? keepOffset : prev ? prev.start : null;
rescan();
if (typeof locate === 'string') {
/* the element moved or was just inserted — find it by its exact source */
const hit = S.nodes.find((n) => S.src.slice(n.start, n.end) === locate);
S.sel = hit ? hit.id : -1;
} else if (locate != null) {
let best = -1, bestD = Infinity;
for (const n of S.nodes) {
const d = Math.abs(n.start - locate);
if (d < bestD && (!prev || n.tag === prev.tag)) { bestD = d; best = n.id; }
}
S.sel = bestD < 400 ? best : -1;
}
updateChrome();
reloadPreview(true);
renderInspector();
if (msg) toast(msg);
}
function updateChrome() {
const dirty = S.src !== S.original;
$('#chgCount').textContent = String(S.changes.length);
$('#btnDiff').disabled = !dirty;
$('#btnSave').disabled = !dirty;
$('#btnUndo').disabled = S.hi <= 0;
$('#btnRedo').disabled = S.hi >= S.hist.length - 1;
const p = $('#savePill'), t = $('#savePillText');
p.className = 'pill ' + (!S.fileName ? 'off' : dirty ? 'dirty' : 'ok');
t.textContent = !S.fileName ? 'No file open' : dirty
? S.changes.length + ' unsaved change' + (S.changes.length === 1 ? '' : 's')
: 'Saved';
}
/* ── files ───────────────────────────────────────────────────────────── */
function canFS() { return typeof window.showDirectoryPicker === 'function' && window.self === window.top; }
async function openFolder() {
if (!canFS()) { $('#fileInput').click(); return; }
let dir;
try { dir = await window.showDirectoryPicker({ mode: 'readwrite', id: 'raptool-html-designer' }); }
catch (e) { return; }
S.dir = dir;
const files = [];
for await (const [name, h] of dir.entries()) {
if (h.kind === 'file' && /\.html?$/i.test(name) && !/\.bak-/.test(name)) files.push([name, h]);
}
files.sort((a, b) => a[0].localeCompare(b[0]));
const sel = $('#fileSel');
sel.innerHTML = '' +
files.map(([n], i) => '').join('');
sel.style.display = '';
sel._files = files;
if (!files.length) { toast('No .html files in that folder'); return; }
const w = $('#welcome'); w.style.display = ''; w.style.maxWidth = '560px';
w.innerHTML = '
';
body.querySelectorAll('[data-attr]').forEach((inp) =>
inp.addEventListener('change', () => setAttribute(inp.dataset.attr, inp.value)));
const ib = $('#innerBox');
if (ib) ib.addEventListener('change', () => {
const nd = S.nodes[S.sel];
const val = ib.value;
const simple = !val.includes('<') && !val.includes('${') && plain;
const out = setInnerSource(S.src, nd, val);
if (simple && selEl) { try { selEl.textContent = val; } catch (e) {} applyLive(out, 'Edited text of ' + describe(nd), 'text'); scheduleSync(); }
else applyEdit(out, 'Edited content of ' + describe(nd), 'text', nd.start);
});
const sp = $('#selParent');
if (sp) sp.onclick = () => { const p = S.nodes[S.sel].parent; if (p >= 0) select(p, selEl && selEl.parentElement ? elWithDsn(selEl.parentElement) : null); };
$('#dupBtn').onclick = () => { const nd = S.nodes[S.sel]; applyEdit(duplicateNode(S.src, nd), 'Duplicated ' + describe(nd), 'add', nd.start); };
$('#delBtn').onclick = () => {
const nd = S.nodes[S.sel];
applyEdit(deleteNode(S.src, nd), 'Deleted ' + describe(nd), 'delete', nd.start);
S.sel = -1; selEl = null;
};
}
/* ── theme tokens ────────────────────────────────────────────────────── */
function readTheme() {
S.tokens = []; S.themeRange = null;
const styleAt = S.src.search(/
Everything is !important because the desktop value it has to beat is
an inline style. The phone block comes after the tablet block, so at
390 px both match and the phone rule wins.
──────────────────────────────────────────────────────────────────── */
const FH_MEDIA = {
tablet: { q: '@media (max-width: 834px)', tag: 'tablet', label: 'tablets and phones' },
phone: { q: '@media (max-width: 480px)', tag: 'phone', label: 'phones only' },
};
const FH_ORDER = ['tablet', 'phone'];
const FH_OPEN = '', inner);
if (e < 0) return null;
return { open: m.index, inner, innerEnd: e, end: e + 8 };
}
/* Create the block on first use: before , else before
. */
function fhEnsureBlock(src) {
if (fhBlockRange(src)) return { src, at: 0, delta: 0 };
const block = FH_OPEN + FH_NOTE + '\n';
let at;
const h = /<\/head\s*>/i.exec(src);
if (h) at = h.index;
else { const b = /<\/body\s*>/i.exec(src); at = b ? b.index : src.length; }
return { src: src.slice(0, at) + block + src.slice(at), at, delta: block.length };
}
function fhMediaRe(mode) {
const q = FH_MEDIA[mode].q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(q + '\\s*\\{\\s*\\/\\* ' + FH_MEDIA[mode].tag + ' \\*\\/\\n([\\s\\S]*?)\\n\\}', '');
}
/* The whole block, read into { tablet:[[key,decls]…], phone:[…] }. */
function fhParseBlock(src) {
const model = { tablet: [], phone: [] };
const r = fhBlockRange(src);
if (!r) return model;
const inner = src.slice(r.inner, r.innerEnd);
for (const mode of FH_ORDER) {
const m = fhMediaRe(mode).exec(inner);
if (!m) continue;
const re = /\[data-rt="([^"]+)"\]\s*\{([^}]*)\}/g;
let x;
while ((x = re.exec(m[1]))) model[mode].push([x[1], x[2].trim()]);
}
return model;
}
function fhSerialize(model) {
let out = FH_NOTE;
for (const mode of FH_ORDER) {
const rows = model[mode];
if (!rows.length) continue;
out += FH_MEDIA[mode].q + ' { /* ' + FH_MEDIA[mode].tag + ' */\n' +
rows.map(([k, d]) => '[data-rt="' + k + '"] { ' + d + ' }').join('\n') + '\n}\n';
}
return out;
}
/* Write the model back. Returns the offset it touched and by how much,
so a caller can keep a node offset that sits after it. */
function fhWriteBlock(src, model) {
const empty = FH_ORDER.every((m) => !model[m].length);
let r = fhBlockRange(src), extra = 0, at;
if (!r) {
if (empty) return { src, at: src.length, delta: 0 };
const e = fhEnsureBlock(src);
src = e.src; extra = e.delta; at = e.at;
r = fhBlockRange(src);
} else at = r.inner;
const body = fhSerialize(model);
const before = src.slice(r.inner, r.innerEnd);
if (body === before) return { src, at, delta: extra };
src = src.slice(0, r.inner) + body + src.slice(r.innerEnd);
return { src, at, delta: extra + (body.length - before.length) };
}
function fhDeclParse(s) {
return String(s || '').split(';').map((x) => x.trim()).filter(Boolean).map((d) => {
const i = d.indexOf(':');
if (i < 0) return null;
return [d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).replace(/!\s*important/i, '').trim()];
}).filter((p) => p && p[0] && p[1]);
}
function fhDeclString(pairs) { return pairs.map(([k, v]) => k + ': ' + v + ' !important;').join(' '); }
function fhSetDecl(pairs, prop, value) {
const i = pairs.findIndex(([k]) => k === prop.toLowerCase());
if (value === '' || value == null) { if (i >= 0) pairs.splice(i, 1); }
else if (i >= 0) pairs[i][1] = value;
else pairs.push([prop.toLowerCase(), value]);
}
/* Read / write / clear one element's rules, on the source text. */
function fhModeGet(src, key, mode) {
const rows = fhParseBlock(src)[mode] || [];
const hit = rows.find(([k]) => k === key);
return hit ? hit[1] : '';
}
function fhModeSet(src, key, mode, decls) {
const model = fhParseBlock(src);
const rows = model[mode];
const i = rows.findIndex(([k]) => k === key);
if (!decls) { if (i >= 0) rows.splice(i, 1); }
else if (i >= 0) rows[i][1] = decls;
else rows.push([key, decls]);
return fhWriteBlock(src, model);
}
function fhModeClear(src, key) {
const model = fhParseBlock(src);
for (const m of FH_ORDER) {
const i = model[m].findIndex(([k]) => k === key);
if (i >= 0) model[m].splice(i, 1);
}
return fhWriteBlock(src, model);
}
function fhHasAnyMode(src, key) {
if (!key) return false;
const model = fhParseBlock(src);
return FH_ORDER.some((m) => model[m].some(([k]) => k === key));
}
/* A short, stable id for the element, unique in this file. */
const FH_B36 = 'abcdefghijklmnopqrstuvwxyz0123456789';
function fhNewKey(src) {
for (let t = 0; t < 600; t++) {
let k = FH_B36[(Math.random() * 26) | 0];
const len = 4 + (t > 80 ? 1 : 0);
for (let i = 1; i < len; i++) k += FH_B36[(Math.random() * 36) | 0];
if (src.indexOf('data-rt="' + k + '"') < 0) return k;
}
return 'rt' + Date.now().toString(36).slice(-3);
}
/* ── writing one set of changes, respecting the device mode ──────────── */
/* The preview shows styles that the source keeps in the device block —
put it back in step once the user stops. */
let fhLoadedSrc = null;
const _fhReloadPreview = reloadPreview;
reloadPreview = function (keepPlace) { fhLoadedSrc = S.src; return _fhReloadPreview(keepPlace); };
function fhScheduleResync() {
fhDirtyLive = true;
clearTimeout(fhResyncT);
fhResyncT = setTimeout(() => { fhResyncT = null; fhFlushLive(); }, 700);
}
function fhFlushLive(force) {
clearTimeout(fhResyncT); fhResyncT = null;
if (!fhDirtyLive && !force) return;
fhDirtyLive = false;
if (frameDoc) reloadPreview(true);
}
/* decls = [[prop, value] …]; value '' removes. `f` carries the parent
that may need position:relative and the element for live updates. */
function fhCommit(node, decls, label, f, coalesce) {
if (!node) return false;
const mode = fhModeOf(fhDev);
let src = S.src, start = node.start, moved = false;
/* the parent of a Canvas element must be positioned — it sits BEFORE
the element in the file, so that edit shifts the element's offset */
if (f && f.needRel && f.parentStart != null) {
const p = S.nodes.find((x) => x.start === f.parentStart);
if (p) {
const b = src.length;
src = fhStyleEdit(src, p, [['position', 'relative']]);
if (p.start < start) start += src.length - b;
moved = src.length !== b;
if (f.parent) { try { f.parent.style.position = 'relative'; } catch (e) {} }
}
}
if (mode) {
const n2 = (moved ? fhNodeAt(src, start) : node) || node;
let key = getAttr(n2, 'data-rt');
if (!key) {
key = fhNewKey(src);
src = setAttr(src, n2, 'data-rt', key);
if (f && f.el) { try { f.el.setAttribute('data-rt', key); } catch (e) {} }
}
const cur = fhDeclParse(fhModeGet(src, key, mode));
for (const [p, v] of decls) fhSetDecl(cur, p, v);
const r = fhModeSet(src, key, mode, fhDeclString(cur));
src = r.src;
if (r.at <= start) start += r.delta;
fhPush(src, label, coalesce);
fhScheduleResync();
return true;
}
const n3 = (moved ? fhNodeAt(src, start) : node) || node;
src = fhStyleEdit(src, n3, decls);
fhPush(src, label, coalesce);
return true;
}
/* One history entry, or fold into the previous one when the user is
holding an arrow key down. */
function fhPush(src, label, coalesce) {
if (coalesce && S.hi > 0 && S.hist[S.hi] && S.hist[S.hi].label === label) {
S.src = src; S.hist[S.hi].src = src;
rescan(); updateChrome();
if (typeof saveDraft === 'function') saveDraft();
} else {
applyLive(src, label, 'move');
}
scheduleSync(); renderInspector();
}
/* ═══ measuring an element before it is moved ═════════════════════════ */
function fhIsCanvas(el) {
return !!(el && el.classList && (el.classList.contains('rt-canvas') || el.hasAttribute('data-canvas')));
}
/* The current value of one offset: the device override wins, then the
inline (desktop) value, then the computed one. */
function fhBase(node, el, prop, cs, useComputed) {
const mode = fhModeOf(fhDev);
if (mode && node) {
const key = getAttr(node, 'data-rt');
if (key) {
const hit = fhDeclParse(fhModeGet(S.src, key, mode)).find(([k]) => k === prop);
if (hit) return fhNum(hit[1]);
}
}
if (el.style && el.style[prop]) return fhNum(el.style[prop]);
if (useComputed && cs && cs[prop] && cs[prop] !== 'auto') return fhNum(cs[prop]);
return 0;
}
/* Which side an absolutely placed element is pinned to.
getComputedStyle() is no help: Chrome reports the USED value, so `left`
is a number even when the author wrote `left:auto;right:20px`. So read
what was actually declared — inline first, then the stylesheets that
apply at this width. */
function fhSpecified(el, prop) {
if (el.style && el.style.getPropertyValue(prop)) return el.style.getPropertyValue(prop);
const win = fhWin();
let out = '';
const walk = (rules) => {
if (!rules) return;
for (const rule of rules) {
if (rule.media || rule.conditionText) {
let on = true;
try { on = win.matchMedia(rule.conditionText || rule.media.mediaText).matches; } catch (e) {}
if (on) walk(rule.cssRules);
continue;
}
if (!rule.selectorText || !rule.style) continue;
let hit = false;
try { hit = el.matches(rule.selectorText); } catch (e) {}
if (!hit) continue;
const v = rule.style.getPropertyValue(prop);
if (v) out = v;
}
};
try { for (const sheet of frameDoc.styleSheets) { try { walk(sheet.cssRules); } catch (e) {} } } catch (e) {}
return out;
}
/* Container padding box + every sibling box, in PAGE coordinates.
The preview may be shown scaled (S.zoom): boxFor() already converts a
rect, the border widths are frame px and have to be scaled by hand. */
function fhGeometry(el, parent) {
const out = { cont: null, sibs: [] };
if (!parent) return out;
const k = fhZoom();
const pcs = fhCS(parent), pb = boxFor(parent);
if (pcs) {
out.cont = {
left: pb.left + fhNum(pcs.borderLeftWidth) * k,
top: pb.top + fhNum(pcs.borderTopWidth) * k,
right: pb.left + pb.w - fhNum(pcs.borderRightWidth) * k,
bottom: pb.top + pb.h - fhNum(pcs.borderBottomWidth) * k,
};
}
for (const kid of parent.children) {
if (kid === el) continue;
const b = boxFor(kid);
if (b.w < 1 && b.h < 1) continue;
out.sibs.push({ left: b.left, top: b.top, right: b.left + b.w, bottom: b.top + b.h });
}
return out;
}
/* Everything the write-back needs, measured once when the drag starts. */
function fhMeasure(node, el) {
const cs = fhCS(el);
if (!cs) return null;
const r = el.getBoundingClientRect(), parent = el.parentElement;
const pos = cs.position;
let kind = 'flow';
if (pos === 'absolute' || pos === 'fixed') kind = 'abs';
else if (fhIsCanvas(parent)) kind = 'canvas';
const f = {
kind, el, parent, node: node || null,
nodeStart: node ? node.start : null,
css0: el.getAttribute('style') || '', op0: el.style.opacity || '',
w: r.width, h: r.height, /* frame px — what gets written out */
r0: boxFor(el), /* page px — what the guides compare */
geo: fhGeometry(el, parent),
parentStart: null, parentCss0: null, needRel: false,
wasStatic: pos === 'static',
inlineRel: (el.style.position || '').trim() === 'relative',
dx: 0, dy: 0,
};
if (kind === 'abs') {
const sp = (k) => fhSpecified(el, k);
f.useRight = !/^\s*(|auto)\s*$/.test(sp('right') || '') && /^\s*(|auto)\s*$/.test(sp('left') || '');
f.useBottom = !/^\s*(|auto)\s*$/.test(sp('bottom') || '') && /^\s*(|auto)\s*$/.test(sp('top') || '');
f.x0 = fhBase(node, el, f.useRight ? 'right' : 'left', cs, true);
f.y0 = fhBase(node, el, f.useBottom ? 'bottom' : 'top', cs, true);
} else if (kind === 'canvas') {
const pcs = fhCS(parent), pr = parent.getBoundingClientRect();
/* `left`/`top` place the MARGIN edge, but the box we measured is the
border box — an
or an would land its own margin lower
than the pointer without this. */
f.x0 = Math.round(r.left - pr.left - fhNum(pcs.borderLeftWidth) - fhNum(cs.marginLeft));
f.y0 = Math.round(r.top - pr.top - fhNum(pcs.borderTopWidth) - fhNum(cs.marginTop));
f.needRel = pcs.position === 'static';
const pid = parent.getAttribute && parent.getAttribute('data-dsn');
const pn = pid != null ? S.nodes[+pid] : null;
f.parentStart = pn ? pn.start : null;
f.parentCss0 = parent.getAttribute('style') || '';
if (f.parentStart == null) f.needRel = false;
} else {
f.x0 = fhBase(node, el, 'left', cs, false);
f.y0 = fhBase(node, el, 'top', cs, false);
}
return f;
}
function fhRestore(f) {
if (!f || !f.el) return;
try { f.css0 ? f.el.setAttribute('style', f.css0) : f.el.removeAttribute('style'); } catch (e) {}
if (f.parent && f.parentCss0 != null) {
try { f.parentCss0 ? f.parent.setAttribute('style', f.parentCss0) : f.parent.removeAttribute('style'); } catch (e) {}
}
}
/* ═══ the write-back rules ════════════════════════════════════════════
dx / dy are the pointer delta in preview CSS px.
absolute / fixed → add to inline left/top (or subtract from
right/bottom when the element is pinned that way)
inside a Canvas → become position:absolute at that exact spot, with
width/height pinned so it cannot collapse
anything else → stay in the layout, nudged with position:relative
+ left/top; within 2 px of home the offsets go
──────────────────────────────────────────────────────────────────── */
function fhMoveDecls(f, dx, dy, tol) {
const R = Math.round;
if (tol == null) tol = FH_HOME;
if (f.kind === 'abs') {
return [
[f.useRight ? 'right' : 'left', R(f.useRight ? f.x0 - dx : f.x0 + dx) + 'px'],
[f.useBottom ? 'bottom' : 'top', R(f.useBottom ? f.y0 - dy : f.y0 + dy) + 'px'],
];
}
if (f.kind === 'canvas') {
return [
['position', 'absolute'],
['left', R(f.x0 + dx) + 'px'], ['top', R(f.y0 + dy) + 'px'],
['width', R(f.w) + 'px'], ['height', R(f.h) + 'px'],
];
}
const L = Math.round(f.x0 + dx), T = Math.round(f.y0 + dy);
/* on a device override an explicit value is always recorded — "back in
the layout" is a desktop idea, the override has to beat the inline */
if (!fhModeOf(fhDev) && Math.abs(L) <= tol && Math.abs(T) <= tol) {
const out = [['left', ''], ['top', '']];
if (f.wasStatic || f.inlineRel) out.push(['position', '']);
return out;
}
const out = [];
if (f.wasStatic) out.push(['position', 'relative']);
out.push(['left', L + 'px'], ['top', T + 'px']);
return out;
}
/* ═══ smart magnetic guides ═══════════════════════════════════════════ */
function fhSnapDelta(f, dx, dy, axisLock) {
const guides = [];
if (!fhSnapOn || !f.geo) return { dx, dy, guides };
const g = f.geo, r = f.r0;
const vs = [], hs = [];
const push = (arr, pos, a, b) => { if (isFinite(pos)) arr.push({ pos, a, b }); };
if (g.cont) {
push(vs, g.cont.left, g.cont.top, g.cont.bottom);
push(vs, (g.cont.left + g.cont.right) / 2, g.cont.top, g.cont.bottom);
push(vs, g.cont.right, g.cont.top, g.cont.bottom);
push(hs, g.cont.top, g.cont.left, g.cont.right);
push(hs, (g.cont.top + g.cont.bottom) / 2, g.cont.left, g.cont.right);
push(hs, g.cont.bottom, g.cont.left, g.cont.right);
}
for (const s of g.sibs) {
const a = g.cont ? g.cont.top : s.top, b = g.cont ? g.cont.bottom : s.bottom;
const c = g.cont ? g.cont.left : s.left, d = g.cont ? g.cont.right : s.right;
push(vs, s.left, a, b); push(vs, (s.left + s.right) / 2, a, b); push(vs, s.right, a, b);
push(hs, s.top, c, d); push(hs, (s.top + s.bottom) / 2, c, d); push(hs, s.bottom, c, d);
}
const pick = (lines, edges) => {
let best = null;
for (const L of lines) for (const e of edges) {
const d = L.pos - e;
if (Math.abs(d) <= FH_SNAP && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, line: L };
}
return best;
};
if (axisLock !== 'y') {
const x = r.left + dx;
const b = pick(vs, [x, x + r.w / 2, x + r.w]);
if (b) { dx += b.d; guides.push({ axis: 'v', pos: b.line.pos, a: b.line.a, b: b.line.b }); }
}
if (axisLock !== 'x') {
const y = r.top + dy;
const b = pick(hs, [y, y + r.h / 2, y + r.h]);
if (b) { dy += b.d; guides.push({ axis: 'h', pos: b.line.pos, a: b.line.a, b: b.line.b }); }
}
/* a line that stops short of the thing in hand explains nothing — let
each one reach from the container across the element being dragged */
for (const g of guides) {
if (g.axis === 'v') { g.a = Math.min(g.a, r.top + dy); g.b = Math.max(g.b, r.top + dy + r.h); }
else { g.a = Math.min(g.a, r.left + dx); g.b = Math.max(g.b, r.left + dx + r.w); }
}
return { dx, dy, guides };
}
function fhDrawGuides(ov) {
for (const g of fhGuides) {
const d = document.createElement('div');
d.className = 'fhg ' + g.axis;
if (g.axis === 'v') {
d.style.left = Math.round(g.pos) + 'px'; d.style.top = g.a + 'px';
d.style.height = Math.max(2, g.b - g.a) + 'px';
} else {
d.style.top = Math.round(g.pos) + 'px'; d.style.left = g.a + 'px';
d.style.width = Math.max(2, g.b - g.a) + 'px';
}
ov.appendChild(d);
}
}
/* ═══ free dragging ═══════════════════════════════════════════════════ */
const _fhBeginDrag = beginDrag;
beginDrag = function (opts) {
/* dragging something in from a panel is unmistakably editing — leave
"Use the app" mode so the drop is not silently ignored */
if (opts && opts.kind === 'insert' && S.tool !== 'select') { setTool('select'); toast('Back to editing — drop it where you want it', 2200); }
_fhBeginDrag(opts);
if (!fhFree || !dragState) return;
if (dragState.kind === 'insert') { dragState.fhBlock = fhIsBlockMarkup(dragState.markup); return; }
if (dragState.kind !== 'move' || !dragState.el || !dragState.el.isConnected) return;
const n = S.nodes[dragState.nodeId];
const f = fhMeasure(n, dragState.el);
if (f) dragState.fh = f;
};
/* A whole block is meant to stack with the other blocks, so it keeps the
Layout behaviour even in Free mode — it just lands after the block it
was dropped on rather than in the nearest inline slot. */
function fhIsBlockMarkup(markup) {
return /^\s* { fhUsePress = S.tool !== 'select' ? { x: e.clientX, y: e.clientY } : null; }, true);
d.addEventListener('pointermove', (e) => {
if (!fhUsePress || S.tool === 'select' || e.buttons === 0) return;
if (Math.hypot(e.clientX - fhUsePress.x, e.clientY - fhUsePress.y) < 24) return;
fhUsePress = null;
if (Date.now() - fhUseHintAt < 8000) return;
fhUseHintAt = Date.now();
toastActions('You are using the app right now, so nothing moves — switch back to editing to move things', [['Back to editing', () => setTool('select')]], 6000);
}, true);
d.addEventListener('pointerup', () => { fhUsePress = null; }, true);
};
const _fhOnDragMove = onDragMove;
onDragMove = function (e) {
const st = dragState;
if (fhFree && st && st.kind === 'insert') { fhTrackMods(e); fhInsertMove(st, e); return; }
if (!fhFree || !st || !st.fh) { _fhOnDragMove(e); return; }
fhTrackMods(e);
if (st.fh.resize) fhResizeMove(st, e); else fhFreeMove(st, e);
};
/* Dragging something NEW in: the ghost follows the pointer and there is
no drop mark — where you let go is where it goes. */
function fhInsertMove(st, e) {
const dx = e.clientX - st.startX, dy = e.clientY - st.startY;
if (!st.moved && Math.hypot(dx, dy) < 7) return;
st.moved = true;
showGhost(); moveGhost(e.clientX, e.clientY);
if (!ghostEl) {
const g = $('#ghost');
g.textContent = st.label; g.style.display = 'block';
g.style.left = (e.clientX + 14) + 'px'; g.style.top = (e.clientY + 14) + 'px';
}
st.fhPt = { x: e.clientX, y: e.clientY };
if (st.fhBlock) { dropInfo = fhBlockSlot(e.clientX, e.clientY) || dropTargetAt(e.clientX, e.clientY); drawDropMark(); return; }
dropInfo = null; dropMark = null;
scheduleSync();
}
/* Where a whole block lands: after the block under the pointer. */
function fhBlockSlot(cx, cy) {
if (!frameDoc) return null;
const fr = frameRect(), k = fhZoom();
const x = (cx - fr.left) / k, y = (cy - fr.top) / k;
if (x < 0 || y < 0 || x > fr.width / k || y > fr.height / k) return null;
let el = elWithDsn(frameDoc.elementFromPoint(x, y));
if (!el) return null;
let node = S.nodes[+el.getAttribute('data-dsn')];
if (!node) return null;
if (typeof bkFindBlockAncestor === 'function') node = bkFindBlockAncestor(node) || node;
else while (node.parent >= 0 && S.nodes[node.parent] && S.nodes[node.parent].parent >= 0) node = S.nodes[node.parent];
const bel = frameDoc.querySelector('[data-dsn="' + node.id + '"]');
if (!bel) return null;
return { el: bel, node, pos: 'after', rect: bel.getBoundingClientRect(), row: false, fr };
}
function fhFreeMove(st, e) {
const f = st.fh;
/* the pointer delta is PAGE px — that is the space the guides live in;
the offset written into the file is the app's own CSS px */
let dx = e.clientX - st.startX, dy = e.clientY - st.startY;
if (!st.moved && Math.hypot(dx, dy) < 7) return;
if (!st.moved) { st.moved = true; try { st.el.style.opacity = f.op0; } catch (x) {} }
st.moved = true;
f.alt = fhMods.alt;
let lock = null;
if (fhMods.shift) {
if (Math.abs(dx) >= Math.abs(dy)) { dy = 0; lock = 'x'; } else { dx = 0; lock = 'y'; }
}
const s = fhSnapDelta(f, dx, dy, lock);
const k = fhZoom();
f.dx = s.dx / k; f.dy = s.dy / k; fhGuides = s.guides;
fhStyleLive(f.el, fhMoveDecls(f, f.dx, f.dy));
if (f.needRel && f.parent) { try { f.parent.style.position = 'relative'; } catch (x) {} }
scheduleSync();
}
/* Dropping a palette / block card also produces a click on that card
(the pointer capture sends the mouse-up back to it), which would add a
SECOND copy through the click-to-add path. Swallow that one click. */
let fhDropAt = 0;
document.addEventListener('click', (e) => {
if (Date.now() - fhDropAt < 500) { e.preventDefault(); e.stopPropagation(); }
}, true);
const _fhOnDragUp = onDragUp;
onDragUp = function () {
const st = dragState;
if (st && st.kind === 'insert' && st.moved) fhDropAt = Date.now();
if (fhFreeInsert(st) && st.moved && st.fhPt) {
const job = fhInsertPlan(st);
if (job) {
dropInfo = null; dropMark = null; st.moved = false; /* base cleans up only */
_fhOnDragUp();
swallowClick = true; setTimeout(() => { swallowClick = false; }, 400);
fhRunInsert(job);
return;
}
/* nowhere sensible to put it — the Layout behaviour and its toast */
dropInfo = dropTargetAt(st.fhPt.x, st.fhPt.y);
}
if (!fhFree || !st || !st.fh) { _fhOnDragUp(); return; }
const f = st.fh, moved = st.moved;
dropInfo = null; dropMark = null;
st.moved = false; /* the base only cleans up, no drop */
fhGuides = [];
_fhOnDragUp();
try { f.el.style.opacity = f.op0; } catch (e) {}
if (!moved) { fhRestore(f); scheduleSync(); return; }
swallowClick = true; setTimeout(() => { swallowClick = false; }, 400);
if (f.resize) fhCommitResize(f); else fhCommitMove(f);
};
function fhCommitMove(f) {
const node = S.nodes.find((x) => x.start === f.nodeStart);
if (!node) { scheduleSync(); return; }
if (!f.dx && !f.dy) { scheduleSync(); return; }
const decls = fhMoveDecls(f, f.dx, f.dy);
const many = repeatOf(f.el);
if (f.alt) {
const label = 'Duplicated and moved ' + describe(node);
fhDuplicateAndMove(node, decls, label, f);
return;
}
const label = 'Moved ' + describe(node) + (many > 1 ? ' in every one of the ' + many + ' items' : '');
fhCommit(node, decls, label, f);
toastUndo(label);
}
/* Alt or Ctrl held: the original stays, the copy takes the new place. */
function fhDuplicateAndMove(node, decls, label, f) {
const text = S.src.slice(node.start, node.end);
let src = duplicateNode(S.src, node);
const dup = scanSource(src).nodes.find((x) => x.start >= node.end && src.slice(x.start, x.end) === text);
fhRestore(f);
if (!dup) { toast('That element could not be copied'); scheduleSync(); return; }
let start = dup.start;
const mode = fhModeOf(fhDev);
if (mode) {
const key = fhNewKey(src);
src = setAttr(src, dup, 'data-rt', key);
const cur = [];
for (const [p, v] of decls) fhSetDecl(cur, p, v);
const r = fhModeSet(src, key, mode, fhDeclString(cur));
src = r.src;
if (r.at <= start) start += r.delta;
} else {
src = fhStyleEdit(src, dup, decls);
}
fhDirtyLive = false; clearTimeout(fhResyncT); fhResyncT = null;
applyEdit(src, label, 'move', start);
toastUndo(label);
}
/* ═══ adding something new, exactly where it is dropped ══════════════
Two edits — put the markup in, then push it to the drop point — folded
into ONE history entry, so Undo takes the whole thing away again.
──────────────────────────────────────────────────────────────────── */
/* Containers something new may be dropped into. */
const FH_BOX = /^(div|section|form|main|header|footer|nav|aside|article|fieldset|li|td|ul|ol)$/;
/* The container under the pointer: the nearest ancestor with data-dsn
that is a box and is drawn only once (a template row is drawn N times,
so climb out of it the way dropTargetAt does). */
function fhDropContainer(cx, cy) {
if (!frameDoc) return null;
const fr = frameRect(), k = fhZoom();
const x = (cx - fr.left) / k, y = (cy - fr.top) / k;
if (x < 0 || y < 0 || x > fr.width / k || y > fr.height / k) return null;
/* every box the pointer is inside, innermost first */
const chain = [];
let el = elWithDsn(frameDoc.elementFromPoint(x, y));
while (el) {
const n = S.nodes[+el.getAttribute('data-dsn')];
if (n && FH_BOX.test(n.tag) && n.closeStart > 0 && repeatOf(el) === 1 && !fhRuntimeFilled(el, n))
chain.push({ el, node: n });
el = elWithDsn(el.parentElement);
}
/* a Canvas holds things exactly where they are put, so drop straight
into the innermost one the pointer is over */
const canvas = chain.find((c) => fhIsCanvas(c.el));
if (canvas) return canvas;
/* Otherwise take the OUTERMOST box — the screen, not the card inside
it. A free drop must never be buried inside something already on the
screen: the new element would make that thing taller, which moves it
and everything below it, and "where you dropped it" would stop being
true for what is already there. The offset puts the element exactly
where it was let go either way. */
const usable = chain.filter((c) => !fhReflows(c.el) && !fhFreePlaced(c.el));
if (usable.length) return usable[usable.length - 1];
if (chain.length) return chain[chain.length - 1];
/* the pointer is over the page background (body / margins) */
return fhScreenRootBox(x, y);
}
/* A flex or grid container shares its space out between its children, so
adding one to it moves all the others. */
function fhReflows(el) {
const cs = fhCS(el);
return !!cs && /^(flex|grid|inline-flex|inline-grid)$/.test((cs.display || '').trim());
}
/* Something this designer has already placed by hand. */
function fhFreePlaced(el) {
const s = el && el.style;
if (!s) return false;
return !!(s.left || s.top) && /^(relative|absolute|fixed)$/.test(s.position || '');
}
/* The box a drop with nothing under it belongs to: the biggest one that
contains the point (a Canvas wins outright), else the biggest on screen. */
function fhScreenRootBox(x, y) {
if (!frameDoc) return null;
let best = null, bestArea = 0, bestInside = false;
for (const el of frameDoc.body.querySelectorAll('[data-dsn]')) {
const n = S.nodes[+el.getAttribute('data-dsn')];
if (!n || !FH_BOX.test(n.tag) || n.closeStart < 0 || repeatOf(el) !== 1 || fhRuntimeFilled(el, n)) continue;
if (fhReflows(el) || fhFreePlaced(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 40 || r.height < 40) continue;
const inside = x != null && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
if (inside && fhIsCanvas(el)) return { el, node: n };
const area = r.width * r.height;
if ((inside && !bestInside) || (inside === bestInside && area > bestArea)) {
bestInside = inside; bestArea = area; best = { el, node: n };
}
}
return best;
}
/* A box that is empty in the file but full on screen is filled by the app
itself (innerHTML = …). Anything put inside it would be wiped the next
time the app draws, so keep climbing and place it in a real container. */
function fhRuntimeFilled(el, n) {
if (!n || n.closeStart < 0) return false;
if (S.src.slice(n.tagEnd, n.closeStart).trim() !== '') return false;
return el.children.length > 0;
}
function fhInsertPlan(st) {
const hit = fhDropContainer(st.fhPt.x, st.fhPt.y);
if (!hit) return null;
return { markup: st.markup, label: st.label, contStart: hit.node.start,
px: st.fhPt.x, py: st.fhPt.y };
}
let fhPending = null;
function fhRunInsert(job) {
const cont = S.nodes.find((x) => x.start === job.contStart);
if (!cont) { toast('Nothing moved — let go over a part of the app'); return; }
const out = insertMarkup(S.src, job.markup, cont, 'lastChild');
if (out === S.src) { toast('That could not be added here'); return; }
job.label = 'Added ' + job.label + (job.phrase || ' where you dropped it');
fhPending = job;
fhDirtyLive = false; clearTimeout(fhResyncT); fhResyncT = null;
if (!applyEdit(out, job.label, 'add', job.markup)) fhPending = null;
}
/* The preview has reloaded — the new element exists, so measure where it
landed and nudge it onto the drop point with the normal write-back. */
$('#frame').addEventListener('load', () => {
if (!fhPending) return;
const job = fhPending; fhPending = null;
setTimeout(() => fhPlaceDropped(job), 60);
});
function fhPlaceDropped(job) {
const cont = S.nodes.find((x) => x.start === job.contStart);
if (!cont || !frameDoc) { toastUndo(job.label); return; }
const contEl = frameDoc.querySelector('[data-dsn="' + cont.id + '"]');
if (!contEl) { toastUndo(job.label); return; }
let el = null;
for (let c = contEl.lastElementChild; c; c = c.previousElementSibling) {
if (c.getAttribute && c.getAttribute('data-dsn') != null) { el = c; break; }
}
const node = el ? S.nodes[+el.getAttribute('data-dsn')] : null;
if (!el || !node) { toastUndo(job.label); return; }
select(node.id, el);
const f = fhMeasure(node, el);
if (!f) { toastUndo(job.label); return; }
const b = boxFor(el), k = fhZoom();
const dx = (job.px - b.left) / k, dy = (job.py - b.top) / k;
const decls = fhMoveDecls(f, dx, dy);
const size = fhDefaultSize(f, el, node);
if (size) decls.push(...size);
fhStyleLive(el, decls);
if (f.needRel && f.parent) { try { f.parent.style.position = 'relative'; } catch (e) {} }
fhCommit(node, decls, job.label, f, true); /* same label → one entry */
toastUndo(job.label);
}
/* An app's stylesheet usually says `input { width: 100% }`, so something
dropped into a normal container arrives stretched across the screen —
which is never what "put it here" meant. When the new element fills its
container's content width exactly, give it a sensible size instead.
Buttons, headings and text keep their natural width; a Canvas already
pins the rendered size, so this only applies to flow containers. */
const FH_DROP_W = { input: 240, select: 240, textarea: 240, img: 160 };
function fhDefaultSize(f, el, node) {
if (!f || f.kind !== 'flow' || !f.parent || !node) return null;
const want = FH_DROP_W[node.tag];
if (!want || el.style.width) return null;
const pcs = fhCS(f.parent);
if (!pcs) return null;
const inner = f.parent.clientWidth - fhNum(pcs.paddingLeft) - fhNum(pcs.paddingRight);
if (inner <= want + 2 || Math.abs(f.w - inner) > 2) return null; /* not stretched */
return [['width', want + 'px']];
}
/* Click-to-add: keep the base placement, except on a Canvas, where the
new element goes 16 px below the bottom-left of what is selected. */
const _fhSmartInsert = smartInsert;
smartInsert = function (markup, label) {
const n = selNode(), el = selEl && selEl.isConnected ? selEl : null;
if (fhFree && n && el && frameDoc) {
const canvas = fhIsCanvas(el) ? el : (fhIsCanvas(el.parentElement) ? el.parentElement : null);
if (canvas && canvas.getAttribute('data-dsn') != null) {
const cn = S.nodes[+canvas.getAttribute('data-dsn')];
if (cn) {
const k = fhZoom();
let px, py;
if (canvas === el) {
const cs = fhCS(canvas), cb = boxFor(canvas);
px = cb.left + (fhNum(cs.borderLeftWidth) + fhNum(cs.paddingLeft)) * k;
py = cb.top + (fhNum(cs.borderTopWidth) + fhNum(cs.paddingTop)) * k;
} else {
const b = boxFor(el);
px = b.left; py = b.top + b.h + 16 * k;
}
fhRunInsert({ markup, label, contStart: cn.start, px, py, phrase: ' on the canvas' });
return;
}
}
}
_fhSmartInsert(markup, label);
};
/* ═══ eight resize handles ════════════════════════════════════════════ */
const FH_DIRS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
function fhStartResize(dir, ev, b) {
const el = selEl, n = selNode();
if (!el || !n) return;
const cs = fhCS(el);
if (!cs) return;
/* `b` is the handle box in PAGE px; sizes are written in frame px */
const k = fhZoom(), w0 = b.w / k, h0 = b.h / k;
beginDrag({ kind: 'resize', axis: dir === 'e' || dir === 'w' ? 'e' : dir === 'n' || dir === 's' ? 's' : 'se',
nodeId: S.sel, el, pointerId: ev.pointerId, captureEl: ev.currentTarget,
startX: ev.clientX, startY: ev.clientY, w0: b.w, h0: b.h, label: 'resize' });
if (!dragState) return;
const pos = cs.position;
dragState.fh = {
resize: true, dir, el, parent: el.parentElement, node: n, nodeStart: n.start,
css0: el.getAttribute('style') || '', op0: el.style.opacity || '',
w0: w0, h0: h0, w: w0, h: h0,
movable: pos === 'absolute' || pos === 'fixed' || !!el.style.left || !!el.style.top,
wasStatic: pos === 'static',
x0: fhBase(n, el, 'left', cs, pos === 'absolute' || pos === 'fixed'),
y0: fhBase(n, el, 'top', cs, pos === 'absolute' || pos === 'fixed'),
parentStart: null, needRel: false, moveL: null, moveT: null,
};
}
function fhResizeMove(st, e) {
const f = st.fh;
const pdx = e.clientX - st.startX, pdy = e.clientY - st.startY; /* page px */
if (!st.moved && Math.hypot(pdx, pdy) < 4) return;
st.moved = true;
const k = fhZoom(), dx = pdx / k, dy = pdy / k; /* frame px */
const d = f.dir;
let dw = d.includes('e') ? dx : d.includes('w') ? -dx : 0;
let dh = d.includes('s') ? dy : d.includes('n') ? -dy : 0;
if (fhMods.alt) { dw *= 2; dh *= 2; }
if (fhMods.shift && f.w0 > 0 && f.h0 > 0) {
const ratio = f.w0 / f.h0;
if (d === 'n' || d === 's') dw = dh * ratio;
else if (d === 'e' || d === 'w') dh = dw / ratio;
else if (Math.abs(dw) > Math.abs(dh * ratio)) dh = dw / ratio;
else dw = dh * ratio;
}
const w = Math.max(8, Math.round(f.w0 + dw)), h = Math.max(8, Math.round(f.h0 + dh));
f.w = w; f.h = h; f.moveL = null; f.moveT = null;
const live = [['width', w + 'px'], ['height', h + 'px']];
if (f.movable) {
let L = f.x0, T = f.y0;
if (fhMods.alt) { L = f.x0 - (w - f.w0) / 2; T = f.y0 - (h - f.h0) / 2; }
else { if (d.includes('w')) L = f.x0 - (w - f.w0); if (d.includes('n')) T = f.y0 - (h - f.h0); }
if (Math.round(L) !== Math.round(f.x0)) { f.moveL = Math.round(L); live.push(['left', f.moveL + 'px']); }
if (Math.round(T) !== Math.round(f.y0)) { f.moveT = Math.round(T); live.push(['top', f.moveT + 'px']); }
}
fhStyleLive(f.el, live);
scheduleSync();
}
function fhCommitResize(f) {
const node = S.nodes.find((x) => x.start === f.nodeStart);
if (!node) { scheduleSync(); return; }
const decls = [['width', f.w + 'px'], ['height', f.h + 'px']];
if (f.moveL != null) decls.push(['left', f.moveL + 'px']);
if (f.moveT != null) decls.push(['top', f.moveT + 'px']);
const label = 'Resized ' + describe(node);
fhCommit(node, decls, label, f);
toastUndo(label);
}
/* ═══ keyboard ════════════════════════════════════════════════════════ */
/* Arrows must be caught BEFORE the base handlers, which turn them into
structural moves. A capture listener on the window (and on the
preview's own window) runs before the ones bound to the documents. */
function fhNudge(dx, dy) {
const node = selNode(), el = selEl;
if (!node || !el || !el.isConnected) return;
const f = fhMeasure(node, el);
if (!f) return;
/* a 1 px key nudge must not be swallowed by the "close enough to home"
rule the drag uses — here only an exact 0/0 counts as back home */
const decls = fhMoveDecls(f, dx, dy, 0);
fhStyleLive(el, decls);
if (f.needRel && f.parent) { try { f.parent.style.position = 'relative'; } catch (e) {} }
const many = repeatOf(el);
const label = 'Moved ' + describe(node) + (many > 1 ? ' in every one of the ' + many + ' items' : '');
const now = Date.now();
const coalesce = now - fhNudgeAt < 800;
fhNudgeAt = now;
fhCommit(node, decls, label, f, coalesce);
}
function fhZ(dir) {
const node = selNode(), el = selEl;
if (!node || !el) return;
const cur = parseInt(fhInlineZ(node), 10) || 0;
const z = cur + dir;
const cs = fhCS(el);
const decls = [['z-index', String(z)]];
if (cs && cs.position === 'static') decls.push(['position', 'relative']);
fhStyleLive(el, decls);
const label = (dir > 0 ? 'Brought ' : 'Sent ') + describe(node) + (dir > 0 ? ' forward' : ' backward');
fhCommit(node, decls, label, null);
toastUndo(label);
}
function fhInlineZ(node) {
const mode = fhModeOf(fhDev);
if (mode) {
const key = getAttr(node, 'data-rt');
const hit = key ? fhDeclParse(fhModeGet(S.src, key, mode)).find(([k]) => k === 'z-index') : null;
if (hit) return hit[1];
}
const p = parseStyle(getAttr(node, 'style')).find(([k]) => k.toLowerCase() === 'z-index');
return p ? p[1] : '';
}
function fhTyping(t) {
return /^(INPUT|TEXTAREA|SELECT)$/.test((t && t.tagName) || '') || (t && t.isContentEditable);
}
function fhKey(e) {
fhTrackMods(e);
if (fhTyping(e.target)) return;
const mod = e.ctrlKey || e.metaKey;
if (e.altKey && (e.key === ']' || e.key === '[' || e.code === 'BracketRight' || e.code === 'BracketLeft')) {
if (S.sel < 0) return;
e.preventDefault(); e.stopPropagation();
fhZ(e.key === ']' || e.code === 'BracketRight' ? 1 : -1);
return;
}
if (mod || e.altKey) return;
if ((e.key === 'f' || e.key === 'F') && S.fileName) {
e.preventDefault(); e.stopPropagation();
fhSetFree(!fhFree, true);
return;
}
if (S.fileName && /^[1-4]$/.test(e.key)) {
e.preventDefault(); e.stopPropagation();
fhSetDev(FH_DEVS[+e.key - 1].v, true);
return;
}
if (!fhFree || S.sel < 0 || dragState) return;
const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault(); e.stopPropagation();
const k = e.shiftKey ? 10 : 1;
fhNudge(d[0] * k, d[1] * k);
}
window.addEventListener('keydown', fhKey, true);
window.addEventListener('keyup', fhTrackMods, true);
const _fhAttachFrame = attachFrame;
attachFrame = function (d) {
_fhAttachFrame(d);
const w = d.defaultView;
if (w) {
w.addEventListener('keydown', fhKey, true);
w.addEventListener('keyup', fhTrackMods, true);
}
fhDirtyLive = false;
clearTimeout(fhResyncT); fhResyncT = null;
};
/* ═══ overlay: eight handles, guides, hidden marker ═══════════════════ */
function fhHiddenHere(node) {
const mode = fhModeOf(fhDev);
if (!mode || !node) return false;
const key = getAttr(node, 'data-rt');
if (!key) return false;
const hit = fhDeclParse(fhModeGet(S.src, key, mode)).find(([k]) => k === 'display');
return !!hit && hit[1] === 'none';
}
const _fhSyncOverlay = syncOverlay;
syncOverlay = function () {
_fhSyncOverlay();
const ov = $('#overlay');
if (!ov) return;
fhDrawGuides(ov);
if (!frameDoc || S.tool !== 'select' || !selEl || !selEl.isConnected) return;
const node = selNode();
/* hidden on this device: nothing to outline, so mark where it lives */
if (node && fhHiddenHere(node)) {
const host = selEl.parentElement;
ov.querySelectorAll('.ov.sel').forEach((x) => x.remove()); /* a 0×0 box helps nobody */
if (host) {
const b = boxFor(host);
const box = mkBox(b, 'fhhid');
const badge = document.createElement('div');
badge.className = 'tagbadge';
badge.innerHTML = '' + ico(G.eyeOff) + '' +
esc(describe(node)) + ' · hidden on ' + esc(fhDevInfo(fhDev).name);
box.appendChild(badge);
ov.appendChild(box);
}
} else if (node) {
const badge = ov.querySelector('.ov.sel .tagbadge');
if (badge && fhHasAnyMode(S.src, getAttr(node, 'data-rt'))) {
badge.insertAdjacentHTML('afterbegin', '' + ico(FH_I.tablet) + '');
}
}
if (!fhFree) return;
ov.querySelectorAll('.hdl').forEach((h) => h.remove());
const b = boxFor(selEl);
if (b.w < 1 && b.h < 1) return;
const fr = frameRect();
if (b.top > fr.bottom || b.top + b.h < fr.top - 4) return;
for (const dir of FH_DIRS) {
const h = document.createElement('div');
h.className = 'hdl fh ' + dir;
const x = dir.includes('w') ? b.left : dir.includes('e') ? b.left + b.w : b.left + b.w / 2;
const y = dir.includes('n') ? b.top : dir.includes('s') ? b.top + b.h : b.top + b.h / 2;
h.style.left = (x - 5) + 'px'; h.style.top = (y - 5) + 'px';
h.title = 'Drag to resize (Shift keeps the shape, Alt from the middle)';
h.addEventListener('pointerdown', (ev) => {
ev.preventDefault(); ev.stopPropagation();
fhTrackMods(ev);
fhStartResize(dir, ev, b);
});
ov.appendChild(h);
}
};
/* ═══ quick toolbar extras ════════════════════════════════════════════ */
function fhHasOffsets(node) {
const st = parseStyle(getAttr(node, 'style'));
return st.some(([k, v]) => /^(left|top|right|bottom|z-index)$/i.test(k.trim()) ||
(/^position$/i.test(k.trim()) && /^(relative|absolute)$/i.test(String(v).trim())));
}
function fhResetPos() {
const node = selNode();
if (!node) return;
const decls = [['left', ''], ['top', ''], ['right', ''], ['bottom', ''], ['z-index', '']];
const p = parseStyle(getAttr(node, 'style')).find(([k]) => k.toLowerCase() === 'position');
if (p && /^(relative|absolute)$/i.test(p[1].trim())) decls.push(['position', '']);
if (selEl) fhStyleLive(selEl, decls);
const label = 'Snapped ' + describe(node) + ' back into the layout';
let src = fhStyleEdit(S.src, node, decls);
applyLive(src, label, 'move');
scheduleSync(); renderInspector();
toastUndo(label);
}
function fhHideHere() {
const node = selNode(), mode = fhModeOf(fhDev);
if (!node || !mode) return;
const hidden = fhHiddenHere(node);
const label = (hidden ? 'Showed ' : 'Hid ') + describe(node) + ' on ' + fhDevInfo(fhDev).name.toLowerCase() + 's';
if (selEl) { try { hidden ? selEl.style.removeProperty('display') : selEl.style.setProperty('display', 'none', 'important'); } catch (e) {} }
fhCommit(node, [['display', hidden ? '' : 'none']], label, null);
toastUndo(label);
}
function fhResetResponsive() {
const node = selNode();
if (!node) return;
const key = getAttr(node, 'data-rt');
if (!key || !fhHasAnyMode(S.src, key)) { toast('This element has no per-device changes'); return; }
const r = fhModeClear(S.src, key);
const label = 'Removed the per-device changes on ' + describe(node);
applyLive(r.src, label, 'move');
fhFlushLive(true);
toastUndo(label);
}
const _fhBuildQuickBar = buildQuickBar;
buildQuickBar = function () {
_fhBuildQuickBar();
if (!qtb) return;
const node = selNode();
if (!node) return;
const bar = qtb;
const add = (icon, title, fn, on) => {
const x = document.createElement('button');
x.innerHTML = ico(icon); x.title = title;
if (on) x.classList.add('fhon');
x.onpointerdown = (e) => { e.preventDefault(); e.stopPropagation(); };
x.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fn(); };
bar.appendChild(x);
};
const vr = () => bar.appendChild(Object.assign(document.createElement('span'), { className: 'vr' }));
const mode = fhModeOf(fhDev);
let sep = false;
if (fhFree) {
vr(); sep = true;
add(FH_I.fwd, 'Bring forward (Alt+])', () => fhZ(1));
add(FH_I.back, 'Send backward (Alt+[)', () => fhZ(-1));
}
if (fhHasOffsets(node)) {
if (!sep) { vr(); sep = true; }
add(FH_I.home, 'Reset position — put it back in the layout', fhResetPos);
}
if (mode) {
vr();
const hidden = fhHiddenHere(node);
add(hidden ? G.eye : G.eyeOff,
(hidden ? 'Show it again on ' : 'Hide on this device — ') + fhDevInfo(fhDev).name.toLowerCase() + 's',
fhHideHere, hidden);
if (fhHasAnyMode(S.src, getAttr(node, 'data-rt'))) {
add(FH_I.resp, 'Reset responsive — drop every per-device change on this element', fhResetResponsive);
}
}
/* a hidden element has no box — hang the bar off its container instead */
const fr = frameRect();
if (mode && fhHiddenHere(node) && selEl && selEl.parentElement) {
const b = boxFor(selEl.parentElement);
bar.style.left = b.left + 'px';
bar.style.top = (b.top - fr.top > 46 ? b.top - 39 : b.top + b.h + 8) + 'px';
}
/* keep the bar inside the frame after growing it */
const w = bar.getBoundingClientRect().width;
const left = Math.max(fr.left + 4, Math.min(parseFloat(bar.style.left) || fr.left, fr.left + fr.width - w - 4));
bar.style.left = left + 'px';
};
/* ═══ top bar: Move mode and device modes ═════════════════════════════ */
(function fhBuildTopBar() {
const tt = $('#toolTabs');
if (!tt) return;
/* v0.6 put an ⓘ straight after #toolTabs — stay on its far side so the
ⓘ keeps explaining the two modes */
let anchor = tt;
if (anchor.nextElementSibling && anchor.nextElementSibling.classList.contains('ib')) anchor = anchor.nextElementSibling;
const mm = document.createElement('div');
mm.className = 'tabs'; mm.id = 'moveMode'; mm.style.display = 'none';
mm.innerHTML = '' +
'';
const lbl = document.createElement('span');
lbl.className = 'segLabel'; lbl.id = 'moveModeLabel'; lbl.textContent = 'Move'; lbl.style.display = 'none';
anchor.insertAdjacentElement('afterend', mm);
anchor.insertAdjacentElement('afterend', lbl);
mm.querySelectorAll('[data-move]').forEach((b) => b.onclick = () => fhSetFree(b.dataset.move === 'free', true));
const dm = document.createElement('div');
dm.className = 'tabs'; dm.id = 'devModes'; dm.style.display = 'none';
dm.innerHTML = FH_DEVS.map((d, i) =>
'').join('');
const ws = $('#widthSel');
if (ws) ws.insertAdjacentElement('afterend', dm); else $('#top').appendChild(dm);
dm.querySelectorAll('[data-dev]').forEach((b) => b.onclick = () => fhSetDev(b.dataset.dev, true));
})();
function fhSetFree(v, announce) {
fhFree = !!v;
try { localStorage.setItem('rtd-free', fhFree ? '1' : '0'); } catch (e) {}
fhSyncChrome();
clearOverlay(); scheduleSync();
if (announce) toast(fhFree ? 'Free move — drag anything exactly where you want it' :
'Layout moves — dropping between other elements', 2400);
}
function fhSetDev(v, announce) {
const d = fhDevInfo(v);
fhDev = d.v;
const ws = $('#widthSel');
if (ws) { ws.value = d.v; }
fhSyncChrome();
fitFrame();
/* the preview must be rebuilt when it is showing styles the source
keeps in the device block, or when it has no device block at all */
/* … and whenever the preview was loaded from an older source: apps like
WMS re-render their screens on resize from the markup they were loaded
with, which would silently drop every live (no-reload) edit */
if (fhDirtyLive || fhLoadedSrc !== S.src || (fhBlockRange(S.src) && frameDoc && !frameDoc.getElementById('rt-modes'))) fhFlushLive(true);
scheduleSync();
if (announce) toast(d.name + (d.mode ? ' — changes here only affect ' + FH_MEDIA[d.mode].label : ' — the normal layout'), 2600);
}
function fhSyncChrome() {
const open = !!S.fileName;
const mm = $('#moveMode'), dm = $('#devModes'), lb = $('#moveModeLabel'), ws = $('#widthSel');
if (ws) ws.style.display = 'none';
if (mm) {
mm.style.display = open ? '' : 'none';
mm.querySelectorAll('[data-move]').forEach((b) =>
b.classList.toggle('on', (b.dataset.move === 'free') === fhFree));
}
if (lb) lb.style.display = open ? '' : 'none';
if (dm) {
dm.style.display = open ? '' : 'none';
dm.querySelectorAll('[data-dev]').forEach((b) => b.classList.toggle('on', b.dataset.dev === fhDev));
}
if (open && S.tool === 'select') {
$('#modeText').textContent = fhFree
? 'Editing — drag anything exactly where you want it (F switches to Layout moves)'
: 'Editing — click anything, drag to move it';
}
const d = fhDevInfo(fhDev);
const base = canFS() ? 'saves in place' : 'Save hands you the edited file';
$('#stMode').textContent = !open ? base
: S.tool !== 'select' ? 'click-through — the app runs normally'
: d.name + (d.v === '0' ? '' : ' ' + d.v) + ' · ' + d.note + ' · ' + base;
}
const _fhSetTool = setTool;
setTool = function (t) { _fhSetTool(t); fhSyncChrome(); };
const _fhStartFromText = startFromText;
startFromText = function (t) {
_fhStartFromText(t);
fhDirtyLive = false;
/* open at the app's main size (Desktop 1440 unless the file says otherwise), scaled to fit */
fhDev = fhDetectBase(t);
const ws = $('#widthSel');
if (ws && ws.value !== fhDev) { ws.value = fhDev; fitFrame(); }
fhSyncChrome();
};
/* ═══ learnability ════════════════════════════════════════════════════ */
TOPHELP.moveMode = ['Free or Layout moves',
'Free puts the element exactly where you drop it and writes that offset into your file — ' +
'alignment lines appear as you get close to other elements. Layout is the older behaviour: ' +
'the element is dropped between other elements and keeps the app’s own spacing. ' +
'Press F to switch.'];
TOPHELP.devModes = ['Desktop, Laptop, Tablet, Phone',
'Shows the app at that width. On Tablet and Phone anything you change is saved as an ' +
'override for that size only, in one small block at the top of the file — the desktop layout is left alone. ' +
'Keys 1–4.'];
(function fhTitles() {
for (const id of ['moveMode', 'devModes']) {
const el = document.getElementById(id);
if (el && TOPHELP[id]) el.title = TOPHELP[id][0] + ' — ' + TOPHELP[id][1].replace(/<[^>]+>/g, '');
}
})();
KEYS.push(
['Free move / Layout moves', 'F'],
['Nudge 1 px / 10 px (Free move)', 'Arrows / Shift+Arrows'],
['Drag a copy instead', 'Alt+drag'],
['Drag along one direction only', 'Shift+drag'],
['Bring forward / send backward', 'Alt+] / Alt+['],
['Resize from the middle / keep the shape', 'Alt+drag / Shift+drag a handle'],
['Desktop / Laptop / Tablet / Phone', '1 / 2 / 3 / 4']
);
const _fhCommands = commands;
commands = function () {
const c = _fhCommands();
const n = selNode();
const add = (grp, name, hint, icon, key, fn, on = true) => c.push({ grp, name, hint, icon, key, fn, on });
add('Mode', 'Switch to Free move', 'Drag anything exactly where you want it', FH_I.free, 'F',
() => fhSetFree(true, true), !!S.fileName);
add('Mode', 'Switch to Layout moves', 'Drop between other elements', FH_I.layout, 'F',
() => fhSetFree(false, true), !!S.fileName);
add('Mode', 'Turn the magnetic guides ' + (fhSnapOn ? 'off' : 'on'), 'Alignment lines while dragging',
FH_I.free, '', () => {
fhSnapOn = !fhSnapOn;
try { localStorage.setItem('rtd-snap', fhSnapOn ? '1' : '0'); } catch (e) {}
toast(fhSnapOn ? 'Alignment lines on' : 'Alignment lines off');
}, !!S.fileName);
FH_DEVS.forEach((d, i) => add('Mode', d.name + ' view',
d.mode ? 'Changes here only affect ' + FH_MEDIA[d.mode].label : 'The normal layout',
d.icon, String(i + 1), () => fhSetDev(d.v, true), !!S.fileName));
if (n) {
add('This element', 'Reset position', 'Put it back where the layout wants it', FH_I.home, '',
fhResetPos, fhHasOffsets(n));
add('This element', 'Bring it forward', 'On top of what it overlaps', FH_I.fwd, 'Alt+]', () => fhZ(1));
add('This element', 'Send it backward', 'Behind what it overlaps', FH_I.back, 'Alt+[', () => fhZ(-1));
add('This element', 'Hide on this device', 'Only on ' + (fhModeOf(fhDev) ? FH_MEDIA[fhModeOf(fhDev)].label : 'tablet or phone'),
G.eyeOff, '', fhHideHere, !!fhModeOf(fhDev));
add('This element', 'Reset responsive', 'Drop every per-device change on it', FH_I.resp, '',
fhResetResponsive, fhHasAnyMode(S.src, getAttr(n, 'data-rt')));
}
/* keep each heading together — the list is grouped by runs, not by name */
const order = [];
c.forEach((x) => { if (order.indexOf(x.grp) < 0) order.push(x.grp); });
return order.reduce((out, g) => out.concat(c.filter((x) => x.grp === g)), []);
};
/* ── first run ───────────────────────────────────────────────────────── */
fhSyncChrome();
/* ══ layer blocks ══ */
/* ══ layer: blocks ═══════════════════════════════════════════════════
Nicepage's "blocks" model, adapted to warehouse/worker apps:
1. A Blocks tab — a library of ready-made sections, grouped, with a
wireframe thumbnail, searchable, click or drag to insert.
2. A "+" line between blocks in the preview to insert right there.
3. Hover controls on a block (a "Block · Name" tag, a Background button).
4. A context toolbar tailored to element type (text / button / input /
image), plus a universal "Wrap in a card" / "Unwrap".
Every base function used here is documented in CONTRACT.md. Nothing in
base.html is edited — everything below wraps or adds to it.
──────────────────────────────────────────────────────────────────── */
/* ── theme-token helpers ─────────────────────────────────────────────
Look up one of the app's own :root tokens by a list of likely names,
and fall back to a plain colour when the app has none. */
function bkTok(names, fallback) {
for (const nm of names) {
if (S.tokens.some((t) => t.name.toLowerCase() === nm.toLowerCase())) return 'var(' + nm + ', ' + fallback + ')';
}
return fallback;
}
const bkCardBg = () => bkTok(['--card', '--surface', '--panel', '--bg-elevated'], '#ffffff');
const bkBorder = () => bkTok(['--border', '--bd', '--bd2'], '#e3e5e8');
const bkTextColor = () => bkTok(['--text', '--fg'], '#16181d');
const bkMuted = () => bkTok(['--muted', '--fg2', '--fg3'], '#6b7280');
const bkPrimary = () => bkTok(['--primary', '--accent'], '#2f6feb');
const bkWarn = () => bkTok(['--warn', '--warning'], '#f5a524');
/* ── icons for the new toolbar controls (same convention as base's G) ── */
const BKI = {
paint: '',
tap: '',
variant: '',
wrap: '',
unwrap: '',
alignL: '', alignC: '', alignR: '',
image: '',
card: '',
input: '',
select: '',
check: '',
find: '',
};
/* ── the block library: 4 groups, ~4 blocks each ─────────────────────
Every make() returns indented markup rooted in one
/
carrying data-block="", using cls()/variantClass() so buttons and
inputs inherit the app's own classes, and the app's own theme tokens
for colour wherever one is defined. ──────────────────────────────── */
const BLOCK_GROUPS = [
{ name: 'Screens', items: [
{ key: 'screen-header', label: 'Screen header', desc: 'Back button, title and a menu button.',
wire: [[{ cls: 'circle' }, { w: 1, cls: 'bar2' }, { cls: 'circle' }]],
make: () => `
Screen title
` },
{ key: 'login-card', label: 'Login card', desc: 'Title, username and password, and a big Sign in button.',
wire: [[{ w: 1, cls: 'bar2' }], [{ w: 1, cls: 'box' }], [{ w: 1, cls: 'box' }], [{ w: 1, cls: 'btn' }]],
make: () => `
`; } },
{ key: 'canvas', label: 'Canvas', desc: 'An empty free-placement area — anything you place here keeps the exact spot you put it, instead of flowing with the rest of the page.',
wire: [[{ w: 1, cls: 'dashedbox' }]],
make: () => `` },
] },
];
const BLOCKS_FLAT = BLOCK_GROUPS.reduce((a, g) => a.concat(g.items), []);
/* ── the wireframe thumbnail: rows of grey bars/boxes, drawn with divs ── */
function bkWireHtml(rows) {
return '
Click a block to add it after whatever is selected, or drag it exactly where ' +
'you want. Blocks copy this app’s own button and input styles, and use its theme colours where it has them.
';
const fi = $('#bkFilter');
fi.oninput = () => { bkSearch = fi.value; const p = fi.selectionStart; bkRenderBlocks();
const nf = $('#bkFilter'); if (nf) { nf.focus(); nf.setSelectionRange(p, p); } };
body.querySelectorAll('[data-blk]').forEach((card) => {
const b = BLOCKS_FLAT[+card.dataset.blk];
card.onclick = () => bkInsertBlock(b.make(), b.label);
card.addEventListener('pointerdown', (e) => {
e.preventDefault();
if (S.tool !== 'select') setTool('select');
beginDrag({ kind: 'insert', markup: b.make(), label: b.label, pointerId: e.pointerId, captureEl: card,
startX: e.clientX, startY: e.clientY });
});
});
}
/* ── the left rail gets a fourth tab, first in the row ───────────────── */
(function bkSetupLeftTab() {
const tabs = document.querySelector('#leftHead .tabs');
if (!tabs) return;
const addBtn = tabs.querySelector('[data-left="add"]');
if (addBtn) addBtn.textContent = 'Elements';
const btn = document.createElement('button');
btn.className = 'tab'; btn.dataset.left = 'blocks'; btn.textContent = 'Blocks';
tabs.insertBefore(btn, tabs.firstChild);
btn.onclick = () => {
$$('#leftHead .tab').forEach((x) => x.classList.toggle('on', x === btn));
S.leftTab = 'blocks';
bkRenderBlocks();
};
})();
/* Loading a file (or reverting) can call renderPalette() while the Blocks
tab is showing; redraw ours afterwards so it is never left blank. */
const _bkStartFromText = startFromText;
startFromText = function (t) { _bkStartFromText(t); if (S.leftTab === 'blocks') bkRenderBlocks(); };
/* ── what counts as "a block" ─────────────────────────────────────────
A block is a top-level sibling of the screen: its own parent is a
fragment root (parent === -1, e.g. straight in
) or a tracked
. Everything nested inside belongs to the nearest such ancestor. */
function bkFindBlockAncestor(node) {
let n = node;
while (n) {
if (n.parent === -1) return n;
const p = S.nodes[n.parent];
if (p && /^(body|main)$/.test(p.tag)) return n;
n = p;
}
return node;
}
/* only containers count as blocks — a lone button at the top of a
template fragment (a list item rendered by .map) is not one */
const BK_BLOCK_TAGS = /^(div|section|header|footer|nav|main|aside|form|article|fieldset|ul|ol|table|dialog)$/;
function bkIsBlockEl(el) {
if (!el || !el.getAttribute) return false;
const idAttr = el.getAttribute('data-dsn');
if (idAttr == null) return false;
const node = S.nodes[+idAttr];
return !!node && BK_BLOCK_TAGS.test(node.tag) && bkFindBlockAncestor(node) === node;
}
function bkBlockName(node) {
const key = node.attrs['data-block'];
if (key) {
const b = BLOCKS_FLAT.find((x) => x.key === key);
return b ? b.label : key.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
return describe(node);
}
/* The screen root: the parent of the first visible tracked element in
— usually itself, or a router/view container. */
function bkFirstVisibleDsn(root) {
const all = root.querySelectorAll('[data-dsn]');
for (const el of all) if (el.offsetParent !== null || el === frameDoc.body) return el;
return all[0] || null;
}
function bkScreenRoot() {
if (!frameDoc || !frameDoc.body) return null;
const firstVisible = bkFirstVisibleDsn(frameDoc.body);
return firstVisible ? firstVisible.parentElement : frameDoc.body;
}
function bkTopLevelBlocks() {
const root = bkScreenRoot();
if (!root || !root.children) return [];
return [...root.children].filter((el) => el.getAttribute && el.getAttribute('data-dsn') != null);
}
/* Append at the end of the screen root. Used when nothing is selected.
The root is often the untracked , which has no node of its own —
in that case, land after the last top-level piece of real markup. */
function bkAppendToScreenRoot(markup, label) {
if (!frameDoc || !frameDoc.body) { toast('Open a file first'); return; }
const root = bkScreenRoot();
const parentDsn = root && root.getAttribute ? root.getAttribute('data-dsn') : null;
if (parentDsn != null) {
const pn = S.nodes[+parentDsn];
const l = 'Added ' + label + ' to the screen';
applyEdit(insertMarkup(S.src, markup, pn, 'lastChild'), l, 'add', markup);
toastUndo(l);
return;
}
const htmlRoots = S.roots.map((id) => S.nodes[id]).filter((x) => x && x.mode === 'html');
if (!htmlRoots.length) { toast('Could not find a place to add it'); return; }
const l = 'Added ' + label + ' to the screen';
applyEdit(insertMarkup(S.src, markup, htmlRoots[htmlRoots.length - 1], 'after'), l, 'add', markup);
toastUndo(l);
}
/* Click on a block card: land AFTER the block the selection sits in
(never inside it), still honouring the "this is a repeated list" case
the way smartInsert does. With nothing selected, append to the screen. */
function bkInsertBlock(markup, label) {
const n = selNode();
if (!n) { bkAppendToScreenRoot(markup, label); return; }
const block = bkFindBlockAncestor(n);
const blockEl = frameDoc ? frameDoc.querySelector('[data-dsn="' + block.id + '"]') : null;
const copies = blockEl ? repeatOf(blockEl) : 1;
if (copies > 1 && blockEl) {
let c = elWithDsn(blockEl.parentElement);
while (c && repeatOf(c) > 1) c = elWithDsn(c.parentElement);
const cn = c ? S.nodes[+c.getAttribute('data-dsn')] : null;
if (cn) {
const l = 'Added ' + label + ' once, below the list of ' + copies;
const anchor = cn.start;
applyEdit(insertMarkup(S.src, markup, cn, 'after'), l, 'add', markup);
toastActions(l, [['Undo', undo], ['Add to every item instead', () => {
undo();
setTimeout(() => {
const n2 = S.nodes.find((x) => x.start === anchor) || cn;
const l2 = 'Added ' + label + ' to every item in the list (' + copies + ')';
applyEdit(insertMarkup(S.src, markup, n2, 'after'), l2, 'add', markup);
toastUndo(l2);
}, 80);
}]]);
return;
}
}
const l = 'Added ' + label + ' after ' + describe(block);
applyEdit(insertMarkup(S.src, markup, block, 'after'), l, 'add', markup);
toastUndo(l);
}
/* ── the "+" line between blocks, drawn in #overlay on hover ─────────── */
let bkBoundary = null; // {y,left,w,prevNode,nextNode} in page coords, or null
let bkLastMoveKey = null, bkLastMoveY = null;
function bkComputeBoundaries() {
const blocks = bkTopLevelBlocks();
const boxed = blocks.map((el) => ({ node: S.nodes[+el.getAttribute('data-dsn')], box: boxFor(el) }))
.filter((x) => x.node);
if (!boxed.length) return [];
const out = [{ y: boxed[0].box.top, left: boxed[0].box.left, w: boxed[0].box.w, prevNode: null, nextNode: boxed[0].node }];
for (let i = 0; i < boxed.length - 1; i++) {
const a = boxed[i], b = boxed[i + 1];
out.push({ y: (a.box.top + a.box.h + b.box.top) / 2,
left: Math.min(a.box.left, b.box.left),
w: Math.max(a.box.left + a.box.w, b.box.left + b.box.w) - Math.min(a.box.left, b.box.left),
prevNode: a.node, nextNode: b.node });
}
const last = boxed[boxed.length - 1];
out.push({ y: last.box.top + last.box.h, left: last.box.left, w: last.box.w, prevNode: last.node, nextNode: null });
return out;
}
function bkHandleFrameMove(e) {
if (dragState || S.tool !== 'select' || !frameDoc) { if (bkBoundary) { bkBoundary = null; scheduleSync(); } return; }
const el = elWithDsn(e.target);
const key = el ? el.getAttribute('data-dsn') : 'none';
if (key === bkLastMoveKey && bkLastMoveY != null && Math.abs(e.clientY - bkLastMoveY) <= 4) return;
bkLastMoveKey = key; bkLastMoveY = e.clientY;
const fr = frameRect();
const pageY = e.clientY * S.zoom + fr.top;
let hit = null;
for (const b of bkComputeBoundaries()) { if (Math.abs(pageY - b.y) <= 10) { hit = b; break; } }
const changed = (hit && (!bkBoundary || bkBoundary.y !== hit.y)) || (!hit && bkBoundary);
bkBoundary = hit;
if (changed) scheduleSync();
}
const _bkOnFrameMove = onFrameMove;
onFrameMove = function (e) { _bkOnFrameMove(e); bkHandleFrameMove(e); };
let bkAddLineEl = null;
function bkClearAddLine() { bkBoundary = null; if (bkAddLineEl) { bkAddLineEl.remove(); bkAddLineEl = null; } scheduleSync(); }
function bkRedrawAddLine() {
if (bkAddLineEl) { bkAddLineEl.remove(); bkAddLineEl = null; }
if (!bkBoundary || dragState || S.tool !== 'select') return;
const hit = bkBoundary;
const el = document.createElement('div');
el.className = 'bkAddLine';
el.style.left = hit.left + 'px'; el.style.top = (hit.y - 1) + 'px'; el.style.width = Math.max(24, hit.w) + 'px';
el.innerHTML = '';
$('#overlay').appendChild(el);
const btn = el.querySelector('.bkAddBtn');
btn.onpointerdown = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); bkOpenBoundaryPicker(btn); };
bkAddLineEl = el;
}
let bkHoverLabelEl = null;
function bkDrawBlockHoverLabel() {
if (bkHoverLabelEl) { bkHoverLabelEl.remove(); bkHoverLabelEl = null; }
if (!frameDoc || S.tool !== 'select' || dragState) return;
if (!hoverEl || !hoverEl.isConnected || hoverEl === selEl || !bkIsBlockEl(hoverEl)) return;
const node = S.nodes[+hoverEl.getAttribute('data-dsn')]; if (!node) return;
const b = boxFor(hoverEl), fr = frameRect();
if (b.top < fr.top - 20 || b.top > fr.bottom) return;
const el = document.createElement('div');
el.className = 'bkBlockLabel';
el.textContent = 'Block · ' + bkBlockName(node);
el.style.left = (b.left + 6) + 'px'; el.style.top = (b.top + 6) + 'px';
$('#overlay').appendChild(el);
bkHoverLabelEl = el;
}
const _bkSyncOverlay = syncOverlay;
syncOverlay = function () { _bkSyncOverlay(); bkRedrawAddLine(); bkDrawBlockHoverLabel(); };
/* ── small light popovers (colour swatches / "when tapped" / "add here") ─
Independent of the base's dark .pop tooltip — these need to host real
controls (grids of cards, lists of buttons), so they get their own
light style and their own single-open-at-a-time bookkeeping. */
let bkPopEl = null;
function bkClosePop() { if (bkPopEl) { bkPopEl.remove(); bkPopEl = null; } }
document.addEventListener('pointerdown', (e) => { if (bkPopEl && !bkPopEl.contains(e.target)) bkClosePop(); }, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bkPopEl) bkClosePop(); }, true);
function bkPlacePop(p, anchorEl) {
document.body.appendChild(p);
const r = anchorEl.getBoundingClientRect();
let left = r.left + r.width / 2 - p.offsetWidth / 2;
left = Math.max(8, Math.min(left, innerWidth - p.offsetWidth - 8));
let top = r.bottom + 8;
if (top + p.offsetHeight > innerHeight - 10) top = Math.max(8, r.top - p.offsetHeight - 8);
p.style.left = left + 'px'; p.style.top = top + 'px';
bkPopEl = p;
}
function bkOpenBoundaryPicker(anchorBtn) {
const hit = bkBoundary;
bkClosePop();
if (!hit) return;
const p = document.createElement('div');
p.className = 'bkPop bkBoundaryPop';
p.innerHTML = '
';
bkPlacePop(p, anchorBtn);
p.querySelectorAll('[data-tokname]').forEach((b) => b.onclick = () => { setProp(prop, 'var(' + b.dataset.tokname + ')'); bkClosePop(); });
const ci = p.querySelector('#bkColorInput');
ci.oninput = () => { if (selEl) { try { selEl.style.setProperty(prop, ci.value); } catch (e) {} } };
ci.onchange = () => { setProp(prop, ci.value); bkClosePop(); };
}
/* "When tapped" — screens found in the file, Go back, the most common
existing handlers (deduped), and Custom via a modal prompt. */
function bkCollectOnclicks() {
const counts = new Map();
for (const n of S.nodes) {
const v = (n.attrs.onclick || '').trim();
if (!v) continue;
counts.set(v, (counts.get(v) || 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([v]) => v);
}
/* The shape most of the app's own buttons already use to go to a screen,
e.g. location.hash='#/x' or nav('#/x') — reuse that shape for the hash
the person picks, instead of guessing a convention of our own. */
function bkNavPattern() {
const tally = new Map();
for (const n of S.nodes) {
const v = n.attrs.onclick || '';
const m = /(['"`])(#\/[^'"`]*)\1/.exec(v);
if (!m) continue;
const tmpl = v.slice(0, m.index) + '\u0001' + v.slice(m.index + m[0].length);
tally.set(tmpl, (tally.get(tmpl) || 0) + 1);
}
if (!tally.size) return (h) => "location.hash='" + h + "'";
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0];
return (h) => best.replace('\u0001', "'" + h + "'");
}
function bkSetOnclick(v) {
const n = selNode(); if (!n) return;
if (selEl) { try { selEl.setAttribute('onclick', v); } catch (e) {} }
applyLive(setAttr(S.src, n, 'onclick', v), 'Set what ' + describe(n) + ' does when tapped', 'attr');
renderInspector();
}
function bkPromptCustomOnclick() {
const n = selNode(); if (!n) return;
openModal('Custom action', '
' +
'' +
'
',
[['Cancel', 'btn', closeModal], ['Set', 'btn pri', () => { const v = $('#bkCustomOnclick').value; closeModal(); bkSetOnclick(v); }]]);
setTimeout(() => { const i = $('#bkCustomOnclick'); if (i) { i.focus(); i.select(); } }, 50);
}
function bkOpenTapPopover(anchorBtn) {
bkClosePop();
const n = selNode(); if (!n) return;
const screens = findScreens(), navFmt = bkNavPattern(), handlers = bkCollectOnclicks();
const rows = [];
if (screens.length) rows.push('
';
bkPlacePop(p, anchorBtn);
p.querySelectorAll('[data-nav]').forEach((b) => b.onclick = () => { bkSetOnclick(navFmt(b.dataset.nav)); bkClosePop(); });
const backB = p.querySelector('[data-back]'); if (backB) backB.onclick = () => { bkSetOnclick('history.back()'); bkClosePop(); };
p.querySelectorAll('[data-h]').forEach((b) => b.onclick = () => { bkSetOnclick(handlers[+b.dataset.h]); bkClosePop(); });
const customB = p.querySelector('[data-custom]'); if (customB) customB.onclick = () => { bkClosePop(); bkPromptCustomOnclick(); };
}
/* Same popover, reachable from the command palette (no button to anchor on). */
function bkOpenTapPopoverForSelection() {
if (!selNode()) return;
const live = qtb && qtb.querySelector('[data-bk="tap"]');
if (live) { bkOpenTapPopover(live); return; }
if (!selEl) return;
const b = boxFor(selEl);
bkOpenTapPopover({ getBoundingClientRect: () => ({ left: b.left, right: b.left + b.w, top: b.top, bottom: b.top + b.h, width: b.w, height: b.h }) });
}
/* ── attribute helpers for bare boolean attributes (autofocus, …) ─────
setAttr()/getAttr() only understand name="value" pairs; a bare
attribute like `autofocus` needs its own add/remove. */
function bkAttrPresent(node, name) { return node.attrs[name] != null; }
function bkAddBareAttr(src, node, name) { return src.slice(0, node.nameEnd) + ' ' + name + src.slice(node.nameEnd); }
function bkRemoveBareAttr(src, node, name) {
const re = new RegExp('(^|\\s)' + name + '(\\s*=\\s*(?:"[^"]*"|\'[^\']*\'|[^\\s>]+))?', 'i');
const m = re.exec(node.attrText);
if (!m) return src;
const start = node.nameEnd + m.index + m[1].length, end = node.nameEnd + m.index + m[0].length;
return src.slice(0, start) + src.slice(end);
}
function bkToggleBoolAttr(name) {
const n = selNode(); if (!n) return;
const on = bkAttrPresent(n, name);
const out = on ? bkRemoveBareAttr(S.src, n, name) : bkAddBareAttr(S.src, n, name);
if (selEl) { try { on ? selEl.removeAttribute(name) : selEl.setAttribute(name, ''); } catch (e) {} }
applyLive(out, (on ? 'Turned off ' : 'Turned on ') + name + ' on ' + describe(n), 'attr');
renderInspector();
}
/* Two style properties in one edit, one applyLive, one history entry. */
function bkSetStyleProps(src, node, propsObj) {
const pairs = parseStyle(getAttr(node, 'style'));
for (const prop in propsObj) {
const value = propsObj[prop];
const i = pairs.findIndex(([k]) => k.toLowerCase() === prop.toLowerCase());
if (value === '' || value == null) { if (i >= 0) pairs.splice(i, 1); }
else if (i >= 0) pairs[i][1] = value;
else pairs.push([prop, value]);
}
return setAttr(src, node, 'style', styleString(pairs));
}
const BK_STYLE_OPTIONS = [['', 'Style…'], ['hl', 'Heading L'], ['hm', 'Heading M'], ['hs', 'Heading S'],
['body', 'Body'], ['small', 'Small'], ['label', 'Label']];
const BK_STYLE_MAP = { hl: ['28px', '700'], hm: ['22px', '600'], hs: ['17px', '600'],
body: ['15px', '400'], small: ['12px', '400'], label: ['12px', '500'] };
const BK_STYLE_LABEL = Object.fromEntries(BK_STYLE_OPTIONS);
function bkApplyStylePreset(key) {
const n = selNode(); if (!n || !BK_STYLE_MAP[key]) return;
const [fs, fw] = BK_STYLE_MAP[key];
const out = bkSetStyleProps(S.src, n, { 'font-size': fs, 'font-weight': fw });
if (selEl) { try { selEl.style.fontSize = fs; selEl.style.fontWeight = fw; } catch (e) {} }
applyLive(out, 'Made ' + describe(n) + ' ' + BK_STYLE_LABEL[key].toLowerCase(), 'style');
renderInspector();
}
function bkToggleBold() {
const n = selNode(); if (!n) return;
const cur = inlineVal('font-weight');
const bold = cur === '700' || cur === 'bold' || parseInt(cur, 10) >= 600;
setProp('font-weight', bold ? '' : '700');
}
function bkBumpFontSize(delta) {
const n = selNode(); if (!n) return;
const cur = inlineVal('font-size') || computed('fontSize') || '15px';
const next = Math.max(8, (parseFloat(cur) || 15) + delta) + 'px';
setProp('font-size', next);
}
function bkPromptPlaceholder() {
const n = selNode(); if (!n) return;
openModal('Placeholder text', '',
[['Cancel', 'btn', closeModal], ['Set', 'btn pri', () => { const v = $('#bkPh').value; closeModal(); setAttribute('placeholder', v); }]]);
setTimeout(() => { const i = $('#bkPh'); if (i) { i.focus(); i.select(); } }, 50);
}
function bkPromptImageSrc() {
const n = selNode(); if (!n) return;
openModal('Change image', '',
[['Cancel', 'btn', closeModal], ['Set', 'btn pri', () => { const v = $('#bkImgSrc').value; closeModal(); setAttribute('src', v); }]]);
setTimeout(() => { const i = $('#bkImgSrc'); if (i) { i.focus(); i.select(); } }, 50);
}
function bkPromptAlt() {
const n = selNode(); if (!n) return;
openModal('Alt text', '',
[['Cancel', 'btn', closeModal], ['Set', 'btn pri', () => { const v = $('#bkImgAlt').value; closeModal(); setAttribute('alt', v); }]]);
setTimeout(() => { const i = $('#bkImgAlt'); if (i) { i.focus(); i.select(); } }, 50);
}
function bkCycleVariant() {
const n = selNode(); if (!n) return;
const primaryCls = variantClass('primary', 'btn primary'), plainCls = cls('button', 'btn');
const cur = (n.attrs.class || '').split(/\s+/);
const isPrimary = cur.includes('primary') || (n.attrs.class || '') === primaryCls;
setAttribute('class', isPrimary ? plainCls : primaryCls);
}
/* ── "Wrap in a card" / "Unwrap" — works on any element ──────────────── */
function bkWrapInCard() {
const n = selNode(); if (!n) return;
const openTag = '
', closeTag = '
';
const out = S.src.slice(0, n.start) + openTag + S.src.slice(n.start, n.end) + closeTag + S.src.slice(n.end);
const keep = n.start + openTag.length;
const l = 'Wrapped ' + describe(n) + ' in a card';
applyEdit(out, l, 'add', keep);
toastUndo(l);
}
function bkCanUnwrap(n) { return !!n && n.tag === 'div' && n.children.length === 1; }
function bkUnwrap() {
const n = selNode(); if (!n || !bkCanUnwrap(n)) return;
const child = S.nodes[n.children[0]];
const childText = S.src.slice(child.start, child.end);
const out = S.src.slice(0, n.start) + childText + S.src.slice(n.end);
const l = 'Unwrapped ' + describe(n);
applyEdit(out, l, 'delete', childText);
toastUndo(l);
}
function bkOnKey(e) {
if (e.ctrlKey && e.altKey && (e.key === 'g' || e.key === 'G')) {
if (S.sel < 0) return;
e.preventDefault(); e.stopPropagation();
bkWrapInCard();
}
}
document.addEventListener('keydown', bkOnKey, true);
const _bkAttachFrame = attachFrame;
attachFrame = function (d) {
_bkAttachFrame(d);
d.addEventListener('keydown', bkOnKey, true);
/* NOTE: deliberately not clearing the + line on the iframe's own
pointerleave — the + button itself sits (pointer-events:auto) right
over the boundary, and the moment it is drawn under the cursor the
browser reports the iframe document as "left" (hit-testing now
resolves to our overlay in the parent document instead), which would
otherwise erase the button the instant it appears. Any real pointer
movement inside the frame keeps recomputing the boundary correctly
via bkHandleFrameMove; only leaving the frame for good can leave a
stale line showing, which a fresh hover immediately corrects. */
};
/* ── the context toolbar, by element type ─────────────────────────────
Wraps buildQuickBar(): call the previous, then append onto the same
bar (qtb, possibly null) with a .vr separator first. ─────────────── */
function bkAdd(icon, title, fn, key) {
const x = document.createElement('button');
x.innerHTML = ico(icon); x.title = title;
if (key) x.setAttribute('data-bk', key);
x.onpointerdown = (e) => { e.preventDefault(); e.stopPropagation(); };
x.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fn(); };
qtb.appendChild(x);
return x;
}
function bkAddText(label, title, fn, key) {
const x = document.createElement('button');
x.textContent = label; x.title = title; x.className = 'bktxt';
if (key) x.setAttribute('data-bk', key);
x.onpointerdown = (e) => { e.preventDefault(); e.stopPropagation(); };
x.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fn(); };
qtb.appendChild(x);
return x;
}
function bkAddSelect(options, current, title, onChange, key) {
const sel = document.createElement('select');
sel.title = title;
if (key) sel.setAttribute('data-bk', key);
sel.innerHTML = options.map(([v, label]) => '').join('');
sel.onpointerdown = (e) => e.stopPropagation();
sel.onclick = (e) => e.stopPropagation();
sel.onchange = (e) => { e.stopPropagation(); onChange(sel.value); };
qtb.appendChild(sel);
return sel;
}
function bkVr() { qtb.appendChild(Object.assign(document.createElement('span'), { className: 'vr' })); }
function bkBlockControls() {
bkVr();
const bgBtn = bkAdd(BKI.paint, 'Block background', () => bkOpenColorPopover(bgBtn, 'background', 'Block background'), 'bg');
}
function bkTextControls() {
bkVr();
bkAddSelect(BK_STYLE_OPTIONS, '', 'Text style', (v) => { if (v) bkApplyStylePreset(v); }, 'style');
bkAddText('B', 'Bold', bkToggleBold, 'bold');
bkAddText('A−', 'Smaller text', () => bkBumpFontSize(-2), 'smaller');
bkAddText('A+', 'Bigger text', () => bkBumpFontSize(2), 'bigger');
const colorBtn = bkAdd(BKI.paint, 'Text colour', () => bkOpenColorPopover(colorBtn, 'color', 'Text colour'), 'color');
bkAdd(BKI.alignL, 'Align left', () => setProp('text-align', 'left'), 'alignL');
bkAdd(BKI.alignC, 'Align centre', () => setProp('text-align', 'center'), 'alignC');
bkAdd(BKI.alignR, 'Align right', () => setProp('text-align', 'right'), 'alignR');
}
function bkButtonControls() {
bkVr();
const tapBtn = bkAdd(BKI.tap, 'When tapped', () => bkOpenTapPopover(tapBtn), 'tap');
bkAdd(BKI.variant, 'Switch style (primary / plain)', bkCycleVariant, 'variant');
const bgBtn = bkAdd(BKI.paint, 'Button background', () => bkOpenColorPopover(bgBtn, 'background', 'Button background'), 'bgbtn');
}
function bkInputControls(n) {
bkVr();
if (n.tag === 'input' || n.tag === 'textarea') bkAdd(BKI.input, 'Placeholder text', bkPromptPlaceholder, 'placeholder');
if (n.tag === 'input') bkAddSelect([['text', 'Text'], ['number', 'Number'], ['password', 'Password'], ['date', 'Date']],
n.attrs.type || 'text', 'Field type', (v) => setAttribute('type', v), 'type');
bkAddText('AF', 'Autofocus when the screen opens' + (bkAttrPresent(n, 'autofocus') ? ' (on)' : ' (off)'),
() => bkToggleBoolAttr('autofocus'), 'autofocus');
}
function bkImageControls() {
bkVr();
bkAdd(BKI.image, 'Change image', bkPromptImageSrc, 'image');
bkAdd(BKI.check, 'Alt text', bkPromptAlt, 'alt');
}
function bkWrapControls(n) {
bkVr();
bkAdd(BKI.wrap, 'Wrap in a card (Ctrl+Alt+G)', bkWrapInCard, 'wrap');
if (bkCanUnwrap(n)) bkAdd(BKI.unwrap, 'Unwrap', bkUnwrap, 'unwrap');
}
function bkClampQuickBar() {
if (!qtb) return;
const fr = frameRect();
const w = qtb.getBoundingClientRect().width;
const curLeft = parseFloat(qtb.style.left) || fr.left;
qtb.style.left = Math.max(fr.left + 4, Math.min(curLeft, fr.left + fr.width - w - 4)) + 'px';
}
const TEXT_TAG_RE = /^(h1|h2|h3|h4|h5|h6|p|span|label|small|strong)$/;
function bkExtendQuickBar() {
if (!qtb) return;
const n = selNode(); if (!n) return;
const isBlock = BK_BLOCK_TAGS.test(n.tag) && bkFindBlockAncestor(n) === n;
const isText = TEXT_TAG_RE.test(n.tag) ||
((n.tag === 'div' || n.tag === 'td' || n.tag === 'th') && n.textOnly && innerSource(S.src, n).trim());
const isButton = n.tag === 'button' || n.tag === 'a' || (n.tag === 'input' && /^(button|submit)$/.test(n.attrs.type || ''));
const isInput = /^(input|textarea|select)$/.test(n.tag) && !isButton;
const isImage = n.tag === 'img';
if (isBlock) bkBlockControls();
if (isText) bkTextControls();
if (isButton) bkButtonControls();
if (isInput) bkInputControls(n);
if (isImage) bkImageControls();
bkWrapControls(n);
bkClampQuickBar();
}
const _bkBuildQuickBar = buildQuickBar;
buildQuickBar = function () { _bkBuildQuickBar(); bkExtendQuickBar(); };
/* ── learnability: HELP, KEYS, commands() ─────────────────────────────── */
HELP['blocks'] = ['Blocks', 'Ready-made sections for warehouse and worker screens — a scan bar, a button ' +
'row, a table of stock. Click one to add it after whatever is selected, or drag it exactly where you want. ' +
'Hover the gap between two blocks in the preview for a quick + button.'];
HELP['elements'] = HELP['add'];
KEYS.push(['Wrap the selection in a card', 'Ctrl+Alt+G']);
const _bkCommands = commands;
commands = function () {
const c = _bkCommands();
const n = selNode();
c.push({ grp: 'Add', name: 'Add a block…', hint: 'Screen sections for worker apps', icon: BKI.card, key: '', fn: () => clickTab('left', 'blocks'), on: true });
if (n) {
c.push({ grp: 'This element', name: 'Wrap it in a card', hint: '', icon: BKI.wrap, key: 'Ctrl+Alt+G', fn: bkWrapInCard, on: true });
if (bkCanUnwrap(n)) c.push({ grp: 'This element', name: 'Unwrap it', hint: '', icon: BKI.unwrap, key: '', fn: bkUnwrap, on: true });
if (n.tag === 'button' || n.tag === 'a') c.push({ grp: 'This element', name: 'When tapped…', hint: 'Choose what it does', icon: BKI.tap, key: '', fn: bkOpenTapPopoverForSelection, on: true });
}
return c;
};
/* ══ layer grid ══ */
/* ═══ v0.7 · grid ═════════════════════════════════════════════════════
A Grid block (Nicepage's Grid, cut down for warehouse screens): a row
of equal boxes you drop things into. The boxes are ordinary flow
containers, so anything dropped in one lines up instead of floating —
this is the "place blocks in a grid" the freehand canvas cannot give.
…
Empty boxes are marked "Drop here" in the designer overlay only —
nothing about that hint is ever written into the file.
──────────────────────────────────────────────────────────────────── */
/* ── state ───────────────────────────────────────────────────────────── */
let gdPt = null; /* last pointer of any drag, PAGE px */
let gdPopEl = null;
const GD_GAPS = [0, 8, 12, 16, 24];
const GDI = {
addCol: '',
delCol: '',
addRow: '',
gap: '',
stack: '',
grid: '',
};
/* ── the markup ──────────────────────────────────────────────────────── */
function gdCellMarkup() { return ''; }
function gdMake(cols, rows, template) {
const n = cols * (rows || 1);
let out = '
';
for (let i = 0; i < n; i++) out += '\n ' + gdCellMarkup();
return out + '\n
';
}
/* the wireframe thumbnails the Blocks panel draws */
const gdWire = (cols, rows) => Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({ w: 1, cls: 'dashedbox', h: 22 })));
/* ── into the two libraries ──────────────────────────────────────────── */
const GD_BLOCKS = [
{ key: 'grid-2', label: 'Grid 2 columns', desc: 'Two equal boxes side by side.',
wire: gdWire(2, 1), make: () => gdMake(2, 1) },
{ key: 'grid-3', label: 'Grid 3 columns', desc: 'Three equal boxes side by side.',
wire: gdWire(3, 1), make: () => gdMake(3, 1) },
{ key: 'grid-4', label: 'Grid 4 columns', desc: 'Four equal boxes side by side.',
wire: gdWire(4, 1), make: () => gdMake(4, 1) },
{ key: 'grid-2x2', label: 'Grid 2 × 2', desc: 'Four boxes, two across and two down.',
wire: gdWire(2, 2), make: () => gdMake(2, 2) },
{ key: 'grid-sidebar', label: 'Sidebar + content', desc: 'A narrow box beside a wide one.',
wire: [[{ w: 1, cls: 'dashedbox', h: 22 }, { w: 2, cls: 'dashedbox', h: 22 }]],
make: () => gdMake(2, 1, '1fr 2fr') },
];
if (typeof BLOCK_GROUPS !== 'undefined') {
BLOCK_GROUPS.push({ name: 'Layout', items: GD_BLOCKS });
GD_BLOCKS.forEach((b) => BLOCKS_FLAT.push(b));
if (S.leftTab === 'blocks' && typeof bkRenderBlocks === 'function') bkRenderBlocks();
}
/* renderPalette reads PALETTE every time it draws, so pushing is safe */
if (typeof PALETTE !== 'undefined') {
PALETTE.push({ k: 'grid', label: 'Grid', icon: GDI.grid, make: () => gdMake(3, 1) });
if (S.leftTab === 'add') renderPalette();
}
/* ── small helpers ───────────────────────────────────────────────────── */
const gdZoom = () => S.zoom || 1;
function gdIsGridNode(n) { return !!n && /(^|\s)rt-grid(\s|$)/.test(n.attrs.class || ''); }
function gdIsCellNode(n) { return !!n && /(^|\s)rt-cell(\s|$)/.test(n.attrs.class || ''); }
/* The grid a node belongs to (the node itself, its cell's grid, …). */
function gdGridNodeFor(n) {
let c = n;
while (c) {
if (gdIsGridNode(c)) return c;
c = c.parent >= 0 ? S.nodes[c.parent] : null;
}
return null;
}
function gdCellNodes(grid) {
return (grid.children || []).map((id) => S.nodes[id]).filter(gdIsCellNode);
}
function gdColsOf(grid) {
const a = parseInt(getAttr(grid, 'data-cols'), 10);
if (a > 0) return a;
const t = (parseStyle(getAttr(grid, 'style')).find(([k]) => k.toLowerCase() === 'grid-template-columns') || [])[1] || '';
const rep = /repeat\(\s*(\d+)/.exec(t);
if (rep) return +rep[1];
return t.trim() ? t.trim().split(/\s+/).length : 1;
}
function gdTemplateOf(grid) {
return (parseStyle(getAttr(grid, 'style')).find(([k]) => k.toLowerCase() === 'grid-template-columns') || [])[1] || '';
}
function gdCellEmpty(el) {
return !el.querySelector('[data-dsn]') && !(el.textContent || '').trim();
}
/* The grid cell under a PAGE-px point, if any. */
function gdCellAt(pt) {
if (!pt || !frameDoc) return null;
const fr = frameRect(), k = gdZoom();
const x = (pt.x - fr.left) / k, y = (pt.y - fr.top) / k;
if (x < 0 || y < 0) return null;
const held = dragState && dragState.el;
/* in Free mode the element being dragged sits under the pointer, so
look past it (and past anything inside it) */
let list;
try { list = frameDoc.elementsFromPoint(x, y) || []; } catch (e) { list = []; }
for (const el of list) {
if (held && (el === held || held.contains(el))) continue;
const cell = el && el.closest ? el.closest('.rt-cell') : null;
if (!cell || cell.getAttribute('data-dsn') == null) continue;
if (held && held.contains(cell)) continue;
const node = S.nodes[+cell.getAttribute('data-dsn')];
if (node) return { el: cell, node };
}
/* elementsFromPoint only ever sees what is actually painted, and a
grid that was just added (or grew) can leave its own cells below
the preview's current fit — viewfit deliberately skips refitting
on every ordinary edit so nothing already placed jitters (see its
own vfSkipNextRefit), so "just inserted, not yet refit" is the
normal state right after adding a grid, not an edge case. A cell's
own geometry is unaffected by an ancestor clipping it from view,
so fall back to matching the point against every cell's real rect
directly — this is exactly what a later refit would bring back
into view anyway, just without waiting for one. */
let cells;
try { cells = frameDoc.querySelectorAll('.rt-cell[data-dsn]'); } catch (e) { cells = []; }
for (const cell of cells) {
if (held && (cell === held || held.contains(cell) || cell.contains(held))) continue;
const r = cell.getBoundingClientRect();
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
const node = S.nodes[+cell.getAttribute('data-dsn')];
if (node) return { el: cell, node };
}
return null;
}
/* ═══ new grids stack on phones from the start ════════════════════════
Written in the SAME edit that adds the grid, so it is one undo step.
Phone → one column; tablet → two, when the grid has three or more.
──────────────────────────────────────────────────────────────────── */
function gdEnsureStacking(src) {
if (typeof fhModeSet !== 'function') return src;
const targets = scanSource(src).nodes
.filter((n) => gdIsGridNode(n) && !getAttr(n, 'data-rt'))
.sort((a, b) => b.start - a.start); /* from the end backwards */
if (!targets.length) return src;
const keys = [];
for (const n of targets) {
const key = fhNewKey(src);
src = setAttr(src, n, 'data-rt', key);
keys.push([key, gdColsOf(n)]);
}
for (const [key, cols] of keys) {
src = fhModeSet(src, key, 'phone', fhDeclString([['grid-template-columns', '1fr']])).src;
if (cols >= 3) src = fhModeSet(src, key, 'tablet',
fhDeclString([['grid-template-columns', 'repeat(2,minmax(0,1fr))']])).src;
}
return src;
}
const _gdApplyEdit = applyEdit;
applyEdit = function (src, label, kind, keep) {
if (kind === 'add' && typeof src === 'string' && src.indexOf('rt-grid') >= 0) {
const out = gdEnsureStacking(src);
/* the caller asked to keep the selection on this exact markup, and we
just wrote a data-rt into it — point at the node instead, or the
new grid would come back unselected */
if (out !== src && typeof keep === 'string' && keep.indexOf('rt-grid') >= 0) {
const hit = scanSource(out).nodes.find((n) =>
out.slice(n.start, n.end).replace(/\s+data-rt="[^"]*"/, '') === keep);
if (hit) keep = hit.start;
}
src = out;
}
return _gdApplyEdit(src, label, kind, keep);
};
/* ═══ dropping into a cell ════════════════════════════════════════════
A cell is a plain flow container: whatever lands in it lines up, with
no free-move offset, in Free mode as much as in Layout mode.
──────────────────────────────────────────────────────────────────── */
/* remember where the pointer is for every drag, not just insert drags */
const _gdOnDragMove = onDragMove;
onDragMove = function (e) {
if (dragState && e && typeof e.clientX === 'number') gdPt = { x: e.clientX, y: e.clientY };
_gdOnDragMove(e);
};
/* A grid box is the one container a free drop DOES go inside — freehand
otherwise aims at the outermost box on the screen. */
if (typeof fhDropContainer === 'function') {
const _gdDropContainer = fhDropContainer;
fhDropContainer = function (cx, cy) {
const cell = gdCellAt({ x: cx, y: cy });
if (cell) return { el: cell.el, node: cell.node };
return _gdDropContainer(cx, cy);
};
}
/* a free insert that lands in a cell goes in as its last child, flat */
if (typeof fhInsertPlan === 'function') {
const _gdInsertPlan = fhInsertPlan;
fhInsertPlan = function (st) {
const job = _gdInsertPlan(st);
if (!job) return job;
const cont = S.nodes.find((x) => x.start === job.contStart);
const el = cont && frameDoc ? frameDoc.querySelector('[data-dsn="' + cont.id + '"]') : null;
const cell = el && el.closest ? el.closest('.rt-cell') : null;
if (cell && cell.getAttribute('data-dsn') != null) {
const cn = S.nodes[+cell.getAttribute('data-dsn')];
if (cn) { job.contStart = cn.start; job.gdFlow = true; job.phrase = ' into the grid'; }
}
return job;
};
const _gdPlaceDropped = fhPlaceDropped;
fhPlaceDropped = function (job) {
if (!job || !job.gdFlow) { _gdPlaceDropped(job); return; }
/* no offset — the cell decides where it sits */
const cont = S.nodes.find((x) => x.start === job.contStart);
const contEl = cont && frameDoc ? frameDoc.querySelector('[data-dsn="' + cont.id + '"]') : null;
if (contEl) {
for (let c = contEl.lastElementChild; c; c = c.previousElementSibling) {
if (c.getAttribute && c.getAttribute('data-dsn') != null) { select(+c.getAttribute('data-dsn'), c); break; }
}
}
scheduleSync();
toastUndo(job.label);
};
}
/* a whole block dropped on a cell goes INTO the cell, not after it */
const _gdOnDragUp = onDragUp;
onDragUp = function () {
const st = dragState;
if (st && st.kind === 'insert' && st.moved && st.fhBlock && fhFree) {
const cell = gdCellAt(gdPt || st.fhPt);
if (cell) {
const cellStart = cell.node.start, markup = st.markup, label = st.label;
dropInfo = null; dropMark = null; st.moved = false;
_gdOnDragUp();
swallowClick = true; setTimeout(() => { swallowClick = false; }, 400);
if (typeof fhDropAt !== 'undefined') fhDropAt = Date.now(); /* swallow the card's click */
gdInsertIntoCell(cellStart, markup, label);
return;
}
}
_gdOnDragUp();
};
function gdInsertIntoCell(cellStart, markup, label) {
const cell = S.nodes.find((x) => x.start === cellStart);
if (!cell) { toast('That box is gone — try again'); return; }
const out = insertMarkup(S.src, markup, cell, 'lastChild');
const l = 'Added ' + label + ' into the grid';
applyEdit(out, l, 'add', markup);
toastUndo(l);
}
/* ── moving something that already exists onto a cell ────────────────── */
if (typeof fhCommitMove === 'function') {
const _gdCommitMove = fhCommitMove;
fhCommitMove = function (f) {
const cell = gdCellAt(gdPt);
if (cell && f && f.el) {
if (f.el.parentElement === cell.el) {
fhRestore(f); scheduleSync();
toast('Grid boxes keep things lined up — drop it in another box to move it', 3000);
return;
}
if (!f.el.contains(cell.el)) {
const node = S.nodes.find((x) => x.start === f.nodeStart);
if (node) { gdMoveIntoCell(node, cell.node, f); return; }
}
}
_gdCommitMove(f);
};
}
function gdMoveIntoCell(node, cellNode, f) {
fhRestore(f); /* the live free offset goes away */
/* whatever free offsets it carried belong to the old place */
let src = fhStyleEdit(S.src, node,
[['position', ''], ['left', ''], ['top', ''], ['right', ''], ['bottom', '']]);
const delta = src.length - S.src.length;
const scan = scanSource(src);
const n2 = scan.nodes.find((x) => x.start === node.start);
const cellStart = cellNode.start + (cellNode.start > node.start ? delta : 0);
const c2 = scan.nodes.find((x) => x.start === cellStart);
if (!n2 || !c2) { scheduleSync(); toast('That could not be moved into the grid'); return; }
const text = src.slice(n2.start, n2.end);
const out = moveNode(src, n2, c2, 'lastChild');
if (!out) { scheduleSync(); toast('That cannot go inside itself'); return; }
const label = 'Moved ' + describe(node) + ' into the grid';
applyEdit(out, label, 'move', text);
toastUndo(label);
}
/* ═══ the grid's own controls ═════════════════════════════════════════ */
function gdRepeat(cols) { return 'repeat(' + cols + ',minmax(0,1fr))'; }
/* One edit that changes the grid's style/attributes AND its cells:
cells sit after the grid's own tag, so the cell edit is applied first
and the grid's offsets stay valid. */
function gdWriteGrid(grid, opts) {
let src = S.src;
if (opts.insertCells) src = insertMarkup(src, opts.insertCells, grid, 'lastChild');
if (opts.deleteNode) src = deleteNode(src, opts.deleteNode);
if (opts.style) for (const [p, v] of opts.style) src = fhStyleEdit(src, grid, [[p, v]]);
if (opts.cols != null) {
const g2 = scanSource(src).nodes.find((x) => x.start === grid.start) || grid;
src = setAttr(src, g2, 'data-cols', String(opts.cols));
}
applyEdit(src, opts.label, 'style', grid.start);
toastUndo(opts.label);
}
function gdAddColumn(grid) {
const cols = gdColsOf(grid), t = gdTemplateOf(grid);
const next = /^\s*repeat\(/.test(t) || !t.trim() ? gdRepeat(cols + 1) : (t.trim() + ' 1fr');
gdWriteGrid(grid, { insertCells: gdCellMarkup(), cols: cols + 1,
style: [['grid-template-columns', next]],
label: 'Grid now has ' + (cols + 1) + ' columns' });
}
function gdRemoveColumn(grid) {
const cols = gdColsOf(grid);
if (cols <= 1) { toast('A grid needs at least one column'); return; }
const cells = gdCellNodes(grid);
const last = cells[cells.length - 1];
if (!last) { toast('This grid has no boxes left to remove'); return; }
const lastEl = frameDoc ? frameDoc.querySelector('[data-dsn="' + last.id + '"]') : null;
if (lastEl && !gdCellEmpty(lastEl)) {
toast('The last box still has something in it — move that out first, then remove the column', 3600);
return;
}
const t = gdTemplateOf(grid);
const next = /^\s*repeat\(/.test(t) || !t.trim() ? gdRepeat(cols - 1)
: t.trim().split(/\s+/).slice(0, -1).join(' ');
gdWriteGrid(grid, { deleteNode: last, cols: cols - 1,
style: [['grid-template-columns', next]],
label: 'Grid now has ' + (cols - 1) + ' columns' });
}
function gdAddRow(grid) {
const cols = gdColsOf(grid);
let cells = '';
for (let i = 0; i < cols; i++) cells += (i ? '\n ' : '') + gdCellMarkup();
gdWriteGrid(grid, { insertCells: cells, label: 'Added a row of ' + cols + ' boxes' });
}
/* ── gap ─────────────────────────────────────────────────────────────── */
function gdClosePop() {
if (gdPopEl) { gdPopEl.remove(); gdPopEl = null; }
if (typeof bkClosePop === 'function') bkClosePop();
}
document.addEventListener('pointerdown', (e) => {
if (gdPopEl && !gdPopEl.contains(e.target)) gdClosePop();
}, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && gdPopEl) gdClosePop(); }, true);
function gdOpenGapPop(anchor, grid) {
gdClosePop();
const cur = (parseStyle(getAttr(grid, 'style')).find(([k]) => k.toLowerCase() === 'gap') || [])[1] || '';
const p = document.createElement('div');
p.className = 'bkPop';
p.innerHTML = '
Space between the boxes
' +
GD_GAPS.map((g) => '').join('') + '
';
if (typeof bkPlacePop === 'function') { bkPlacePop(p, anchor); gdPopEl = p; }
else {
document.body.appendChild(p);
const r = anchor.getBoundingClientRect();
p.style.position = 'fixed'; p.style.left = r.left + 'px'; p.style.top = (r.bottom + 8) + 'px';
gdPopEl = p;
}
p.querySelectorAll('[data-gap]').forEach((b) => b.onclick = () => {
const v = b.dataset.gap + 'px';
gdClosePop();
const g = S.nodes.find((x) => x.start === grid.start) || grid;
gdWriteGrid(g, { style: [['gap', v]], label: 'Space between the boxes is now ' + v });
});
}
/* ── stack on phones ─────────────────────────────────────────────────── */
function gdStacked(grid) {
if (typeof fhModeGet !== 'function') return false;
const key = getAttr(grid, 'data-rt');
if (!key) return false;
const hit = fhDeclParse(fhModeGet(S.src, key, 'phone')).find(([k]) => k === 'grid-template-columns');
return !!hit && /^1fr$/i.test(hit[1].trim());
}
function gdToggleStack(grid) {
if (typeof fhModeSet !== 'function') { toast('Device sizes are not available here'); return; }
const on = !gdStacked(grid);
const cols = gdColsOf(grid);
let src = S.src, start = grid.start;
let key = getAttr(grid, 'data-rt');
if (!key) { key = fhNewKey(src); src = setAttr(src, grid, 'data-rt', key); }
let r = fhModeSet(src, key, 'phone', on ? fhDeclString([['grid-template-columns', '1fr']]) : '');
src = r.src; if (r.at <= start) start += r.delta;
if (cols >= 3) {
r = fhModeSet(src, key, 'tablet',
on ? fhDeclString([['grid-template-columns', gdRepeat(2)]]) : '');
src = r.src; if (r.at <= start) start += r.delta;
}
const label = on
? 'The grid now stacks on phones' + (cols >= 3 ? ' and shows two columns on tablets' : '')
: 'The grid keeps its columns on every size';
applyEdit(src, label, 'style', start);
toastUndo(label);
}
/* ── the buttons on the quick toolbar ────────────────────────────────── */
function gdText(label, title, fn, on) {
let b;
if (typeof bkAddText === 'function') b = bkAddText(label, title, fn, 'gd');
else {
b = document.createElement('button');
b.textContent = label; b.title = title; b.className = 'bktxt';
b.onpointerdown = (e) => { e.preventDefault(); e.stopPropagation(); };
b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fn(); };
qtb.appendChild(b);
}
if (on) b.classList.add('fhon');
return b;
}
function gdVr() {
if (typeof bkVr === 'function') bkVr();
else qtb.appendChild(Object.assign(document.createElement('span'), { className: 'vr' }));
}
const _gdBuildQuickBar = buildQuickBar;
buildQuickBar = function () {
_gdBuildQuickBar();
if (!qtb) return;
const n = selNode(); if (!n) return;
const grid = gdGridNodeFor(n);
if (!grid) return;
gdVr();
gdText('+ Col', 'Add a column — one more box across', () => gdAddColumn(grid));
gdText('− Col', 'Remove the last column', () => gdRemoveColumn(grid));
gdText('+ Row', 'Add a row of boxes underneath', () => gdAddRow(grid));
const gapBtn = gdText('Gap', 'Space between the boxes', () => gdOpenGapPop(gapBtn, grid));
const stacked = gdStacked(grid);
gdText('Phone', stacked
? 'Stacked on phones — press to keep the columns instead'
: 'Stack on phones — put the boxes one under the other on a small screen',
() => gdToggleStack(grid), stacked);
if (typeof bkClampQuickBar === 'function') bkClampQuickBar();
};
/* ── the boxes, outlined in the overlay ──────────────────────────────── */
function gdActiveGrid() {
if (!frameDoc) return null;
if (dragState && gdPt) {
const c = gdCellAt(gdPt);
if (c) return c.el.parentElement;
}
const cands = [hoverEl, selEl];
for (const el of cands) {
if (el && el.isConnected && el.closest) {
const g = el.closest('.rt-grid');
if (g) return g;
}
}
return null;
}
const _gdSyncOverlay = syncOverlay;
syncOverlay = function () {
_gdSyncOverlay();
if (!frameDoc || S.tool !== 'select') return;
const grid = gdActiveGrid();
if (!grid) return;
const ov = $('#overlay'), fr = frameRect();
grid.querySelectorAll(':scope > .rt-cell').forEach((cell) => {
const b = boxFor(cell);
if (b.top > fr.bottom || b.top + b.h < fr.top - 4) return;
const d = document.createElement('div');
const empty = gdCellEmpty(cell);
d.className = 'gdcell' + (empty ? ' empty' : '');
d.style.left = b.left + 'px'; d.style.top = b.top + 'px';
d.style.width = b.w + 'px'; d.style.height = b.h + 'px';
if (empty) d.innerHTML = 'Drop here';
ov.appendChild(d);
});
};
/* a grid added by clicking lands beside the block you are in, like the
other blocks do, rather than straight after the selected element */
const _gdSmartInsert = smartInsert;
smartInsert = function (markup, label) {
if (/data-block="grid"/.test(markup || '') && typeof bkInsertBlock === 'function' && selNode()) {
bkInsertBlock(markup, label);
return;
}
_gdSmartInsert(markup, label);
};
/* ── learnability ────────────────────────────────────────────────────── */
if (typeof HELP !== 'undefined') {
HELP.layout = ['Layout', 'Grids: a row of equal boxes you drop things into. Whatever you put in a box ' +
'lines up with everything else instead of floating free — the right tool when you want two or three ' +
'things side by side that stay side by side. New grids stack into one column on a phone.'];
}
KEYS.push(['Boxes in a grid keep things lined up', 'drop into a box']);
const _gdCommands = commands;
commands = function () {
const c = _gdCommands();
const n = selNode();
const grid = n ? gdGridNodeFor(n) : null;
const add = (grp, name, hint, icon, key, fn, on = true) => c.push({ grp, name, hint, icon, key, fn, on });
GD_BLOCKS.forEach((b) => add('Add', 'Add a ' + b.label.toLowerCase(), b.desc, GDI.grid, '',
() => smartInsert(b.make(), b.label), !!S.fileName));
if (grid) {
add('This element', 'Add a column to the grid', '', GDI.addCol, '', () => gdAddColumn(grid));
add('This element', 'Remove a column from the grid', '', GDI.delCol, '', () => gdRemoveColumn(grid));
add('This element', 'Add a row to the grid', '', GDI.addRow, '', () => gdAddRow(grid));
add('This element', gdStacked(grid) ? 'Keep the grid columns on phones' : 'Stack the grid on phones',
'One under the other on a small screen', GDI.stack, '', () => gdToggleStack(grid));
}
const order = [];
c.forEach((x) => { if (order.indexOf(x.grp) < 0) order.push(x.grp); });
return order.reduce((out, g) => out.concat(c.filter((x) => x.grp === g)), []);
};
/* ══ layer widgets ══ */
/* ══ layer: widgets ═══════════════════════════════════════════════════
A generated library of charts, pies/gauges and WMS dashboard tiles —
inline SVG or plain HTML, demo data only, no external libraries.
1. WG_WIDGETS — one entry per widget: {key,label,family,size,make()}.
Built by a handful of parametrised generator functions (line/bar/
pie/ring) driven by small config tables, not hand-copied per item.
2. Right panel: selecting something chart-ish (svg/canvas/img, a
data-widget element, a chart-ish class/id, or a subtree holding an