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) {
|
||||
|
||||
+23
-12
@@ -50,10 +50,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.panel-section .eyebrow{margin-bottom:10px}
|
||||
.panel-section h2{font-size:14px;margin-bottom:12px}
|
||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
|
||||
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
|
||||
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
|
||||
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
|
||||
.library button span{font-size:19px;line-height:1}
|
||||
.panel-title{display:flex;justify-content:space-between;align-items:center}
|
||||
.panel-title h2{margin-bottom:0}
|
||||
.panel-title+.panel-hint{margin-top:10px}
|
||||
@@ -188,9 +184,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.editor-page-name h1{font-size:14px}
|
||||
.panel-section h2{font-size:13px;letter-spacing:0}
|
||||
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
|
||||
.library{gap:8px}
|
||||
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
|
||||
.library button span{font-size:20px}
|
||||
.tree-item{font-size:12px;border-radius:6px}
|
||||
.right-panel label{font-size:12px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
|
||||
@@ -204,10 +197,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.canvas-panel{background:#e7ebe5}
|
||||
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
|
||||
.icon-button{border-radius:8px}
|
||||
.row-library{display:grid;gap:6px;margin-bottom:14px}
|
||||
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
|
||||
.row-library button:hover:not(:disabled){border-color:var(--accent)}
|
||||
.row-library span{letter-spacing:-2px;font-size:13px}
|
||||
#design-search{font-size:12px;min-height:36px;padding:9px}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
@@ -241,8 +230,30 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
#library button[draggable=true]:active{cursor:grabbing}
|
||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
|
||||
.insert-hint .text-button{margin-left:4px}
|
||||
#toolbox.awaiting-insert .library button[data-add]{box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
|
||||
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
|
||||
fade on the insert points is a transition and is switched off with the rest. */
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The toolbox, grouped the way a page is built.
|
||||
|
||||
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
|
||||
element would land is disabled and carries the reason in its title, so the rule is learned
|
||||
rather than merely enforced.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.library-group + .library-group{margin-top:16px}
|
||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
|
||||
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
|
||||
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.library-buttons button{display:flex;align-items:center;gap:7px;text-align:left;min-height:38px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
||||
.library-buttons button>span:first-child{flex:none;width:14px;text-align:center;font-size:13px;color:var(--accent)}
|
||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
|
||||
.library-buttons button:disabled>span:first-child{color:var(--muted)}
|
||||
/* A row is offered by column count, so its glyph shows the count - and needs the room for it,
|
||||
instead of the fixed single-glyph width the other entries share. */
|
||||
.library-buttons button>span.library-row-glyph{width:auto;letter-spacing:0;font-size:10px;color:var(--accent)}
|
||||
.library-buttons button[data-row]{grid-column:span 2}
|
||||
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
|
||||
+86
-15
@@ -1,4 +1,4 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -65,8 +65,7 @@ function controls() {
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
}
|
||||
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
@@ -718,6 +717,7 @@ function addElement(type) {
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
updateLibrary();
|
||||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||||
$('#insert-hint').hidden = !target;
|
||||
if (target) {
|
||||
@@ -726,17 +726,84 @@ function setPendingInsert(target) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The toolbox.
|
||||
//
|
||||
// It is built here rather than listed in the template because it has to answer a question that
|
||||
// only the current selection can answer: what fits where the next element would go. A flat list
|
||||
// of every type offered a row on an empty page and a column next to nothing - the structure the
|
||||
// editor describes in its own hint was not the structure the buttons followed.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function buildLibrary() {
|
||||
const library = $('#library');
|
||||
library.replaceChildren();
|
||||
for (const category of CATEGORIES) {
|
||||
const group = document.createElement('div'); group.className = 'library-group';
|
||||
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
|
||||
group.append(heading);
|
||||
if (category.hint) {
|
||||
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
|
||||
group.append(hint);
|
||||
}
|
||||
const list = document.createElement('div'); list.className = 'library-buttons';
|
||||
for (const type of category.types) {
|
||||
// A row without columns is an empty box, so it is offered by column count instead - the
|
||||
// shape people actually want when they reach for a row.
|
||||
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.draggable = true;
|
||||
button.dataset.add = type;
|
||||
if (columns) button.dataset.row = String(columns);
|
||||
const glyph = document.createElement('span'); glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = columns ? '▥'.repeat(Math.min(columns, 4)) : icons[type];
|
||||
if (columns) glyph.className = 'library-row-glyph';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
||||
button.append(glyph, text);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
group.append(list);
|
||||
library.append(list.children.length ? group : document.createComment('leer'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greys out what cannot go where the next element would land, and says where it will land when
|
||||
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
|
||||
*/
|
||||
function updateLibrary() {
|
||||
const ready = Boolean(history);
|
||||
for (const button of $('#library').querySelectorAll('button')) {
|
||||
const type = button.dataset.add;
|
||||
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
||||
const target = pendingInsert
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
}
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||
if (type) addElement(type);
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled) return;
|
||||
if (button.dataset.row) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
const id = history.change(doc => at
|
||||
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
|
||||
: insertRow(doc, button.dataset.row, selected));
|
||||
if (id) { selected = id; propertyPanel = 'design'; changed(); }
|
||||
return;
|
||||
}
|
||||
addElement(button.dataset.add);
|
||||
});
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
// A row with columns, in one click: the piece people actually look for when they want a row.
|
||||
$('#rows').addEventListener('click', event => {
|
||||
const columns = event.target.closest('[data-row]')?.dataset.row;
|
||||
if (!columns || busy || !history) return;
|
||||
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
@@ -834,7 +901,9 @@ overlay = createCanvasOverlay({
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
return;
|
||||
}
|
||||
@@ -847,12 +916,13 @@ overlay = createCanvasOverlay({
|
||||
// toolbox remains usable with the keyboard alone.
|
||||
$('#library').addEventListener('dragstart', event => {
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || busy || !history) { event.preventDefault(); return; }
|
||||
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
|
||||
const type = button.dataset.add;
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('text/plain', button.dataset.add);
|
||||
event.dataTransfer.setData('text/plain', type);
|
||||
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
|
||||
// one does not exist until it lands.
|
||||
overlay.startDrag({ kind: 'add', type: button.dataset.add, block: { type: button.dataset.add } });
|
||||
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
|
||||
});
|
||||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||||
|
||||
@@ -907,4 +977,5 @@ window.addEventListener('keydown', event => {
|
||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||
});
|
||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||
buildLibrary();
|
||||
load();
|
||||
Reference in new issue
Block a user