From dad92ad799f493a40c52abfeebca729334f8f59c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 27 Jul 2026 09:51:45 +0100 Subject: [PATCH] =?UTF-8?q?=20=20=20=20Inline=20text=20editing=20directly?= =?UTF-8?q?=20on=20the=20canvas=20*=20Rich-text=20editing=20for=20textarea?= =?UTF-8?q?=20fields=20*=20Bold,=20italic,=20underline,=20lists,=20links?= =?UTF-8?q?=20and=20clear=20formatting=20*=20Image=20URL=20input=20*=20Loc?= =?UTF-8?q?al=20image=20selection=20with=20preview=20*=20Undo=20and=20redo?= =?UTF-8?q?=20history=20*=20Copy=20and=20paste=20selected=20components=20*?= =?UTF-8?q?=20Delete=20selected=20component=20from=20the=20keyboard=20*=20?= =?UTF-8?q?Keyboard=20shortcuts:=20=20=20=20=20*=20=E2=8C=98Z=20/=20Ctrl+Z?= =?UTF-8?q?=20=E2=80=94=20undo=20=20=20=20=20*=20=E2=87=A7=E2=8C=98Z=20/?= =?UTF-8?q?=20Ctrl+Shift+Z=20=E2=80=94=20redo=20=20=20=20=20*=20=E2=8C=98C?= =?UTF-8?q?=20/=20Ctrl+C=20=E2=80=94=20copy=20component=20=20=20=20=20*=20?= =?UTF-8?q?=E2=8C=98V=20/=20Ctrl+V=20=E2=80=94=20paste=20component=20=20?= =?UTF-8?q?=20=20=20*=20Delete=20/=20Backspace=20=E2=80=94=20delete=20sele?= =?UTF-8?q?cted=20component=20*=20History=20limited=20to=2080=20editor=20s?= =?UTF-8?q?tates=20*=20Existing=20responsive=20styles=20and=20nested=20lay?= =?UTF-8?q?outs=20retained?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/views/admin/form.blade.php | 163 +++++++++++++++++- 1 file changed, 154 insertions(+), 9 deletions(-) diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php index dbec35c..72621bd 100644 --- a/Modules/Content/Resources/views/admin/form.blade.php +++ b/Modules/Content/Resources/views/admin/form.blade.php @@ -58,7 +58,15 @@

Canvas

{{ $selectedType?->name ?? 'Content document' }} · live structured preview

-
+
+
+ + + + +
+
+
@@ -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 `
${nodeToolbar(node)}

${escapeHtml(value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content')}

`; + 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' + ? `` + : `
${control === 'textarea' ? String(content) : escapeHtml(content)}
`; + return `
${nodeToolbar(node)}
${preview}
`; } if (node.type === 'columns') { return `
${nodeToolbar(node)}
${node.columns.map((column, index) => `
${renderDropZone(node.id, `column:${index}`, `Column ${index + 1}`)}
${column.children.map(renderNode).join('')}
`).join('')}
`; @@ -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 = ``; - else if (control === 'textarea') inspector.innerHTML = ``; - else { + if (control === 'toggle') { + inspector.innerHTML = ``; + } else if (control === 'textarea') { + inspector.innerHTML = `
${String(data[node.key] || '')}
`; + } else if (control === 'image') { + inspector.innerHTML = `
${data[node.key] ? `` : ''}
`; + } else { const type = control === 'number' ? 'number' : control === 'url' ? 'url' : 'text'; inspector.innerHTML = ``; } 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');