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; } }; }