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 = `

Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.

Startbildschirm
Browser-Reiter
`; 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(() => {}); } }