Inline text editing directly on the canvas
* Rich-text editing for textarea fields
* Bold, italic, underline, lists, links and clear formatting
* Image URL input
* Local image selection with preview
* Undo and redo history
* Copy and paste selected components
* Delete selected component from the keyboard
* Keyboard shortcuts:
* ⌘Z / Ctrl+Z — undo
* ⇧⌘Z / Ctrl+Shift+Z — redo
* ⌘C / Ctrl+C — copy component
* ⌘V / Ctrl+V — paste component
* Delete / Backspace — delete selected component
* History limited to 80 editor states
* Existing responsive styles and nested layouts retained
This commit is contained in:
@@ -58,7 +58,15 @@
|
||||
<main class="min-w-0 bg-zinc-50 p-4 dark:bg-zinc-950/60 sm:p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h2 class="font-semibold">Canvas</h2><p class="text-xs text-zinc-500">{{ $selectedType?->name ?? 'Content document' }} · live structured preview</p></div>
|
||||
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900"><button type="button" class="builder-width rounded px-3 py-1.5 font-medium" data-width="100%">Desktop</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="768px">Tablet</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="390px">Mobile</button></div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button type="button" id="builder-undo" class="rounded px-2.5 py-1.5" title="Undo (⌘Z)">Undo</button>
|
||||
<button type="button" id="builder-redo" class="rounded px-2.5 py-1.5" title="Redo (⇧⌘Z)">Redo</button>
|
||||
<button type="button" id="builder-copy" class="rounded px-2.5 py-1.5" title="Copy selected (⌘C)">Copy</button>
|
||||
<button type="button" id="builder-paste" class="rounded px-2.5 py-1.5" title="Paste (⌘V)">Paste</button>
|
||||
</div>
|
||||
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900"><button type="button" class="builder-width rounded px-3 py-1.5 font-medium" data-width="100%">Desktop</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="768px">Tablet</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="390px">Mobile</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="builder-viewport" class="mx-auto w-full transition-all duration-200">
|
||||
<div id="builder-canvas" class="min-h-[34rem] space-y-3 rounded-xl border border-dashed border-zinc-300 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"></div>
|
||||
@@ -100,7 +108,7 @@
|
||||
const clone = value => JSON.parse(JSON.stringify(value));
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
const titleFor = (key, definition = {}) => definition.label || key.replaceAll('_', ' ').replace(/\b\w/g, char => char.toUpperCase());
|
||||
const controlFor = definition => definition.control || (definition.type === 'boolean' ? 'toggle' : definition.type === 'integer' ? 'number' : 'text');
|
||||
const controlFor = definition => definition.control || (definition.type === 'boolean' ? 'toggle' : definition.type === 'integer' ? 'number' : definition.type === 'image' ? 'image' : 'text');
|
||||
|
||||
function normaliseNode(node) {
|
||||
node.id ||= uid();
|
||||
@@ -132,6 +140,10 @@
|
||||
let selectedId = layout[0]?.id || null;
|
||||
let dragId = null;
|
||||
let activeBreakpoint = 'desktop';
|
||||
let clipboardNode = null;
|
||||
let history = [];
|
||||
let historyIndex = -1;
|
||||
let isRestoringHistory = false;
|
||||
|
||||
|
||||
const styleDefaults = {
|
||||
@@ -184,6 +196,10 @@
|
||||
const hidden = document.getElementById('data-json');
|
||||
const jsonEditor = document.getElementById('json-editor');
|
||||
const panel = document.getElementById('json-panel');
|
||||
const undoButton = document.getElementById('builder-undo');
|
||||
const redoButton = document.getElementById('builder-redo');
|
||||
const copyButton = document.getElementById('builder-copy');
|
||||
const pasteButton = document.getElementById('builder-paste');
|
||||
|
||||
const sync = () => {
|
||||
data._layout = layout;
|
||||
@@ -191,6 +207,72 @@
|
||||
jsonEditor.value = JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({ data, layout, selectedId });
|
||||
}
|
||||
|
||||
function refreshHistoryButtons() {
|
||||
if (undoButton) undoButton.disabled = historyIndex <= 0;
|
||||
if (redoButton) redoButton.disabled = historyIndex >= history.length - 1;
|
||||
[undoButton, redoButton].forEach(button => button?.classList.toggle('opacity-40', button.disabled));
|
||||
if (copyButton) copyButton.disabled = !findNode(selectedId);
|
||||
if (pasteButton) pasteButton.disabled = !clipboardNode;
|
||||
[copyButton, pasteButton].forEach(button => button?.classList.toggle('opacity-40', button.disabled));
|
||||
}
|
||||
|
||||
function recordHistory() {
|
||||
if (isRestoringHistory) return;
|
||||
const state = snapshot();
|
||||
if (history[historyIndex] === state) return;
|
||||
history = history.slice(0, historyIndex + 1);
|
||||
history.push(state);
|
||||
if (history.length > 80) history.shift();
|
||||
historyIndex = history.length - 1;
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function restoreHistory(index) {
|
||||
if (index < 0 || index >= history.length) return;
|
||||
isRestoringHistory = true;
|
||||
const state = JSON.parse(history[index]);
|
||||
data = state.data || {};
|
||||
layout = state.layout || [];
|
||||
selectedId = state.selectedId || layout[0]?.id || null;
|
||||
layout.forEach(normaliseNode);
|
||||
historyIndex = index;
|
||||
sync(); renderLibraries(); renderCanvas(); renderInspector();
|
||||
isRestoringHistory = false;
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function renewIds(node) {
|
||||
node.id = uid();
|
||||
if (node.type === 'columns') node.columns.forEach(column => {
|
||||
column.id = uid();
|
||||
column.children.forEach(renewIds);
|
||||
}); else (node.children || []).forEach(renewIds);
|
||||
return node;
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
const found = findNode(selectedId);
|
||||
if (!found) return;
|
||||
clipboardNode = clone(found.node);
|
||||
refreshHistoryButtons();
|
||||
}
|
||||
|
||||
function pasteSelected() {
|
||||
if (!clipboardNode) return;
|
||||
const copy = renewIds(clone(clipboardNode));
|
||||
const found = findNode(selectedId);
|
||||
const collection = found?.collection || layout;
|
||||
const index = found ? found.index + 1 : collection.length;
|
||||
collection.splice(index, 0, copy);
|
||||
selectedId = copy.id;
|
||||
update();
|
||||
}
|
||||
|
||||
function walk(nodes, callback, parent = null, slot = null) {
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index];
|
||||
@@ -266,7 +348,13 @@
|
||||
if (node.type === 'field') {
|
||||
const definition = schema[node.key] || {};
|
||||
const value = data[node.key];
|
||||
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>`;
|
||||
const control = controlFor(definition);
|
||||
const isEditableText = ['text', 'textarea'].includes(control);
|
||||
const content = value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content';
|
||||
const preview = control === 'image'
|
||||
? `<img src="${escapeHtml(value || '')}" alt="" class="max-h-52 w-full rounded-lg object-cover" onerror="this.style.display='none'">`
|
||||
: `<div ${isEditableText ? `contenteditable="true" data-inline-field="${escapeHtml(node.key)}"` : ''} class="whitespace-pre-wrap text-sm text-zinc-700 outline-none dark:text-zinc-200 ${isEditableText ? 'rounded px-1 py-0.5 focus:ring-2 focus:ring-blue-500/30' : ''}">${control === 'textarea' ? String(content) : escapeHtml(content)}</div>`;
|
||||
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>${preview}</article>`;
|
||||
}
|
||||
if (node.type === 'columns') {
|
||||
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>`;
|
||||
@@ -276,6 +364,16 @@
|
||||
|
||||
function bindCanvas() {
|
||||
canvas.querySelectorAll('[data-select-node]').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); selectedId = button.dataset.selectNode; renderCanvas(); renderInspector(); }));
|
||||
canvas.querySelectorAll('[data-inline-field]').forEach(element => {
|
||||
element.addEventListener('click', event => event.stopPropagation());
|
||||
element.addEventListener('input', event => {
|
||||
const key = element.dataset.inlineField;
|
||||
const definition = schema[key] || {};
|
||||
data[key] = controlFor(definition) === 'textarea' ? element.innerHTML : element.textContent;
|
||||
sync();
|
||||
});
|
||||
element.addEventListener('blur', () => { recordHistory(); renderInspector(); });
|
||||
});
|
||||
canvas.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const found = findNode(button.dataset.id);
|
||||
@@ -325,17 +423,40 @@
|
||||
const control = controlFor(definition);
|
||||
const label = titleFor(node.key, definition);
|
||||
document.getElementById('inspector-help').textContent = label;
|
||||
if (control === 'toggle') inspector.innerHTML = `<label class="flex items-center justify-between rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700"><span>${escapeHtml(label)}</span><input id="inspector-control" type="checkbox" class="h-5 w-5" ${data[node.key] ? 'checked' : ''}></label>`;
|
||||
else if (control === 'textarea') inspector.innerHTML = `<label class="block text-sm font-medium">${escapeHtml(label)}<textarea id="inspector-control" class="mt-2 min-h-40 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950">${escapeHtml(data[node.key] || '')}</textarea></label>`;
|
||||
else {
|
||||
if (control === 'toggle') {
|
||||
inspector.innerHTML = `<label class="flex items-center justify-between rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700"><span>${escapeHtml(label)}</span><input id="inspector-control" type="checkbox" class="h-5 w-5" ${data[node.key] ? 'checked' : ''}></label>`;
|
||||
} else if (control === 'textarea') {
|
||||
inspector.innerHTML = `<div class="space-y-2"><label class="block text-sm font-medium">${escapeHtml(label)}</label><div class="flex flex-wrap gap-1 rounded-lg border border-zinc-200 p-1 dark:border-zinc-700"><button type="button" data-rich-command="bold" class="rounded px-2 py-1 text-xs font-bold">B</button><button type="button" data-rich-command="italic" class="rounded px-2 py-1 text-xs italic">I</button><button type="button" data-rich-command="underline" class="rounded px-2 py-1 text-xs underline">U</button><button type="button" data-rich-command="insertUnorderedList" class="rounded px-2 py-1 text-xs">List</button><button type="button" id="rich-link" class="rounded px-2 py-1 text-xs">Link</button><button type="button" data-rich-command="removeFormat" class="rounded px-2 py-1 text-xs">Clear</button></div><div id="inspector-control" contenteditable="true" class="min-h-40 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm outline-none dark:border-zinc-700 dark:bg-zinc-950">${String(data[node.key] || '')}</div></div>`;
|
||||
} else if (control === 'image') {
|
||||
inspector.innerHTML = `<div class="space-y-3"><label class="block text-sm font-medium">${escapeHtml(label)}<input id="inspector-control" type="url" 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(data[node.key] ?? '')}" placeholder="https://…"></label><label class="block rounded-xl border border-dashed border-zinc-300 p-3 text-sm dark:border-zinc-700">Choose image<input id="inspector-image-file" type="file" accept="image/*" class="mt-2 block w-full text-xs"></label>${data[node.key] ? `<img src="${escapeHtml(data[node.key])}" alt="" class="max-h-48 w-full rounded-xl object-cover">` : ''}</div>`;
|
||||
} else {
|
||||
const type = control === 'number' ? 'number' : control === 'url' ? 'url' : 'text';
|
||||
inspector.innerHTML = `<label class="block text-sm font-medium">${escapeHtml(label)}<input id="inspector-control" type="${type}" 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(data[node.key] ?? '')}"></label>`;
|
||||
}
|
||||
const input = document.getElementById('inspector-control');
|
||||
input?.addEventListener(control === 'toggle' ? 'change' : 'input', event => {
|
||||
data[node.key] = control === 'toggle' ? event.target.checked : control === 'number' ? Number(event.target.value) : event.target.value;
|
||||
const eventName = control === 'toggle' ? 'change' : 'input';
|
||||
input?.addEventListener(eventName, event => {
|
||||
data[node.key] = control === 'toggle' ? event.target.checked : control === 'number' ? Number(event.target.value) : control === 'textarea' ? event.target.innerHTML : event.target.value;
|
||||
sync(); renderCanvas();
|
||||
});
|
||||
input?.addEventListener('change', recordHistory);
|
||||
inspector.querySelectorAll('[data-rich-command]').forEach(button => button.addEventListener('click', () => {
|
||||
document.execCommand(button.dataset.richCommand, false, null);
|
||||
data[node.key] = input.innerHTML; sync(); renderCanvas(); recordHistory();
|
||||
}));
|
||||
document.getElementById('rich-link')?.addEventListener('click', () => {
|
||||
const url = window.prompt('Link URL');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
data[node.key] = input.innerHTML; sync(); renderCanvas(); recordHistory();
|
||||
});
|
||||
document.getElementById('inspector-image-file')?.addEventListener('change', event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) return window.alert('Choose an image smaller than 2 MB.');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => { data[node.key] = reader.result; update(); };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
@@ -373,8 +494,32 @@
|
||||
});
|
||||
}
|
||||
|
||||
function update() { sync(); renderLibraries(); renderCanvas(); renderInspector(); }
|
||||
function update() { sync(); renderLibraries(); renderCanvas(); renderInspector(); recordHistory(); refreshHistoryButtons(); }
|
||||
|
||||
undoButton?.addEventListener('click', () => restoreHistory(historyIndex - 1));
|
||||
redoButton?.addEventListener('click', () => restoreHistory(historyIndex + 1));
|
||||
copyButton?.addEventListener('click', copySelected);
|
||||
pasteButton?.addEventListener('click', pasteSelected);
|
||||
document.addEventListener('keydown', event => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
const editing = ['input', 'textarea', 'select'].includes(tag) || document.activeElement?.isContentEditable;
|
||||
if (modifier && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
restoreHistory(historyIndex + (event.shiftKey ? 1 : -1));
|
||||
} else if (modifier && event.key.toLowerCase() === 'c' && !editing) {
|
||||
event.preventDefault(); copySelected();
|
||||
} else if (modifier && event.key.toLowerCase() === 'v' && !editing) {
|
||||
event.preventDefault(); pasteSelected();
|
||||
} else if ((event.key === 'Delete' || event.key === 'Backspace') && !editing) {
|
||||
const found = findNode(selectedId);
|
||||
if (found && window.confirm('Delete the selected component?')) {
|
||||
found.collection.splice(found.index, 1);
|
||||
selectedId = found.parent?.id || layout[0]?.id || null;
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('toggle-json').addEventListener('click', () => { panel.classList.toggle('hidden'); jsonEditor.value = JSON.stringify(data, null, 2); });
|
||||
document.getElementById('apply-json').addEventListener('click', () => {
|
||||
const message = document.getElementById('json-message');
|
||||
|
||||
Reference in New Issue
Block a user