Schema v25: a button takes an optional icon (any name from the shared set or "none") and an icon position (start or end). Without a choice it keeps the diagonal arrow behind the text, still with the button-arrow class, so stored pages and their own CSS look exactly as before. Thirteen everyday icons join the set (arrow down, chevron, calendar, message, send, download, document, play, search, heart, info, plus, person). Button, icon and icon card choose from a tile picker with a drawing per icon, grouped, keyboard navigable, instead of a select with English keys. icons.js holds the paths for the editor; a test keeps them equal to the Twig drawings and the validator list. ADR 108. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
694 lines
36 KiB
JavaScript
694 lines
36 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' };
|
|
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: '</>' };
|
|
// 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', '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' };
|
|
// 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);
|
|
walk(block.children);
|
|
}
|
|
};
|
|
walk(document.blocks);
|
|
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 (['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 = 25;
|
|
|
|
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 === '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;
|
|
}
|