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>
This commit is contained in:
1 parent
af5a2cef86
commit
22e4ada830
10 files changed
+841
-27
No files matched your search
@@ -0,0 +1,351 @@
|
||||
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The layer above the preview.
|
||||
//
|
||||
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
|
||||
// whole document. Anything drawn inside it would therefore be wiped on the next keystroke and
|
||||
// would also end up in the markup we are supposed to be showing faithfully. So the outline, the
|
||||
// element toolbar, the insert points and the drop marker all live in the editor document, in an
|
||||
// absolutely positioned layer that sits exactly on top of the frame.
|
||||
//
|
||||
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
|
||||
// rectangle measured inside the frame can be used here unchanged. Measurements come from
|
||||
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
|
||||
// the preview only needs a re-measure and no offset arithmetic.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Diameter of a plus sign, in the editor's own pixels. */
|
||||
const INSERT_SIZE = 22;
|
||||
|
||||
/**
|
||||
* What the toolbar offers, in the order it appears.
|
||||
*
|
||||
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
|
||||
* sidebar carries the same five actions, and two controls with the same name on one screen are
|
||||
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
|
||||
* than the generic verb did: the toolbar floats next to the element, the announcement should
|
||||
* carry that context instead of assuming it was seen.
|
||||
*/
|
||||
const TOOLS = [
|
||||
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
|
||||
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
|
||||
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
|
||||
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
const hoverLabel = element('span', 'ov-label');
|
||||
hover.append(hoverLabel);
|
||||
const select = element('div', 'ov-box ov-select');
|
||||
const selectName = document.createElement('span');
|
||||
selectName.className = 'ov-toolbar-name';
|
||||
selectName.setAttribute('aria-hidden', 'true');
|
||||
const toolbar = element('div', 'ov-toolbar');
|
||||
toolbar.removeAttribute('aria-hidden');
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const insertBefore = insertButton('before', 'Element davor einfügen');
|
||||
const insertAfter = insertButton('after', 'Element dahinter einfügen');
|
||||
toolbar.append(selectName);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
|
||||
button.dataset.action = tool.action;
|
||||
button.textContent = tool.glyph;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onAction(tool.action, getSelected()));
|
||||
toolbar.append(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
|
||||
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
|
||||
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
|
||||
*/
|
||||
function element(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.hidden = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
function insertButton(where, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-insert';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
button.textContent = '+';
|
||||
button.hidden = true;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onInsert(getSelected(), where));
|
||||
return button;
|
||||
}
|
||||
|
||||
let hovered = null, dragging = null, dropPlan = null;
|
||||
|
||||
const doc = () => frame.contentDocument;
|
||||
/** Is this id a block of the current document rather than some id in the markup? */
|
||||
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
|
||||
|
||||
/** The nearest element that stands for a block of the document. */
|
||||
function blockElement(node) {
|
||||
for (let current = node; current && current !== doc(); current = current.parentElement) {
|
||||
if (current.id && known(current.id)) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectOf(id) {
|
||||
const node = id ? doc()?.getElementById(id) : null;
|
||||
if (!node) return null;
|
||||
const box = node.getBoundingClientRect();
|
||||
return box.width || box.height ? box : null;
|
||||
}
|
||||
|
||||
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
|
||||
function place(node, box, inset = 0) {
|
||||
if (!box) { node.hidden = true; return false; }
|
||||
node.hidden = false;
|
||||
node.style.left = `${box.left + inset}px`;
|
||||
node.style.top = `${box.top + inset}px`;
|
||||
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
|
||||
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
|
||||
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
|
||||
* would fall under the size a finger or an eye can deal with.
|
||||
*/
|
||||
function counterScale(node, originX, originY) {
|
||||
const scale = getScale();
|
||||
node.style.transformOrigin = `${originX} ${originY}`;
|
||||
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
|
||||
}
|
||||
|
||||
function positionToolbar(box, hasInsert) {
|
||||
if (!box) { toolbar.hidden = true; return; }
|
||||
toolbar.hidden = false;
|
||||
const scale = getScale();
|
||||
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
|
||||
// inside the frame's coordinates shrinks as the preview shrinks.
|
||||
const needed = toolbar.offsetHeight / scale;
|
||||
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
|
||||
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
|
||||
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
|
||||
const above = box.top >= needed + clearance + 2;
|
||||
toolbar.style.left = `${box.left}px`;
|
||||
toolbar.style.top = `${above
|
||||
? box.top - needed - clearance - 2
|
||||
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
|
||||
counterScale(toolbar, 'left', 'top');
|
||||
}
|
||||
|
||||
function positionInsert(button, box, where) {
|
||||
if (!box) { button.hidden = true; return; }
|
||||
const scale = getScale();
|
||||
const size = INSERT_SIZE / scale;
|
||||
button.hidden = false;
|
||||
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
|
||||
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
|
||||
button.style.width = `${size}px`;
|
||||
button.style.height = `${size}px`;
|
||||
counterScale(button, 'center', 'center');
|
||||
}
|
||||
|
||||
/** Renames the toolbar for whatever is selected right now. */
|
||||
function nameTools(id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
const label = block ? blockLabel(block) : 'Element';
|
||||
selectName.textContent = label;
|
||||
for (const button of toolbar.children) {
|
||||
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
|
||||
if (!tool) continue;
|
||||
button.title = tool.title(label);
|
||||
button.setAttribute('aria-label', tool.title(label));
|
||||
}
|
||||
}
|
||||
|
||||
function paintLabel(node, id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
node.textContent = block ? blockLabel(block) : '';
|
||||
counterScale(node, 'left', 'bottom');
|
||||
}
|
||||
|
||||
/** Re-measures everything. Cheap enough to call on every scroll frame. */
|
||||
function sync() {
|
||||
const scale = getScale();
|
||||
layer.style.width = `${frame.clientWidth}px`;
|
||||
layer.style.height = `${frame.clientHeight}px`;
|
||||
layer.style.transform = `scale(${scale})`;
|
||||
if (!doc()) return;
|
||||
const selectedId = getSelected();
|
||||
const selectedBox = rectOf(selectedId);
|
||||
place(select, selectedBox);
|
||||
if (selectedBox) nameTools(selectedId);
|
||||
// Insert points only make sense for a block that has somewhere to sit next to.
|
||||
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
|
||||
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
|
||||
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
|
||||
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) paintDrop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** The child elements of a block, in document order, that stand for its children. */
|
||||
function childElements(id) {
|
||||
const block = id ? locate(getDocument().blocks, id)?.block : null;
|
||||
const children = id ? (block?.children ?? []) : getDocument().blocks;
|
||||
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land inside a container. Children that sit side by side - the columns of
|
||||
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
|
||||
* so the insertion line always runs across the gap the pointer is actually in.
|
||||
*/
|
||||
function indexInside(parentId, x, y) {
|
||||
const kids = childElements(parentId);
|
||||
if (!kids.length) return { index: 0, line: null };
|
||||
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
|
||||
for (let index = 0; index < kids.length; index++) {
|
||||
const box = kids[index].box;
|
||||
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
|
||||
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
|
||||
}
|
||||
const last = kids[kids.length - 1].box;
|
||||
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
|
||||
* inside the hovered block when it is a container that takes this kind of child, otherwise
|
||||
* next to it, otherwise try the same two answers one level up.
|
||||
*/
|
||||
function planDrop(x, y, block) {
|
||||
const document_ = getDocument();
|
||||
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
|
||||
const start = blockElement(doc()?.elementFromPoint(x, y));
|
||||
for (let node = start; ; ) {
|
||||
const id = node?.id ?? null;
|
||||
const item = id ? locate(document_.blocks, id) : null;
|
||||
// Never offer to drop a block into itself or into its own subtree.
|
||||
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
|
||||
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
|
||||
const inside = indexInside(id, x, y);
|
||||
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
|
||||
}
|
||||
const parentId = item?.parent?.id ?? null;
|
||||
if (id && !ownBranch && dropList(document_, parentId, block)) {
|
||||
const box = rectOf(id);
|
||||
const after = box ? y > box.top + box.height / 2 : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: after ? 'bottom' : 'top' } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
if (!node) {
|
||||
// Fell out of the tree: the top level is the last thing left to try.
|
||||
if (!dropList(document_, null, block)) break;
|
||||
const inside = indexInside(null, x, y);
|
||||
return { parentId: null, index: inside.index, line: inside.line };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
|
||||
return;
|
||||
}
|
||||
const vertical = edge === 'left' || edge === 'right';
|
||||
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
|
||||
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
|
||||
drop.style.width = `${vertical ? thickness : box.width}px`;
|
||||
drop.style.height = `${vertical ? box.height : thickness}px`;
|
||||
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
|
||||
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
|
||||
}
|
||||
|
||||
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
|
||||
function attach() {
|
||||
const document_ = doc();
|
||||
if (!document_) return;
|
||||
document_.addEventListener('mousemove', event => {
|
||||
if (dragging) return;
|
||||
const node = blockElement(event.target);
|
||||
const id = node?.id ?? null;
|
||||
if (id === hovered) return;
|
||||
hovered = id;
|
||||
sync();
|
||||
});
|
||||
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
|
||||
document_.addEventListener('scroll', sync, { passive: true, capture: true });
|
||||
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
|
||||
// Inside the frame an event already carries frame coordinates, and those are exactly the
|
||||
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
|
||||
// the editor viewport and back through the scale factor would add.
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
paintDrop();
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = dropPlan;
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
@@ -10,10 +10,13 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
// 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');
|
||||
&& (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) {
|
||||
@@ -59,8 +62,8 @@ export function createBlock(type) {
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
/** 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);
|
||||
@@ -72,6 +75,11 @@ export function insertBlock(document, type, selected) {
|
||||
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);
|
||||
@@ -109,6 +117,55 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
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.
|
||||
|
||||
@@ -209,3 +209,40 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.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}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The working layer above the preview.
|
||||
|
||||
It sits in the editor document, not in the preview, and carries the same scale transform as
|
||||
the frame - so a rectangle measured inside the frame can be used here without conversion. The
|
||||
layer itself lets the mouse through; only the controls on it catch a click, and each of them
|
||||
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
|
||||
.ov-box{position:absolute;pointer-events:none}
|
||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||
.ov-label{background:#5c6b62;font-weight:600}
|
||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||
.ov-tool:hover:not(:disabled){background:#3b4c42}
|
||||
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
|
||||
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
|
||||
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
|
||||
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
|
||||
#library button[draggable=true]{cursor:grab}
|
||||
#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)}
|
||||
|
||||
/* 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}}
|
||||
+117
-12
@@ -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(); });
|
||||
|
||||
Reference in new issue
Block a user