Drag the border between two columns to set their widths in 1.16.0
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

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>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-15 10:39:06 +02:00
1 parent f6350de636
commit 32ee28888e
14 files changed
+346 -3

No files matched your search

+120 -1
View File
@@ -35,7 +35,7 @@ const TOOLS = [
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
];
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
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');
@@ -205,6 +205,7 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
// 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) {
@@ -213,6 +214,124 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
}
}
// -------------------------------------------------------------------------------------------
// 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.
// -------------------------------------------------------------------------------------------
+18
View File
@@ -248,6 +248,23 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return wrap;
};
/**
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
*/
const fractionsControl = (id, value, apply) => {
const input = document.createElement('input');
input.type = 'text';
input.id = id;
input.value = value ?? '';
input.placeholder = 'gleich breit';
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
input.setAttribute('inputmode', 'decimal');
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
return input;
};
const numberControl = (id, value, property, apply) => {
const wrap = element('div', 'number-control');
const slider = document.createElement('input');
@@ -531,6 +548,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
+1
View File
@@ -574,6 +574,7 @@ export function relevantProperty(block, key, property) {
if (property.group === 'menu') return block.type === 'menu';
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (key === 'columnWidths') return block.type === 'row';
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
return true;
}
+7 -1
View File
@@ -291,8 +291,14 @@ html:has(>.editor-body){overflow:hidden;height:100%}
.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}
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
/* The border between two columns of the selected row: a grab area with a short bar in it. */
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display: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}
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* ---- narrow screens ---------------------------------------------------------------------- */
+7
View File
@@ -1347,6 +1347,13 @@ overlay = createCanvasOverlay({
if (!item) return;
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
},
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
// the whole row for the current device, one undo step per drag.
onResize: (rowId, value) => {
if (busy || !history) return;
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
changed();
},
onDrop: (payload, plan) => {
if (busy || !history) return;
if (payload.kind === 'add') {