Files
KantSites/public/assets/editor-model.js
T
kantarciandClaude Opus 5 22e4ada830
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.5.0: die Vorschau wird zur Arbeitsflaeche
Abgleich mit dem Flatsome UX Builder (docs/FLATSOME-ABGLEICH.md): die Gestaltung
ist Flatsome voraus, die Luecken liegen beim Elementkatalog und bei der
Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.

Erste Haelfte der Bedienung:

- Werkzeugleiste am Element in der Vorschau. Sie nennt das Element, auf das sie
  wirkt - die Seitenleiste hat dieselben fuenf Aktionen, und zwei gleichnamige
  Bedienelemente auf einem Bildschirm sind fuer eine Sprachausgabe nicht
  unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
- Auswahl- und Ueberfahrrahmen mit Beschriftung.
- Einfuegepunkte an Ober- und Unterkante: das naechste Element landet dort
  statt am Ende.
- Ziehen aus dem Werkzeugkasten in die Vorschau, mit Ablegemarke zwischen den
  Elementen und Rahmen um einen aufnehmenden Behaelter. Der Strukturbaum zieht
  in dieselbe Flaeche.

Die Ebene liegt ueber dem Rahmen, nicht darin: die Vorschau wird bei jeder
Aenderung ersetzt, eingezeichnete Bedienelemente waeren weg und stuenden in der
Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Leiste und
Pluszeichen werden gegenskaliert und bleiben in der Mobilvorschau fingergross.

Im Modell neu: insertBlockAt, moveBlockTo, dropList.

Dabei behoben: die Ebene war aria-hidden und enthielt fokussierbare
Schaltflaechen; canContain zerbrach an einem fehlenden Block beim Ziehen aus
dem Werkzeugkasten; beim Verschieben ans Listenende wurde der Index begrenzt,
bevor die Entnahme verrechnet war.

Geprueft: npm test 22/22, phpunit 434/434, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:53:23 +02:00

451 lines
23 KiB
JavaScript

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' };
/** Types DocumentValidator refuses outside their group: an entry without its accordion, a tab
without its tab group. An image is not one of them - it stands on its own anywhere. */
export const NEEDS_GROUP = ['accordionitem', '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;
}
/** Raises the document schema to the version that knows this block type. */
export function upgradeForType(document, 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);
}
export function insertBlock(document, type, selected) {
const block = createBlock(type);
upgradeForType(document, type);
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;
}
/**
* 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) {
// The top level mirrors DocumentValidator: everything is allowed there except the two types
// that only ever exist inside their group. Being stricter here than the validator would make
// dragging refuse what the buttons happily insert.
if (parentId === null) return NEEDS_GROUP.includes(block?.type) ? null : document.blocks;
const parent = locate(document.blocks, parentId)?.block;
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
}
/** Inserts a new block at an exact position instead of behind the selection. */
export function insertBlockAt(document, type, parentId, index) {
const block = createBlock(type);
upgradeForType(document, type);
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;
}
/**
* 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 = 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;
}