Fassung 1.5.0: die Vorschau wird zur Arbeitsflaeche
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

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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-14 06:53:23 +02:00
1 parent af5a2cef86
commit 22e4ada830
10 files changed
+841 -27

No files matched your search

+117 -12
View File
@@ -1,7 +1,8 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, 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, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
import { createCanvasOverlay } from './canvas-overlay.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -17,6 +18,9 @@ const mayScript = 'true' === root.dataset.canScript;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
// same factor, so it is kept here instead of being recomputed from the transform string.
let previewScale = 1, overlay = null, pendingInsert = null;
const collapsed = new Set();
function notify(message = '', recover = false) {
@@ -121,6 +125,7 @@ function highlight(scroll = false) {
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
const element = selected ? doc.getElementById(selected) : null;
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
overlay?.sync();
}
function renderProperties() {
@@ -545,9 +550,11 @@ function resizePreview() {
const available = Math.max(100, container.clientWidth - padding);
const scale = Math.min(1, available / width);
const height = Math.max(550, (container.clientHeight - padding) / scale);
previewScale = scale;
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
frame.title = `Vorschau ${devices[device]} · ${width} px`;
overlay?.sync();
}
function schedulePreview() {
@@ -606,10 +613,12 @@ async function updatePreview() {
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
doc.addEventListener('submit', event => event.preventDefault());
// Every refresh is a new document, so the layer above it has to bind to it again.
overlay?.attach();
frame.contentWindow.scrollTo(0, top); highlight();
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
frame.style.visibility = '';
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
$('#preview-status').textContent = 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.';
};
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
frame.style.visibility = 'hidden';
@@ -692,11 +701,36 @@ async function save(publish = false) {
finally { busy = false; controls(); }
}
/**
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
* selection. With one, set by the plus signs in the preview, it lands exactly there.
*/
function addElement(type) {
if (busy || !history) return;
const at = pendingInsert;
setPendingInsert(null);
selected = history.change(doc => at
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
: insertBlock(doc, type, selected));
changed();
}
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
function setPendingInsert(target) {
pendingInsert = target;
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
$('#insert-hint').hidden = !target;
if (target) {
if ($('#toolbox').hidden) $('#toggle-library').click();
$('#toolbox').scrollIntoView({ block: 'nearest' });
}
}
$('#library').addEventListener('click', event => {
const type = event.target.closest('[data-add]')?.dataset.add;
if (!type || busy || !history) return;
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
if (type) addElement(type);
});
$('#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;
@@ -729,8 +763,13 @@ document.querySelectorAll('[data-panel]').forEach(button => button.addEventListe
$('#structure').addEventListener('dragstart', event => {
if (busy) { event.preventDefault(); return; }
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
if (!id) return;
event.dataTransfer.setData('text/plain', id);
event.dataTransfer.effectAllowed = 'move';
const block = locate(history.document.blocks, id)?.block;
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
});
$('#structure').addEventListener('dragend', () => overlay.endDrag());
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
$('#structure').addEventListener('drop', event => {
event.preventDefault(); if (busy) return;
@@ -740,17 +779,83 @@ $('#structure').addEventListener('drop', event => {
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
selected = source; changed();
});
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
$('#delete').addEventListener('click', () => {
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
});
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
});
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
// into the same functions, so the two can never drift apart.
const actions = {
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
delete: id => {
selected = history.change(doc => {
const item = locate(doc.blocks, id);
if (!item) return null;
item.siblings.splice(item.index, 1);
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
});
changed();
},
move: (id, direction) => {
history.change(doc => {
const item = locate(doc.blocks, id);
const next = item.index + direction;
if (next < 0 || next >= item.siblings.length) return;
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
});
selected = id; changed();
},
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
};
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
$('#delete').addEventListener('click', () => actions.delete(selected));
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
$('#outdent').addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
});
// ---------------------------------------------------------------------------------------------
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
// ---------------------------------------------------------------------------------------------
overlay = createCanvasOverlay({
stage: $('#preview-stage'),
frame: $('#preview'),
getScale: () => previewScale,
getDocument: () => history.document,
getSelected: () => selected,
onAction: (action, id) => {
if (!id || busy || !history) return;
if (action === 'up') actions.move(id, -1);
else if (action === 'down') actions.move(id, 1);
else actions[action]?.(id);
},
onInsert: (id, where) => {
const item = locate(history.document.blocks, id);
if (!item) return;
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
},
onDrop: (payload, plan) => {
if (busy || !history) return;
if (payload.kind === 'add') {
const id = history.change(doc => insertBlockAt(doc, payload.type, plan.parentId, plan.index));
if (id) { selected = id; changed(); }
return;
}
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
if (moved) { selected = moved; changed(); }
},
});
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
// 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; }
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', button.dataset.add);
// 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 } });
});
$('#library').addEventListener('dragend', () => overlay.endDrag());
$('#select-page').addEventListener('click', () => choose(null));
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
$('#redo').addEventListener('click', () => { history.redo(); changed(); });