388 lines
17 KiB
JavaScript
388 lines
17 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 }) {
|
|
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));
|
|
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();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------------
|
|
// 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; } };
|
|
}
|