Files
kantarciandClaude Fable 5.1 32ee28888e
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
Drag the border between two columns to set their widths in 1.16.0
Umut, with a card of an icon next to a text as the example: "bei Rows die
Breite ändern, am besten per Drag and Drop im Editor". Rows only ever had
equal columns - both the classic `columns` token and the free `gridColumns`
produce `repeat(n, 1fr)`.

A row now carries the free style `columnWidths`, a list of shares such as
`2 1` or `66.7 33.3`, per device and without a new document schema.
`StyleCompiler::columnTracks()` turns it into `grid-template-columns`,
pads or cuts the shares to the number of columns, and gives a device whose
layout stacks the columns a single track, because the desktop rule carries
an id and would otherwise outrank the class that stacks a phone.

In the editor the border between two side-by-side columns of the selected
row is a handle: dragging restyles the row inline while the pointer moves
and writes one undo step on release; the arrow keys nudge by five points.
The design panel shows the same value as the text field "Spaltenbreiten".
The Playwright editor check drags the handle with the real mouse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:39:06 +02:00

507 lines
23 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. The selection tools therefore live outside it and get rebound after a refresh.
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
// The outline, element toolbar, insert points and drop marker 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, onResize = () => {} }) {
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 dropLabel = element('div', 'ov-drop-label');
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, dropLabel);
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));
placeResizers();
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
if (dragging) {
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
paintDrop();
}
}
// -------------------------------------------------------------------------------------------
// Column widths. Umut: "am besten per Drag and Drop im Editor".
//
// The border between two columns of the selected row is a handle. Dragging it moves share from
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
// result is written to the document once, when the pointer is released. The arrow keys do the
// same in steps of five, so the widths are reachable without a mouse.
// -------------------------------------------------------------------------------------------
const resizers = [];
let focusResizer = null;
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
function resizableRow() {
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
if (!item) return null;
if (item.block.type === 'row') return item.block;
return item.parent?.type === 'row' ? item.parent : null;
}
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
function sharesOf(rowId) {
const kids = childElements(rowId);
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
}
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
function shift(shares, index, delta) {
const pair = shares[index] + shares[index + 1];
const least = Math.min(10, pair / 2);
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
const next = shares.slice();
next[index] = left;
next[index + 1] = pair - left;
return next;
}
const tenth = share => Math.round(share * 10) / 10;
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
function placeResizers() {
const row = dragging ? null : resizableRow();
const kids = row ? childElements(row.id) : [];
// Stacked columns - a phone, or a single-column layout - have no border to drag.
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
const wanted = sideBySide ? kids.length - 1 : 0;
while (resizers.length > wanted) resizers.pop().remove();
while (resizers.length < wanted) resizers.push(resizeButton());
const width = 14 / getScale();
resizers.forEach((button, index) => {
const left = kids[index].box, right = kids[index + 1].box;
button.dataset.row = row.id;
button.dataset.index = String(index);
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
button.setAttribute('aria-label', label);
button.title = `${label}: ziehen oder Pfeiltasten`;
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
button.style.top = `${Math.min(left.top, right.top)}px`;
button.style.width = `${width}px`;
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
});
}
function resizeButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ov-resize';
button.addEventListener('pointerdown', startResize);
button.addEventListener('keydown', event => {
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
if (!step) return;
event.preventDefault();
const rowId = button.dataset.row, index = Number(button.dataset.index);
const shares = sharesOf(rowId);
if (!shares) return;
focusResizer = { row: rowId, index };
onResize(rowId, formatShares(shift(shares, index, step)));
});
layer.append(button);
return button;
}
function startResize(event) {
if (event.button !== 0) return;
const button = event.currentTarget;
const rowId = button.dataset.row, index = Number(button.dataset.index);
const start = sharesOf(rowId);
const node = doc()?.getElementById(rowId);
if (!start || !node) return;
event.preventDefault();
button.setPointerCapture(event.pointerId);
layer.classList.add('is-resizing');
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
// width of the columns without the gaps between them.
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
const startX = event.clientX;
let current = start;
const move = moved => {
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
node.style.gridTemplateColumns = tracksOf(current);
sync();
};
const finish = () => {
button.removeEventListener('pointermove', move);
button.removeEventListener('pointerup', finish);
button.removeEventListener('pointercancel', finish);
layer.classList.remove('is-resizing');
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
else sync();
};
button.addEventListener('pointermove', move);
button.addEventListener('pointerup', finish);
button.addEventListener('pointercancel', finish);
}
// -------------------------------------------------------------------------------------------
// 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 horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (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; dropLabel.hidden = true; return; }
drop.hidden = false;
const scale = getScale();
const thickness = 3 / scale;
const { box, edge } = line;
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
dropLabel.hidden = false;
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
counterScale(dropLabel, 'left', 'top');
drop.style.marginLeft = drop.style.marginTop = '0';
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();
dragPoint = { x: event.clientX, y: event.clientY };
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
paintDrop();
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
});
document_.addEventListener('dragleave', event => {
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
});
document_.addEventListener('drop', event => {
if (!dragging) return;
event.preventDefault();
// Both are read before the clean-up clears them.
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
endDrag();
if (plan) onDrop(payload, plan);
});
sync();
}
function startDrag(payload) {
dragging = payload;
layer.classList.add('is-dragging');
doc()?.documentElement.classList.add('editor-dragging');
hovered = null;
sync();
}
function endDrag() {
dragging = null;
dragPoint = null;
cancelAnimationFrame(scrollFrame); scrollFrame = null;
dropPlan = null;
drop.hidden = true;
dropLabel.hidden = true;
layer.classList.remove('is-dragging');
doc()?.documentElement.classList.remove('editor-dragging');
sync();
}
let dragPoint = null, scrollFrame = null;
function scrollDrag() {
scrollFrame = null;
if (!dragging || !dragPoint) return;
const edge = 60 / getScale();
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
if (amount) {
doc()?.defaultView?.scrollBy(0, amount / getScale());
sync();
scrollFrame = requestAnimationFrame(scrollDrag);
}
}
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
}