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>
352 lines
16 KiB
JavaScript
352 lines
16 KiB
JavaScript
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; } };
|
|
}
|