export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'}; export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' }; // A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the // document, stay selectable and movable, but the editor never edits or fills them. export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/; export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type); export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt'); export const blockIcon = block => icons[block?.type] ?? '❔'; export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type); // Nothing may be dropped into a plugin block: the core does not know its layout contract. // Containers that accept only one kind of child, mirrored by the server side validator. export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' }; export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) && (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type) && (block.type !== 'accordionitem' || parent.type === 'accordion') && (block.type !== 'tabitem' || parent.type === 'tabs'); export const clone = value => structuredClone(value); export function locate(blocks, id, parent = null) { for (let index = 0; index < blocks.length; index++) { const block = blocks[index]; if (block.id === id) return { block, siblings: blocks, index, parent }; const child = locate(block.children || [], id, block); if (child) return child; } return null; } export function createBlock(type) { if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.'); const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type }; if (isContainer(block)) block.children = []; if (type === 'accordion') block.children = [createBlock('accordionitem')]; // A single tab is not a tab group, and the script needs at least two to enhance anything. if (type === 'tabs') block.children = [createBlock('tabitem'), createBlock('tabitem')]; if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 }; if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' }; if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' }; if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' }; if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' }; if (type === 'legal') block.props = { label: 'Rechtliches' }; if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' }; if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' }; if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 }; if (type === 'cta') block.props = { title: 'Bereit für den nächsten Schritt?', text: '', buttonText: 'Jetzt anfragen', href: '/' }; if (type === 'contact') block.props = { phone: '', email: '', address: '' }; if (type === 'spacer') block.props = { size: 'medium' }; if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false }; if (type === 'tabitem') block.props = { title: 'Neuer Tab' }; if (type === 'form') block.props = { form: '' }; if (type === 'menu') block.props = { slot: 'header', layout: 'horizontal' }; if (type === 'partialref') block.props = { partial: '' }; // An embed starts empty and blocked: no address, so nothing loads before someone enters one. if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 }; if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' }; if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true }; if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } export function insertBlock(document, type, selected) { const block = createBlock(type); if (type === 'gallery') upgradeDocument(document, 3); if (type === 'postlist') upgradeDocument(document, 5); if (type === 'postnav') upgradeDocument(document, 18); if (type === 'legal') upgradeDocument(document, 19); if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7); if (type === 'form') upgradeDocument(document, 8); if (type === 'breadcrumb') upgradeDocument(document, 9); if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11); if (type === 'embed') upgradeDocument(document, 14); if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15); let target = locate(document.blocks, selected); if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; } if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id); if (!target) document.blocks.push(block); else if (canContain(target.block, block)) (target.block.children ??= []).push(block); else target.siblings.splice(target.index + 1, 0, block); return block.id; } export function duplicateBlock(document, selected) { const target = locate(document.blocks, selected); if (!target) return null; const copy = clone(target.block); const rekey = block => { block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`; if (block.type === 'heading' && block.props.level === 1) block.props.level = 2; (block.children || []).forEach(rekey); }; rekey(copy); target.siblings.splice(target.index + 1, 0, copy); return copy.id; } export function moveBlock(document, selected, targetId, inside = false) { const source = locate(document.blocks, selected); const target = locate(document.blocks, targetId); if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false; if (inside && !isContainer(target.block)) return false; if (inside && !canContain(target.block, source.block)) return false; if (!inside && target.parent && !canContain(target.parent, source.block)) return false; source.siblings.splice(source.index, 1); const destination = locate(document.blocks, targetId); if (inside) (destination.block.children ??= []).push(source.block); else destination.siblings.splice(destination.index, 0, source.block); return true; } export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }; // Called only inside a history transaction: all other fields and IDs stay untouched. // Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // nirgends. Eine Zahl, an einer Stelle. export const SCHEMA_VERSION = 19; const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION; export function upgradeDocument(document, version = 2) { if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } export function effectiveDesign(block, device, key) { let value, source; for (const name of Object.keys(devices)) { if (block.layout?.design?.[name]?.[key] !== undefined) { value = block.layout.design[name][key]; source = name; } if (name === device) break; } return { value, source }; } export function setDesign(document, id, device, key, value) { if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.'); const block = locate(document.blocks, id)?.block; if (!block) return; if (value === '') { const design = block.layout?.design; if (!design?.[device] || !Object.hasOwn(design[device], key)) return; delete design[device][key]; if (!Object.keys(design[device]).length) delete design[device]; if (!Object.keys(design).length) delete block.layout.design; } else { upgradeDocument(document); ((block.layout ??= {}).design ??= {})[device] ??= {}; block.layout.design[device][key] = value; } } export function blockPath(blocks, id) { const found = locate(blocks, id); return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : []; } export function setEffect(document, id, key, value) { const block = locate(document.blocks, id)?.block; if (!block) return; upgradeDocument(document, 3); ((block.layout ??= {}).effects ??= {})[key] = value; } export function mediaProps(media) { const largest = media.variants.at(-1); return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) }; } export function insertMedia(document, selected, items) { const target = locate(document.blocks, selected)?.block; upgradeDocument(document, 3); if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; } let parent = target; if (target?.type !== 'gallery') { const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block; } for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); } return parent.id; } /** * A row with a given number of columns, inserted where the selection is. * * A row on its own looks like nothing, which is why "add a row" was hard to find: what people * want is a row that already has columns in it. */ /** * A full screen hero: edge to edge, as tall as the screen, content centred, with room for a * background image and a dark overlay so text stays readable on any photo. * * The parts all exist on their own - width "full", min-height in dvh, display flex, overlay - but * finding that combination is a research project. This is the one click. */ export function insertHero(document, selected) { const section = createBlock('section'); section.layout = { tone: 'ink', width: 'full' }; section.style = { desktop: { // dvh, not vh: on a phone 100vh is taller than the screen. minHeight: '100dvh', display: 'flex', alignItems: 'center', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', overlayColor: '#000000', overlayOpacity: 0.4, padding: { top: '80px', right: '32px', bottom: '80px', left: '32px' }, textAlign: 'center', }, mobile: { minHeight: '80dvh', padding: { top: '56px', right: '20px', bottom: '56px', left: '20px' } }, }; // A hero at the top of a page carries its main heading - but a page has exactly one, and // inserting a second would produce a document the editor refuses to save. const hasMainHeading = blocks => blocks.some(block => (block.type === 'heading' && block.props?.level === 1) || hasMainHeading(block.children ?? [])); const heading = createBlock('heading'); heading.props = { text: 'Deine Botschaft, ganzflächig.', level: hasMainHeading(document.blocks) ? 2 : 1 }; const paragraph = createBlock('text'); paragraph.props = { text: 'Ein Satz, der sagt, worum es geht. Wähle im Designbereich unter Hintergrund ein Bild und stelle die Deckkraft der Überlagerung so ein, dass der Text lesbar bleibt.' }; const button = createBlock('button'); section.children = [heading, paragraph, button]; upgradeDocument(document, 10); const top = blockPath(document.blocks, selected)[0]; const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length; document.blocks.splice(index, 0, section); return section.id; } export function insertRow(document, columns, selected) { const count = Math.max(1, Math.min(6, Number(columns) || 2)); const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' }; const row = createBlock('row'); row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } }; row.children = Array.from({ length: count }, () => createBlock('column')); upgradeDocument(document); const target = locate(document.blocks, selected); if (!target) document.blocks.push(row); else if (canContain(target.block, row)) (target.block.children ??= []).push(row); else target.siblings.splice(target.index + 1, 0, row); return row.id; } export function insertPreset(document, name, selected) { if (name === 'hero') return insertHero(document, selected); if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.'); const section = createBlock('section'); const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; }; const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; }; section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' }; section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')]; if (name === 'columns') { const row = createBlock('row'); row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } }; row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => { const column = createBlock('column'); const title = heading(text); title.props.level = 3; column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')]; return column; }); section.children.push(row); upgradeDocument(document); } else section.children.push(createBlock('button')); const top = blockPath(document.blocks, selected)[0]; const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length; document.blocks.splice(index, 0, section); return section.id; } export class EditorHistory { constructor(document) { this.reset(document); } reset(document) { this.document = clone(document); this.past = []; this.future = []; this.mergeKey = null; this.saved = JSON.stringify(document); } change(mutator, mergeKey = null) { const next = clone(this.document); const result = mutator(next); if (JSON.stringify(next) === JSON.stringify(this.document)) return result; if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document); if (this.past.length > 100) this.past.shift(); this.document = next; this.future = []; this.mergeKey = mergeKey; return result; } undo() { if (!this.past.length) return; this.future.push(this.document); this.document = this.past.pop(); this.mergeKey = null; } redo() { if (!this.future.length) return; this.past.push(this.document); this.document = this.future.pop(); this.mergeKey = null; } markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; } get dirty() { return JSON.stringify(this.document) !== this.saved; } } // --------------------------------------------------------------------------------------------- // Free block styles (document schema v10). // // A style is { device: { property: value, hover: { property: value } } }. Desktop is the base, // tablet and mobile override it. Everything mirrors StyleSchema on the server: the editor can only // offer what the server accepts, and a value the server would reject never reaches a document. // --------------------------------------------------------------------------------------------- /** The value that actually applies on a device, plus the device it was inherited from. */ export function effectiveStyle(block, device, key, side = null) { let value, source; for (const name of Object.keys(devices)) { const stored = block?.style?.[name]?.[key]; const candidate = side === null ? stored : stored?.[side]; if (candidate !== undefined) { value = candidate; source = name; } if (name === device) break; } return { value, source }; } export function hoverStyle(block, device, key) { return block?.style?.[device]?.hover?.[key]; } /** Removes empty objects so an untouched block never carries a style skeleton. */ function prune(block) { for (const device of Object.keys(block.style ?? {})) { const values = block.style[device]; for (const key of Object.keys(values)) { if (values[key] && typeof values[key] === 'object' && Object.keys(values[key]).length === 0) delete values[key]; } if (Object.keys(values).length === 0) delete block.style[device]; } if (block.style && Object.keys(block.style).length === 0) delete block.style; } /** * Sets one style value. An empty value removes it, which makes the block fall back to the wider * device and finally to the theme. Called inside a history transaction like every other change. */ export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) { if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.'); const block = locate(document.blocks, id)?.block; if (!block) return; const empty = value === '' || value === null || value === undefined; if (!empty) upgradeDocument(document, 10); block.style ??= {}; block.style[device] ??= {}; let target = block.style[device]; if (hover) { target.hover ??= {}; target = target.hover; } if (side !== null) { if (empty) { if (target[key]) delete target[key][side]; } else { target[key] ??= {}; target[key][side] = value; } } else if (empty) { delete target[key]; } else { target[key] = value; } prune(block); } /** Drops every style of one device, so it inherits again. */ export function resetStyleDevice(document, id, device) { const block = locate(document.blocks, id)?.block; if (!block?.style) return; delete block.style[device]; prune(block); } /** Drops the whole styling of a block. */ export function clearStyle(document, id) { const block = locate(document.blocks, id)?.block; if (!block) return; delete block.style; } /** Copies the styling of one block onto another, with fresh objects. */ export function copyStyle(document, fromId, toId) { const source = locate(document.blocks, fromId)?.block; const target = locate(document.blocks, toId)?.block; if (!source || !target) return false; if (!source.style) { delete target.style; return true; } upgradeDocument(document, 10); target.style = structuredClone(source.style); return true; }