Desktop, tablet and mobile style overrides

* Responsive inheritance:
    * tablet inherits desktop
    * mobile inherits desktop and tablet
* Width and minimum-height controls
* Padding controls for all four sides
* Margin controls for all four sides
* Text alignment
* Flex display controls
* Item alignment and justification
* Background colour
* Text colour
* Border colour
* Border width
* Corner radius
* Responsive override indicators on canvas components
* Clear styles for the active breakpoint
* Live canvas updates while editing
This commit is contained in:
Mark Gregory
2026-07-27 09:44:48 +01:00
parent c972808d0a
commit 5ef726b671
@@ -105,6 +105,10 @@
function normaliseNode(node) {
node.id ||= uid();
node.type ||= 'field';
node.styles ||= { desktop: {}, tablet: {}, mobile: {} };
node.styles.desktop ||= {};
node.styles.tablet ||= {};
node.styles.mobile ||= {};
if (node.type === 'columns') {
node.columns ||= [{ children: [] }, { children: [] }];
node.columns.forEach(column => {
@@ -127,6 +131,51 @@
layout.forEach(normaliseNode);
let selectedId = layout[0]?.id || null;
let dragId = null;
let activeBreakpoint = 'desktop';
const styleDefaults = {
width: '', minHeight: '', textAlign: '', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', backgroundColor: '', color: '',
borderColor: '', borderWidth: '', borderRadius: '', display: '', alignItems: '', justifyContent: ''
};
function resolvedStyles(node) {
const desktop = node.styles?.desktop || {};
const tablet = node.styles?.tablet || {};
const mobile = node.styles?.mobile || {};
if (activeBreakpoint === 'desktop') return { ...styleDefaults, ...desktop };
if (activeBreakpoint === 'tablet') return { ...styleDefaults, ...desktop, ...tablet };
return { ...styleDefaults, ...desktop, ...tablet, ...mobile };
}
function cssValue(value, unit = 'px') {
if (value === '' || value === null || value === undefined) return '';
if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return `${value}${unit}`;
return String(value);
}
function nodeStyle(node) {
const s = resolvedStyles(node);
const declarations = {
width: s.width, minHeight: cssValue(s.minHeight), textAlign: s.textAlign,
paddingTop: cssValue(s.paddingTop), paddingRight: cssValue(s.paddingRight),
paddingBottom: cssValue(s.paddingBottom), paddingLeft: cssValue(s.paddingLeft),
marginTop: cssValue(s.marginTop), marginRight: cssValue(s.marginRight),
marginBottom: cssValue(s.marginBottom), marginLeft: cssValue(s.marginLeft),
backgroundColor: s.backgroundColor, color: s.color, borderColor: s.borderColor,
borderWidth: cssValue(s.borderWidth), borderRadius: cssValue(s.borderRadius),
borderStyle: s.borderWidth ? 'solid' : '', display: s.display, alignItems: s.alignItems,
justifyContent: s.justifyContent
};
return Object.entries(declarations).filter(([, value]) => value !== '').map(([key, value]) => `${key.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${value}`).join(';');
}
function responsiveBadge(node) {
const hasTablet = Object.values(node.styles?.tablet || {}).some(value => value !== '');
const hasMobile = Object.values(node.styles?.mobile || {}).some(value => value !== '');
return `${hasTablet ? '<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300">T</span>' : ''}${hasMobile ? '<span class="rounded bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-700 dark:bg-violet-950 dark:text-violet-300">M</span>' : ''}`;
}
const library = document.getElementById('builder-library');
const layoutLibrary = document.getElementById('layout-library');
@@ -217,12 +266,12 @@
if (node.type === 'field') {
const definition = schema[node.key] || {};
const value = data[node.key];
return `<article draggable="true" data-node-id="${node.id}" class="${base} bg-white p-4 dark:bg-zinc-900"><div class="mb-2 flex items-start justify-between gap-2"><button type="button" data-select-node="${node.id}" class="text-left"><span class="block text-xs font-semibold uppercase tracking-wide text-zinc-500">${escapeHtml(titleFor(node.key, definition))}</span><span class="text-[11px] text-zinc-400">Field</span></button>${nodeToolbar(node)}</div><p class="whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200">${escapeHtml(value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content')}</p></article>`;
return `<article draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} bg-white p-4 dark:bg-zinc-900"><div class="mb-2 flex items-start justify-between gap-2"><button type="button" data-select-node="${node.id}" class="text-left"><span class="block text-xs font-semibold uppercase tracking-wide text-zinc-500">${escapeHtml(titleFor(node.key, definition))}</span><span class="text-[11px] text-zinc-400">Field</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div><p class="whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200">${escapeHtml(value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content')}</p></article>`;
}
if (node.type === 'columns') {
return `<section draggable="true" data-node-id="${node.id}" class="${base} bg-zinc-50 p-3 dark:bg-zinc-950/50"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || 'Columns')}</strong><span class="ml-2 text-[11px] text-zinc-500">${node.columns.length} columns</span></button>${nodeToolbar(node)}</div><div class="grid gap-3" style="grid-template-columns:repeat(${node.columns.length},minmax(0,1fr))">${node.columns.map((column, index) => `<div class="min-h-28 rounded-lg border border-dashed border-zinc-300 bg-white p-2 dark:border-zinc-700 dark:bg-zinc-900">${renderDropZone(node.id, `column:${index}`, `Column ${index + 1}`)}<div class="space-y-2">${column.children.map(renderNode).join('')}</div></div>`).join('')}</div></section>`;
return `<section draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} bg-zinc-50 p-3 dark:bg-zinc-950/50"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || 'Columns')}</strong><span class="ml-2 text-[11px] text-zinc-500">${node.columns.length} columns</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div><div class="grid gap-3" style="grid-template-columns:repeat(${node.columns.length},minmax(0,1fr))">${node.columns.map((column, index) => `<div class="min-h-28 rounded-lg border border-dashed border-zinc-300 bg-white p-2 dark:border-zinc-700 dark:bg-zinc-900">${renderDropZone(node.id, `column:${index}`, `Column ${index + 1}`)}<div class="space-y-2">${column.children.map(renderNode).join('')}</div></div>`).join('')}</div></section>`;
}
return `<section draggable="true" data-node-id="${node.id}" class="${base} ${node.type === 'section' ? 'bg-zinc-50 p-4 dark:bg-zinc-950/50' : 'bg-white p-3 dark:bg-zinc-900'}"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || titleFor(node.type))}</strong><span class="ml-2 text-[11px] uppercase text-zinc-500">${node.type}</span></button>${nodeToolbar(node)}</div>${renderDropZone(node.id, 'children')}<div class="space-y-3">${(node.children || []).map(renderNode).join('')}</div></section>`;
return `<section draggable="true" data-node-id="${node.id}" style="${nodeStyle(node)}" class="${base} ${node.type === 'section' ? 'bg-zinc-50 p-4 dark:bg-zinc-950/50' : 'bg-white p-3 dark:bg-zinc-900'}"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || titleFor(node.type))}</strong><span class="ml-2 text-[11px] uppercase text-zinc-500">${node.type}</span>${responsiveBadge(node)}</button>${nodeToolbar(node)}</div>${renderDropZone(node.id, 'children')}<div class="space-y-3">${(node.children || []).map(renderNode).join('')}</div></section>`;
}
function bindCanvas() {
@@ -295,8 +344,24 @@
const node = found.node;
if (node.type === 'field') return renderFieldInspector(node);
document.getElementById('inspector-help').textContent = node.label || titleFor(node.type);
inspector.innerHTML = `<div class="space-y-4"><label class="block text-sm font-medium">Label<input id="node-label" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(node.label || '')}"></label>${node.type === 'columns' ? `<label class="block text-sm font-medium">Columns<select id="column-count" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950"><option value="2" ${node.columns.length === 2 ? 'selected' : ''}>Two</option><option value="3" ${node.columns.length === 3 ? 'selected' : ''}>Three</option></select></label>` : ''}<p class="rounded-lg bg-zinc-50 p-3 text-xs text-zinc-500 dark:bg-zinc-800">Drag components using their cards. Drop them into sections, containers, columns, or the page root.</p></div>`;
const current = node.styles[activeBreakpoint] || (node.styles[activeBreakpoint] = {});
const input = (key, label, type = 'text', placeholder = '') => `<label class="block text-xs font-medium text-zinc-600 dark:text-zinc-300">${label}<input data-style-key="${key}" type="${type}" placeholder="${placeholder}" value="${escapeHtml(current[key] ?? '')}" class="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"></label>`;
const select = (key, label, options) => `<label class="block text-xs font-medium text-zinc-600 dark:text-zinc-300">${label}<select data-style-key="${key}" class="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950">${options.map(([value, text]) => `<option value="${value}" ${String(current[key] ?? '') === value ? 'selected' : ''}>${text}</option>`).join('')}</select></label>`;
inspector.innerHTML = `<div class="space-y-5">
<div class="rounded-lg bg-zinc-100 p-2 text-xs dark:bg-zinc-800"><strong>${activeBreakpoint[0].toUpperCase() + activeBreakpoint.slice(1)} styles</strong><span class="block text-zinc-500">Tablet and mobile inherit larger breakpoints until overridden.</span></div>
<label class="block text-sm font-medium">Label<input id="node-label" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(node.label || '')}"></label>
${node.type === 'columns' ? `<label class="block text-sm font-medium">Columns<select id="column-count" class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950"><option value="2" ${node.columns.length === 2 ? 'selected' : ''}>Two</option><option value="3" ${node.columns.length === 3 ? 'selected' : ''}>Three</option></select></label>` : ''}
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Layout</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('width','Width','text','100%')}${input('minHeight','Min height','number')}${select('display','Display',[['','Default'],['block','Block'],['flex','Flex']])}${select('textAlign','Text align',[['','Default'],['left','Left'],['center','Centre'],['right','Right']])}${select('alignItems','Align items',[['','Default'],['flex-start','Start'],['center','Centre'],['flex-end','End'],['stretch','Stretch']])}${select('justifyContent','Justify',[['','Default'],['flex-start','Start'],['center','Centre'],['space-between','Space between'],['flex-end','End']])}</div></details>
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Spacing</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('paddingTop','Padding top','number')}${input('paddingRight','Padding right','number')}${input('paddingBottom','Padding bottom','number')}${input('paddingLeft','Padding left','number')}${input('marginTop','Margin top','number')}${input('marginRight','Margin right','number')}${input('marginBottom','Margin bottom','number')}${input('marginLeft','Margin left','number')}</div></details>
<details open class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><summary class="cursor-pointer text-sm font-semibold">Appearance</summary><div class="mt-3 grid grid-cols-2 gap-2">${input('backgroundColor','Background','color')}${input('color','Text colour','color')}${input('borderColor','Border colour','color')}${input('borderWidth','Border width','number')}${input('borderRadius','Corner radius','number')}</div></details>
<button type="button" id="clear-breakpoint-styles" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-xs dark:border-zinc-700">Clear ${activeBreakpoint} overrides</button>
</div>`;
document.getElementById('node-label')?.addEventListener('input', event => { node.label = event.target.value; sync(); renderCanvas(); });
inspector.querySelectorAll('[data-style-key]').forEach(control => control.addEventListener('input', event => {
node.styles[activeBreakpoint][control.dataset.styleKey] = event.target.value;
sync(); renderCanvas();
}));
document.getElementById('clear-breakpoint-styles')?.addEventListener('click', () => { node.styles[activeBreakpoint] = {}; update(); });
document.getElementById('column-count')?.addEventListener('change', event => {
const count = Number(event.target.value);
while (node.columns.length < count) node.columns.push({ id: uid(), children: [] });
@@ -327,9 +392,11 @@
}
});
document.querySelectorAll('.builder-width').forEach(button => button.addEventListener('click', () => {
activeBreakpoint = button.textContent.trim().toLowerCase();
document.getElementById('builder-viewport').style.maxWidth = button.dataset.width;
document.querySelectorAll('.builder-width').forEach(item => item.classList.remove('font-medium','bg-zinc-100','dark:bg-zinc-800'));
button.classList.add('font-medium','bg-zinc-100','dark:bg-zinc-800');
renderCanvas(); renderInspector();
}));
document.getElementById('content-document-form').addEventListener('submit', sync);
document.getElementById('content-type-select')?.addEventListener('change', event => {