- The site icon is no longer a select at the end of the company data. It has its own section: a button opens the media library (upload included), then a square is dragged, resized by its corner, a slider or +/-, and previewed at 180 and 32 px. The crop is stored as fractions (faviconCrop) and the PNGs are cut from it; faviconVersion changes the link so browsers pick up a new cut. Icons without a crop keep their old link. - openMedia() takes needsAlt: false, since the icon never appears as content. The dialog styles moved from editor.css to media-dialog.css so the dialog works outside the editor. - Privacy page and imprint are chosen with the content search, like the 404 page. The picker can store the path instead of the id (valueKey/data-value). - The page and post lists in the administration sort newest first; the subpage indentation is gone there, as it would point at the wrong parent. Tests: 581 PHPUnit, 35 node, test:editor green (the consent step now picks the privacy page by search). test:admin --admin-only fails only at the known nested-interactive menu summaries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
7.9 KiB
JavaScript
138 lines
7.9 KiB
JavaScript
import { openMedia } from './media.js';
|
||
|
||
/*
|
||
* The site icon in the settings: an image from the media library (uploads happen right there in
|
||
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
|
||
* left and top of the width and height, the edge of the shorter side - so it holds for every
|
||
* variant of the image, and the server cuts the PNG files from it when the form is saved.
|
||
*/
|
||
const field = document.querySelector('[data-favicon-field]');
|
||
if (field) setup(field);
|
||
|
||
function setup(field) {
|
||
const idInput = field.querySelector('[name="faviconMediaId"]');
|
||
const cropInput = field.querySelector('[name="faviconCrop"]');
|
||
const choose = field.querySelector('[data-favicon-choose]');
|
||
const remove = field.querySelector('[data-favicon-remove]');
|
||
const current = field.querySelector('.favicon-current');
|
||
const base = document.querySelector('meta[name="admin-base"]').content;
|
||
|
||
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
|
||
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
|
||
<div class="favicon-side">
|
||
<label for="favicon-size">Größe des Ausschnitts</label>
|
||
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
|
||
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
|
||
<div class="favicon-samples" aria-label="Vorschau">
|
||
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
|
||
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
|
||
</div>
|
||
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
|
||
</div>`;
|
||
field.after(editor);
|
||
const stage = editor.querySelector('.favicon-stage');
|
||
const image = stage.querySelector('img');
|
||
const frame = stage.querySelector('.favicon-frame');
|
||
const size = editor.querySelector('#favicon-size');
|
||
const large = editor.querySelector('.favicon-sample-large');
|
||
const small = editor.querySelector('.favicon-sample-small');
|
||
|
||
let crop = { x: 0, y: 0, s: 1 };
|
||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
|
||
|
||
/** Keep the square inside the picture, then show it and write it into the form. */
|
||
const apply = () => {
|
||
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
|
||
crop.s = clamp(crop.s, 0.02, 1);
|
||
const e = edge();
|
||
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
|
||
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
|
||
size.value = Math.round(crop.s * 100);
|
||
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
|
||
for (const canvas of [large, small]) {
|
||
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
|
||
context.imageSmoothingQuality = 'high';
|
||
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
|
||
}
|
||
};
|
||
const center = () => {
|
||
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
|
||
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
|
||
};
|
||
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
|
||
const resize = s => {
|
||
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
|
||
crop.s = clamp(s, 0.02, 1); const after = edge();
|
||
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
|
||
};
|
||
|
||
const load = (src, initial) => new Promise((resolve, reject) => {
|
||
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
|
||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||
image.src = src;
|
||
});
|
||
const largest = item => item.variants.at(-1).src;
|
||
|
||
const showCurrent = label => {
|
||
current.replaceChildren(); current.append(document.createTextNode(label));
|
||
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||
remove.hidden = !idInput.value;
|
||
};
|
||
|
||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
|
||
if (!items.length) return;
|
||
idInput.value = items[0].id; cropInput.value = '';
|
||
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
|
||
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
|
||
catch (error) { showCurrent(error.message); }
|
||
} }));
|
||
remove.addEventListener('click', () => {
|
||
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
|
||
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
|
||
});
|
||
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
|
||
size.addEventListener('input', () => resize(size.value / 100));
|
||
|
||
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
|
||
let drag = null;
|
||
frame.addEventListener('pointerdown', event => {
|
||
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
|
||
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
|
||
});
|
||
frame.addEventListener('pointermove', event => {
|
||
if (!drag) return;
|
||
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
|
||
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
|
||
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
|
||
else {
|
||
// The corner follows the larger of both movements, measured in image pixels.
|
||
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
|
||
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
|
||
crop.x = drag.crop.x; crop.y = drag.crop.y;
|
||
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
|
||
}
|
||
apply();
|
||
});
|
||
const stop = () => { drag = null; };
|
||
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
|
||
frame.addEventListener('keydown', event => {
|
||
const step = event.shiftKey ? 0.1 : 0.01;
|
||
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
||
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
|
||
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
|
||
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
|
||
});
|
||
|
||
// An icon that is already set opens with its picture and its cut-out.
|
||
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
|
||
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
|
||
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
|
||
.then(({ item }) => {
|
||
const parts = cropInput.value.split(',').map(Number);
|
||
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
}
|