A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
700 lines
37 KiB
JavaScript
700 lines
37 KiB
JavaScript
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', 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', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
|
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
|
// 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.
|
|
/**
|
|
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
|
|
* nothing about which of them belong together or where they may go; the first group is the
|
|
* scaffolding, the rest is what goes inside it.
|
|
*/
|
|
export const CATEGORIES = [
|
|
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
|
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
|
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
|
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
|
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
|
];
|
|
|
|
/** How many columns a row can be added with. */
|
|
export const ROW_COLUMNS = [2, 3, 4];
|
|
|
|
/**
|
|
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
|
|
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
|
|
* working is one you keep pressing.
|
|
*/
|
|
export function whyNot(type) {
|
|
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
|
|
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
|
|
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
|
|
}
|
|
|
|
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
|
/**
|
|
* Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen
|
|
* Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument
|
|
* enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung.
|
|
*/
|
|
export const PLUGIN_BLOCKS = {};
|
|
/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */
|
|
export const PLUGIN_PRESETS = {};
|
|
export function registerPlugins(list, presets = []) {
|
|
for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; }
|
|
for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key];
|
|
for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset;
|
|
const types = [];
|
|
for (const definition of list || []) {
|
|
if (!PLUGIN_TYPE.test(definition?.type || '')) continue;
|
|
PLUGIN_BLOCKS[definition.type] = definition;
|
|
labels[definition.type] = definition.label;
|
|
icons[definition.type] = definition.icon || '⬡';
|
|
types.push(definition.type);
|
|
}
|
|
const group = CATEGORIES.find(category => category.name === 'Erweiterungen');
|
|
if (group) group.types = types;
|
|
else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types });
|
|
}
|
|
export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null;
|
|
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) || PLUGIN_BLOCKS[block?.type]?.container === true;
|
|
// 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.
|
|
// ---------------------------------------------------------------------------------------------
|
|
// What may sit inside what.
|
|
//
|
|
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
|
|
// because they are the ones people already have in their hands:
|
|
//
|
|
// ALLOW a container that takes only these children and nothing else
|
|
// REQUIRE a block that exists only inside one of these parents
|
|
// ROOT_NEEDS_CONTAINER
|
|
// a block that may sit in any container but not loose at the top of a document
|
|
//
|
|
// Until now a row took anything - a heading could land directly in it, next to the columns -
|
|
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
|
|
// nowhere. The structure the editor has always described in its own hint text is
|
|
// section -> row -> column -> content, and these rules are that sentence made binding.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/** Containers that take one kind of child and nothing else. */
|
|
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
|
/**
|
|
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
|
* single one, directly to a section: that is how a button or a short text gets centred and
|
|
* narrowed without a one-column row around it. Umut asked for exactly that.
|
|
*/
|
|
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
|
/**
|
|
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
|
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
|
* a section, and that is where a row belongs.
|
|
*/
|
|
export const ROOT_NEEDS_CONTAINER = ['row'];
|
|
|
|
// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts.
|
|
const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type];
|
|
export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true)
|
|
&& (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type))
|
|
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
|
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 (PLUGIN_BLOCKS[type]) {
|
|
block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? '']));
|
|
if (PLUGIN_BLOCKS[type].container) block.children = [];
|
|
return block;
|
|
}
|
|
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' };
|
|
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
|
|
if (type === 'language') block.props = { display: 'code' };
|
|
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
|
if (type === 'html') block.props = { html: '' };
|
|
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 };
|
|
// Welche Profile es gibt, steht in den Einstellungen; der Block bringt nur die Darstellung mit.
|
|
if (type === 'social') block.props = { size: 24, shape: 'plain', layout: 'horizontal', labels: false, networks: [], newTab: 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;
|
|
}
|
|
|
|
/**
|
|
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
|
|
*
|
|
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
|
|
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
|
|
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
|
|
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
|
|
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
|
|
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
|
|
*/
|
|
export function repairSchema(document) {
|
|
const walk = blocks => {
|
|
for (const block of blocks || []) {
|
|
upgradeForType(document, block.type);
|
|
const props = block.props || {};
|
|
if ('caption' in props) upgradeDocument(document, 3);
|
|
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
|
|
if ('newTab' in props) upgradeDocument(document, 20);
|
|
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
|
|
if (block.anchor) upgradeDocument(document, 21);
|
|
if ('collapse' in props) upgradeDocument(document, 22);
|
|
if ('faq' in props) upgradeDocument(document, 23);
|
|
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
|
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
|
walk(block.children);
|
|
}
|
|
};
|
|
walk(document.blocks);
|
|
if (document.translations) upgradeDocument(document, 27);
|
|
return document;
|
|
}
|
|
|
|
/** Raises the document schema to the version that knows this block type. */
|
|
export function upgradeForType(document, type) {
|
|
if (type === 'gallery') upgradeDocument(document, 3);
|
|
if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4);
|
|
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
|
|
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
|
|
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
|
|
if (type === 'postlist') upgradeDocument(document, 18);
|
|
if (type === 'postnav') upgradeDocument(document, 18);
|
|
if (type === 'legal') upgradeDocument(document, 19);
|
|
if (type === 'html') upgradeDocument(document, 24);
|
|
if (type === 'language') upgradeDocument(document, 27);
|
|
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') upgradeDocument(document, 16);
|
|
if (type === 'partialref') upgradeDocument(document, 11);
|
|
if (type === 'embed') upgradeDocument(document, 14);
|
|
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
|
|
if (type === 'slider') upgradeDocument(document, 17);
|
|
if (type === 'social') upgradeDocument(document, 23);
|
|
}
|
|
|
|
/**
|
|
* Where clicking a type in the toolbox would put it - without putting it anywhere.
|
|
*
|
|
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
|
|
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
|
|
* the answer has to be the one the click will actually follow, or the two drift apart.
|
|
*
|
|
* The search goes from the inside out: into the selection when it is a container that takes
|
|
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
|
|
* level. Returns null when the type cannot go anywhere from this selection.
|
|
*
|
|
* @return {{parentId: string|null, index: number}|null}
|
|
*/
|
|
export function insertionTarget(document, selected, type) {
|
|
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
|
|
if (type === 'section') {
|
|
const top = blockPath(document.blocks, selected)[0];
|
|
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
|
|
}
|
|
const probe = { type };
|
|
let item = locate(document.blocks, selected);
|
|
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
|
|
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
|
|
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
|
while (item) {
|
|
const parentId = item.parent?.id ?? null;
|
|
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
|
|
item = item.parent ? locate(document.blocks, item.parent.id) : null;
|
|
}
|
|
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
|
|
}
|
|
|
|
export function insertBlock(document, type, selected) {
|
|
const target = insertionTarget(document, selected, type);
|
|
if (!target) return null;
|
|
return insertBlockAt(document, type, target.parentId, target.index);
|
|
}
|
|
|
|
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('-', '')}`;
|
|
delete block.anchor;
|
|
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;
|
|
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
|
|
// ein Akkordeoneintrag nicht.
|
|
if (!inside && !dropList(document, target.parent?.id ?? null, 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;
|
|
}
|
|
|
|
/**
|
|
* Where a block may land. `parentId` of null means the top level. Returns the list that would
|
|
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
|
|
* and nothing else, an accordion takes entries.
|
|
*/
|
|
export function dropList(document, parentId, block) {
|
|
if (parentId === null) {
|
|
const type = block?.type;
|
|
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
|
|
return loose ? null : document.blocks;
|
|
}
|
|
const parent = locate(document.blocks, parentId)?.block;
|
|
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
|
|
}
|
|
|
|
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
|
|
function place(document, block, parentId, index) {
|
|
const list = dropList(document, parentId, block);
|
|
if (!list) return null;
|
|
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
|
|
return block.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts a new block at an exact position instead of behind the selection.
|
|
*
|
|
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
|
|
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
|
|
* zu sehen.
|
|
*/
|
|
export function insertBlockAt(document, type, parentId, index) {
|
|
const block = createBlock(type);
|
|
if (!dropList(document, parentId, block)) return null;
|
|
upgradeForType(document, type);
|
|
return place(document, block, parentId, index);
|
|
}
|
|
|
|
/**
|
|
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
|
|
* block dragged between two others lands between them and not at the end of the container.
|
|
*/
|
|
export function moveBlockTo(document, selected, parentId, index) {
|
|
const source = locate(document.blocks, selected);
|
|
if (!source) return false;
|
|
// Dropping a container into itself or into its own child would detach the branch.
|
|
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
|
|
if (!dropList(document, parentId, source.block)) return false;
|
|
const from = source.siblings, fromIndex = source.index;
|
|
from.splice(fromIndex, 1);
|
|
// Re-read: removing the block may have shifted the target list or emptied a parent.
|
|
const list = dropList(document, parentId, source.block);
|
|
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
|
|
// The index still counts in the list as it was before the block was taken out, so the shift
|
|
// has to be settled first and only then clamped - the other way round an index pointing past
|
|
// the end is clamped to the old length and then pulled one step too far forward.
|
|
let at = index;
|
|
if (list === from && fromIndex < index) at -= 1;
|
|
at = Math.max(0, Math.min(at, list.length));
|
|
list.splice(at, 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 = 27;
|
|
|
|
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
|
|
|
export function setVisibility(document, id, value) {
|
|
if (!Object.hasOwn(VISIBILITY, value)) throw new Error('Unbekannte Sichtbarkeit.');
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block) return;
|
|
if (value === 'all') {
|
|
if (block.layout) delete block.layout.visibility;
|
|
} else {
|
|
upgradeDocument(document, 22);
|
|
(block.layout ??= {}).visibility = value;
|
|
}
|
|
}
|
|
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;
|
|
if (target?.type === 'image') {
|
|
upgradeDocument(document, 3);
|
|
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
|
|
return target.id;
|
|
}
|
|
let parent = target;
|
|
if (target?.type !== 'gallery') {
|
|
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
|
|
const id = insertBlock(document, 'gallery', selected);
|
|
if (id === null) return null;
|
|
parent = locate(document.blocks, id).block;
|
|
}
|
|
upgradeDocument(document, 3);
|
|
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;
|
|
}
|
|
|
|
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
|
|
function buildRow(columns) {
|
|
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'));
|
|
return row;
|
|
}
|
|
|
|
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
|
export function insertRowAt(document, columns, parentId, index) {
|
|
const row = buildRow(columns);
|
|
if (!dropList(document, parentId, row)) return null;
|
|
upgradeDocument(document);
|
|
return place(document, row, parentId, index);
|
|
}
|
|
|
|
export function insertRow(document, columns, selected) {
|
|
// Same placement rule as a single element, so the row does not land somewhere else than the
|
|
// toolbox promised.
|
|
const target = insertionTarget(document, selected, 'row');
|
|
if (!target) return null;
|
|
upgradeDocument(document);
|
|
return place(document, buildRow(columns), target.parentId, target.index);
|
|
}
|
|
|
|
/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */
|
|
const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) }));
|
|
|
|
export function insertPreset(document, name, selected) {
|
|
if (name === 'hero') return insertHero(document, selected);
|
|
if (PLUGIN_PRESETS[name]) {
|
|
const blocks = freshIds(PLUGIN_PRESETS[name].blocks);
|
|
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, ...blocks);
|
|
upgradeDocument(document, SCHEMA_VERSION);
|
|
return blocks[0].id;
|
|
}
|
|
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);
|
|
}
|
|
/** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */
|
|
adopt(document) {
|
|
this.past.push(this.document);
|
|
if (this.past.length > 100) this.past.shift();
|
|
this.document = clone(document);
|
|
this.future = [];
|
|
this.mergeKey = null;
|
|
}
|
|
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 scrolledStyle(block, device, key) {
|
|
return block?.style?.[device]?.scrolled?.[key];
|
|
}
|
|
|
|
export function setStyle(document, id, device, key, value, { side = null, hover = false, scrolled = 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);
|
|
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
|
|
|
block.style ??= {};
|
|
block.style[device] ??= {};
|
|
let target = block.style[device];
|
|
if (hover) { target.hover ??= {}; target = target.hover; }
|
|
// The scrolled state of the header: same shape as hover, applied once the page has moved.
|
|
if (scrolled) { target.scrolled ??= {}; target = target.scrolled; }
|
|
|
|
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;
|
|
}
|
|
|
|
/** UI relevance only. Existing values remain editable, including on older documents. */
|
|
export function relevantProperty(block, key, property) {
|
|
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined || values.scrolled?.[key] !== undefined)) return true;
|
|
const container = isContainer(block);
|
|
if (property.group === 'slider') return block.type === 'slider';
|
|
if (property.group === 'menu') return block.type === 'menu';
|
|
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
|
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
|
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
|
if (key === 'columnWidths') return block.type === 'row';
|
|
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
|
return true;
|
|
}
|