* 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
556 lines
41 KiB
PHP
556 lines
41 KiB
PHP
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
|
|
@php
|
|
$builderSchema = $selectedType?->schema ?: [];
|
|
$builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES));
|
|
@endphp
|
|
<div class="space-y-6" data-content-builder>
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
|
<a href="{{ route('content.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Content documents</a>
|
|
<span>/</span>
|
|
<span>{{ $document->exists ? 'Visual editor' : 'Create' }}</span>
|
|
</div>
|
|
<h1 class="mt-1 text-2xl font-semibold">{{ $document->exists ? $document->title : 'Create content document' }}</h1>
|
|
<p class="mt-1 text-sm text-zinc-500">Build and edit structured content visually. The stored document format remains fully compatible with the existing renderer.</p>
|
|
</div>
|
|
@if($document->exists)
|
|
<div class="flex flex-wrap gap-2">
|
|
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">@csrf<flux:button type="submit" size="sm">Create independent copy</flux:button></form>
|
|
<form method="POST" action="{{ route('content.documents.destroy', $document) }}" onsubmit="return confirm('Delete this document? This is only allowed when it is not used by any page.')">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Delete</flux:button></form>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>@endif
|
|
@if($errors->any())
|
|
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200"><strong>The document could not be saved.</strong><ul class="mt-2 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
|
|
@endif
|
|
@if($document->exists && $document->placements->count() > 1)
|
|
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"><strong>Shared document.</strong> This document is used in {{ $document->placements->count() }} page placements. Changes appear everywhere it is published.</div>
|
|
@endif
|
|
|
|
<form id="content-document-form" method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="space-y-6">
|
|
@csrf
|
|
@if($document->exists) @method('PUT') @endif
|
|
<input type="hidden" name="data_json" id="data-json" value="{{ $builderData }}">
|
|
|
|
<div class="grid gap-4 rounded-xl border border-zinc-200 bg-white p-4 md:grid-cols-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
|
|
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Generated from title when empty." />
|
|
<flux:select id="content-type-select" name="content_type_id" label="Content type" required>
|
|
@foreach($types as $type)<option value="{{ $type->id }}" @selected((int) old('content_type_id', $selectedType?->id) === $type->id)>{{ $type->name }}</option>@endforeach
|
|
</flux:select>
|
|
<flux:select name="status" label="Status"><option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option><option value="published" @selected(old('status', $document->status) === 'published')>Published</option></flux:select>
|
|
</div>
|
|
|
|
<div class="grid min-h-[42rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white xl:grid-cols-[16rem_minmax(0,1fr)_20rem] dark:border-zinc-700 dark:bg-zinc-900">
|
|
<aside class="border-b border-zinc-200 p-4 xl:border-b-0 xl:border-r dark:border-zinc-700">
|
|
<div class="mb-4"><h2 class="font-semibold">Components</h2><p class="text-xs text-zinc-500">Add layouts or document fields to the canvas.</p></div>
|
|
<div id="layout-library" class="mb-5 grid grid-cols-2 gap-2"></div>
|
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Document fields</div>
|
|
<div id="builder-library" class="space-y-2"></div>
|
|
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
|
<button type="button" id="toggle-json" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Advanced JSON</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<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 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>
|
|
</div>
|
|
<div id="json-panel" class="mt-4 hidden"><div class="mb-2 flex items-center justify-between"><label for="json-editor" class="text-sm font-medium">Document JSON</label><button type="button" id="apply-json" class="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700">Apply JSON</button></div><textarea id="json-editor" class="min-h-72 w-full rounded-xl border border-zinc-300 bg-zinc-950 p-4 font-mono text-sm text-zinc-100" spellcheck="false"></textarea><p id="json-message" class="mt-2 text-sm"></p></div>
|
|
</main>
|
|
|
|
<aside class="border-t border-zinc-200 p-4 xl:border-l xl:border-t-0 dark:border-zinc-700">
|
|
<div class="mb-4"><h2 class="font-semibold">Inspector</h2><p id="inspector-help" class="text-xs text-zinc-500">Select a field on the canvas.</p></div>
|
|
<div id="builder-inspector"></div>
|
|
<div class="mt-6 space-y-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
|
<flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}" />
|
|
<flux:input type="datetime-local" name="expires_at" label="Expire at" value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}" />
|
|
<flux:button type="submit" variant="primary" class="w-full">{{ $document->exists ? 'Save document' : 'Create document' }}</flux:button>
|
|
</div>
|
|
@if($document->exists)
|
|
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700"><h3 class="text-sm font-semibold">Usage</h3><p class="mt-1 text-xs text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>@foreach($document->placements as $placement)<a class="mt-2 block rounded-lg border border-zinc-200 p-2 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800" href="{{ route('content.pages.edit', $placement->page_key) }}"><strong>{{ ucfirst($placement->page_key ?: 'Page') }}</strong><span class="block uppercase text-zinc-500">{{ $placement->region }}</span></a>@endforeach</div>
|
|
@endif
|
|
</aside>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
@push('scripts')
|
|
<script>
|
|
(() => {
|
|
const schema = @json($builderSchema);
|
|
let data;
|
|
try { data = JSON.parse(document.getElementById('data-json').value || '{}'); } catch (_) { data = {}; }
|
|
|
|
const layoutTemplates = {
|
|
section: { type: 'section', label: 'Section', children: [] },
|
|
container: { type: 'container', label: 'Container', children: [] },
|
|
columns2: { type: 'columns', label: 'Two columns', columns: [{ children: [] }, { children: [] }] },
|
|
columns3: { type: 'columns', label: 'Three columns', columns: [{ children: [] }, { children: [] }, { children: [] }] },
|
|
};
|
|
|
|
const uid = () => `node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
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' : definition.type === 'image' ? 'image' : 'text');
|
|
|
|
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 => {
|
|
column.id ||= uid();
|
|
column.children ||= [];
|
|
column.children.forEach(normaliseNode);
|
|
});
|
|
} else {
|
|
node.children ||= [];
|
|
node.children.forEach(normaliseNode);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function defaultLayout() {
|
|
return [{ id: uid(), type: 'section', label: 'Content', children: Object.keys(schema).map(key => ({ id: uid(), type: 'field', key })) }];
|
|
}
|
|
|
|
let layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
|
layout.forEach(normaliseNode);
|
|
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 = {
|
|
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');
|
|
const canvas = document.getElementById('builder-canvas');
|
|
const inspector = document.getElementById('builder-inspector');
|
|
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;
|
|
hidden.value = JSON.stringify(data);
|
|
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];
|
|
if (callback(node, nodes, index, parent, slot) === false) return false;
|
|
if (node.type === 'columns') {
|
|
for (let columnIndex = 0; columnIndex < node.columns.length; columnIndex++) {
|
|
if (walk(node.columns[columnIndex].children, callback, node, `column:${columnIndex}`) === false) return false;
|
|
}
|
|
} else if (walk(node.children || [], callback, node, 'children') === false) return false;
|
|
}
|
|
}
|
|
|
|
function findNode(id) {
|
|
let found = null;
|
|
walk(layout, (node, collection, index, parent, slot) => {
|
|
if (node.id === id) { found = { node, collection, index, parent, slot }; return false; }
|
|
});
|
|
return found;
|
|
}
|
|
|
|
function detachNode(id) {
|
|
const found = findNode(id);
|
|
if (!found) return null;
|
|
return found.collection.splice(found.index, 1)[0];
|
|
}
|
|
|
|
function containsNode(node, id) {
|
|
if (node.id === id) return true;
|
|
let result = false;
|
|
const inspect = children => children?.some(child => containsNode(child, id));
|
|
if (node.type === 'columns') result = node.columns.some(column => inspect(column.children));
|
|
else result = inspect(node.children);
|
|
return Boolean(result);
|
|
}
|
|
|
|
function targetCollection(targetId, slot = 'children') {
|
|
if (targetId === 'root') return layout;
|
|
const target = findNode(targetId)?.node;
|
|
if (!target) return layout;
|
|
if (slot.startsWith('column:')) return target.columns[Number(slot.split(':')[1])].children;
|
|
target.children ||= [];
|
|
return target.children;
|
|
}
|
|
|
|
function addNode(template, targetId = 'root', slot = 'children') {
|
|
const node = normaliseNode(clone(template));
|
|
targetCollection(targetId, slot).push(node);
|
|
selectedId = node.id;
|
|
update();
|
|
}
|
|
|
|
function fieldNode(key) { return { id: uid(), type: 'field', key }; }
|
|
|
|
function renderLibraries() {
|
|
layoutLibrary.innerHTML = Object.entries(layoutTemplates).map(([key, template]) => `<button type="button" data-add-layout="${key}" class="rounded-lg border border-zinc-200 px-2 py-2 text-left text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"><strong class="block">${escapeHtml(template.label)}</strong><span class="text-zinc-500">Add</span></button>`).join('');
|
|
layoutLibrary.querySelectorAll('[data-add-layout]').forEach(button => button.addEventListener('click', () => addNode(layoutTemplates[button.dataset.addLayout])));
|
|
|
|
library.innerHTML = Object.entries(schema).map(([key, definition]) => `<button type="button" data-add-field="${escapeHtml(key)}" class="w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"><span class="block font-medium">${escapeHtml(titleFor(key, definition))}</span><span class="block text-[11px] text-zinc-500">${escapeHtml(controlFor(definition))} · add to canvas</span></button>`).join('') || '<p class="rounded-lg border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-700">This content type has no schema yet.</p>';
|
|
library.querySelectorAll('[data-add-field]').forEach(button => button.addEventListener('click', () => addNode(fieldNode(button.dataset.addField))));
|
|
}
|
|
|
|
function nodeToolbar(node) {
|
|
return `<div class="flex items-center gap-1"><button type="button" data-action="duplicate" data-id="${node.id}" class="rounded border border-zinc-300 px-2 py-1 text-[11px] dark:border-zinc-700">Duplicate</button><button type="button" data-action="delete" data-id="${node.id}" class="rounded border border-red-300 px-2 py-1 text-[11px] text-red-700 dark:border-red-800 dark:text-red-300">Delete</button></div>`;
|
|
}
|
|
|
|
function renderDropZone(targetId, slot, label = 'Drop here') {
|
|
return `<div data-drop-target="${targetId}" data-drop-slot="${slot}" class="my-2 rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-center text-[11px] text-zinc-400 transition dark:border-zinc-700">${label}</div>`;
|
|
}
|
|
|
|
function renderNode(node) {
|
|
const selected = node.id === selectedId;
|
|
const base = `relative rounded-xl border ${selected ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-zinc-200 dark:border-zinc-700'}`;
|
|
if (node.type === 'field') {
|
|
const definition = schema[node.key] || {};
|
|
const value = data[node.key];
|
|
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>`;
|
|
}
|
|
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() {
|
|
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);
|
|
if (!found) return;
|
|
if (button.dataset.action === 'delete') {
|
|
found.collection.splice(found.index, 1);
|
|
selectedId = found.parent?.id || layout[0]?.id || null;
|
|
} else {
|
|
const copy = clone(found.node);
|
|
const renew = node => { node.id = uid(); if (node.type === 'columns') node.columns.forEach(column => { column.id = uid(); column.children.forEach(renew); }); else (node.children || []).forEach(renew); };
|
|
renew(copy);
|
|
found.collection.splice(found.index + 1, 0, copy);
|
|
selectedId = copy.id;
|
|
}
|
|
update();
|
|
}));
|
|
canvas.querySelectorAll('[data-node-id]').forEach(element => {
|
|
element.addEventListener('dragstart', event => { dragId = element.dataset.nodeId; event.dataTransfer.effectAllowed = 'move'; event.stopPropagation(); });
|
|
element.addEventListener('dragend', () => { dragId = null; });
|
|
});
|
|
canvas.querySelectorAll('[data-drop-target]').forEach(zone => {
|
|
zone.addEventListener('dragover', event => { event.preventDefault(); event.stopPropagation(); zone.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-950/30'); });
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30'));
|
|
zone.addEventListener('drop', event => {
|
|
event.preventDefault(); event.stopPropagation();
|
|
zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30');
|
|
const moving = findNode(dragId)?.node;
|
|
const target = findNode(zone.dataset.dropTarget)?.node;
|
|
if (!moving || moving.id === target?.id || (target && containsNode(moving, target.id))) return;
|
|
const detached = detachNode(dragId);
|
|
if (!detached) return;
|
|
targetCollection(zone.dataset.dropTarget, zone.dataset.dropSlot).push(detached);
|
|
selectedId = detached.id;
|
|
update();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderCanvas() {
|
|
canvas.innerHTML = `${renderDropZone('root', 'children', 'Drop at page root')}<div class="space-y-3">${layout.map(renderNode).join('')}</div>`;
|
|
if (!layout.length) canvas.innerHTML += '<div class="flex min-h-64 items-center justify-center text-center text-sm text-zinc-500">Add a section, columns, container, or field from the component library.</div>';
|
|
bindCanvas();
|
|
}
|
|
|
|
function renderFieldInspector(node) {
|
|
const definition = schema[node.key] || {};
|
|
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 = `<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');
|
|
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() {
|
|
const found = findNode(selectedId);
|
|
if (!found) { inspector.innerHTML = '<p class="text-sm text-zinc-500">Select a component on the canvas.</p>'; return; }
|
|
const node = found.node;
|
|
if (node.type === 'field') return renderFieldInspector(node);
|
|
document.getElementById('inspector-help').textContent = node.label || titleFor(node.type);
|
|
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: [] });
|
|
while (node.columns.length > count) {
|
|
const removed = node.columns.pop();
|
|
node.columns[node.columns.length - 1].children.push(...removed.children);
|
|
}
|
|
update();
|
|
});
|
|
}
|
|
|
|
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');
|
|
try {
|
|
data = JSON.parse(jsonEditor.value);
|
|
layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
|
layout.forEach(normaliseNode);
|
|
selectedId = layout[0]?.id || null;
|
|
message.textContent = 'JSON applied to the visual editor.';
|
|
message.className = 'mt-2 text-sm text-green-700 dark:text-green-300';
|
|
update();
|
|
} catch (error) {
|
|
message.textContent = error.message;
|
|
message.className = 'mt-2 text-sm text-red-700 dark:text-red-300';
|
|
}
|
|
});
|
|
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 => {
|
|
if (!@json($document->exists)) window.location.href = `{{ route('content.documents.create') }}?type=${event.target.value}`;
|
|
});
|
|
|
|
update();
|
|
})();
|
|
</script>
|
|
@endpush
|
|
</x-layouts.app>
|