Fassung 1.5.1: der Werkzeugkasten kennt den Zusammenhang
Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als waeren sie Seitenanfaenge. Sie gehoeren in den Inhalt - erst eine Sektion, dann eine Zeile darin. Das war nicht nur eine Anzeigefrage: eine Zeile nahm bisher jedes Kind an, eine Ueberschrift konnte neben den Spalten landen, und eine Spalte durfte ueberall stehen. Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, galt fuer die Schaltflaechen nicht. Uebernommen ist Flatsomes Regelmodell (Elements.php): ALLOW - eine Zeile nimmt nur Spalten; REQUIRE - eine Spalte lebt nur in einer Zeile. Dazu ein Zusatz, den Flatsome nicht hat: ROOT_NEEDS_CONTAINER - eine Zeile ist kein Seitenanfang. In jedem Behaelter bleibt sie erlaubt, auch in einem Tab. Daraus die Bedienung: - Werkzeugkasten nach Aufbau gruppiert statt flacher Liste aus 31 Typen. - Was nicht hingehoert, ist gesperrt und sagt beim Ueberfahren warum. Eine Regel, die man erklaert bekommt, lernt man; eine Schaltflaeche, die nur nicht mehr reagiert, drueckt man weiter. - Was moeglich ist, sagt wohin es kommt. - Die Zeile wird nach Spaltenzahl angeboten und bringt ihre Spalten mit; der separate Zeilenbereich ist entfallen. - insertionTarget beantwortet "wohin kaeme das?", ohne etwas einzufuegen. Knopf, Einfuegepunkt, Ziehen und die Sperrlogik nutzen dieselbe Funktion - vorher entschied insertBlock allein, und insertRow hatte eine zweite Kopie derselben Platzierungslogik. Ein Element, das der gewaehlte Behaelter nicht nimmt, wird nicht gesperrt, sondern steigt auf, bis ein Elternteil es annimmt. Nebenbei behoben: postnav fehlte seit Schema v18 in labels und icons, der Strukturbaum zeigte dafuer "Unbekannt" und ein Fragezeichen. Geprueft: npm test 27/27, phpunit 446/446, npm run test:editor vollstaendig mit neuem Abschnitt und ohne axe-Verstoss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
22e4ada830
commit
0e2334d30f
8 files changed
+397
-74
No files matched your search
+117
-32
@@ -1,7 +1,34 @@
|
||||
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: '⇄' };
|
||||
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'};
|
||||
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: '⇄' };
|
||||
// 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', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
];
|
||||
|
||||
/** 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}$/;
|
||||
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');
|
||||
@@ -9,14 +36,37 @@ 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'];
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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. */
|
||||
export const REQUIRE = { column: ['row'], 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'];
|
||||
|
||||
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');
|
||||
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].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) {
|
||||
@@ -77,16 +127,35 @@ export function upgradeForType(document, type) {
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
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 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;
|
||||
const target = insertionTarget(document, selected, type);
|
||||
if (!target) return null;
|
||||
return insertBlockAt(document, type, target.parentId, target.index);
|
||||
}
|
||||
|
||||
export function duplicateBlock(document, selected) {
|
||||
@@ -123,24 +192,29 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** 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. */
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), 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.
|
||||
@@ -284,18 +358,29 @@ export function insertHero(document, selected) {
|
||||
return section.id;
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
/** 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) {
|
||||
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;
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// 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');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
|
||||
Reference in new issue
Block a user