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:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user