// Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste. import { MAX_ZOOM, MIN_ZOOM } from '../card/config'; import { CARD_H, CARD_W } from '../card/geometry'; import { el, icon } from './dom'; import { ACCEPTED_TYPES } from './upload'; export interface PreviewHandlers { onFile(file: File): void; /** Verschiebung in Karten-px (1536 x 969), nicht in CSS-px. */ onPan(dx: number, dy: number): void; onZoom(zoom: number): void; onDownload(): void; onSave(): void; } export interface PreviewView { hasImage: boolean; zoom: number; saving: boolean; /** true für kurze Zeit nach erfolgreichem Speichern (Knopf zeigt „Gespeichert“). */ saved: boolean; exporting: boolean; } export interface Preview { el: HTMLElement; canvas: HTMLCanvasElement; sync(view: PreviewView): void; setDragging(active: boolean): void; setError(message: string | null): void; setWarning(message: string | null): void; /** Neutraler Hinweis, z. B. „Karte wird geladen …“. */ setNotice(message: string | null): void; } const NO_IMAGE_HINT = 'Erst ein Bild wählen'; const SAVE_LABEL = 'Speichern'; const SAVING_LABEL = 'Speichert …'; const SAVED_LABEL = 'Gespeichert'; const KEY_STEP = 24; const KEY_STEP_BIG = 96; const KEY_ZOOM_STEP = 0.1; /** Nimmt Dateien per Ziehen und Ablegen an `target` an. Auch Ablagen neben der Fläche sollen das Bild laden, statt die Seite zu verlassen. */ export function attachFileDrop( target: HTMLElement, onFile: (file: File) => void, onActive: (active: boolean) => void, ): void { let depth = 0; const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes('Files'); target.addEventListener('dragenter', (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth += 1; onActive(true); }); target.addEventListener('dragover', (e) => { if (!hasFiles(e)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }); target.addEventListener('dragleave', (e) => { if (!hasFiles(e)) return; depth = Math.max(0, depth - 1); if (depth === 0) onActive(false); }); target.addEventListener('drop', (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth = 0; onActive(false); const file = e.dataTransfer?.files[0]; if (file) onFile(file); }); } export function createPreview(handlers: PreviewHandlers): Preview { let zoom = MIN_ZOOM; const input = el('input', { type: 'file', accept: ACCEPTED_TYPES.join(','), hidden: true, 'aria-label': 'Bilddatei wählen', }); const openPicker = () => input.click(); input.addEventListener('change', () => { const file = input.files?.[0]; // Zurücksetzen, damit dieselbe Datei später noch einmal gewählt werden kann. input.value = ''; if (file) handlers.onFile(file); }); // Ablagefläche (ohne Bild) const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, 'Bild auswählen'); const dropzone = el( 'div', { class: 'dropzone' }, icon('imagePlus', 'dropzone-icon'), el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'), chooseButton, el('p', { class: 'dropzone-hint' }, 'PNG / JPG / WebP · Max. 5 MiB'), ); dropzone.addEventListener('click', openPicker); // Kartenvorschau (mit Bild) const canvas = el('canvas', { class: 'card-canvas', width: CARD_W, height: CARD_H, tabindex: 0, role: 'application', 'aria-label': 'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.', }); const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas); let dragId: number | null = null; let lastX = 0; let lastY = 0; canvas.addEventListener('pointerdown', (e) => { if (dragId !== null || (e.pointerType === 'mouse' && e.button !== 0)) return; e.preventDefault(); dragId = e.pointerId; lastX = e.clientX; lastY = e.clientY; canvas.setPointerCapture(e.pointerId); canvas.classList.add('is-panning'); }); canvas.addEventListener('pointermove', (e) => { if (e.pointerId !== dragId || canvas.clientWidth === 0) return; const k = CARD_W / canvas.clientWidth; const dx = (e.clientX - lastX) * k; const dy = (e.clientY - lastY) * k; lastX = e.clientX; lastY = e.clientY; if (dx !== 0 || dy !== 0) handlers.onPan(dx, dy); }); const endPan = (e: PointerEvent) => { if (e.pointerId !== dragId) return; dragId = null; canvas.classList.remove('is-panning'); if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); }; canvas.addEventListener('pointerup', endPan); canvas.addEventListener('pointercancel', endPan); canvas.addEventListener('keydown', (e) => { const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP; const moves: Record = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step], }; const move = moves[e.key]; if (move) { e.preventDefault(); handlers.onPan(move[0], move[1]); } else if (e.key === '+' || e.key === '=') { e.preventDefault(); handlers.onZoom(zoom + KEY_ZOOM_STEP); } else if (e.key === '-') { e.preventDefault(); handlers.onZoom(zoom - KEY_ZOOM_STEP); } }); const stage = el('div', { class: 'stage' }, dropzone, cardWrap); // Zoom const zoomInput = el('input', { type: 'range', class: 'zoom-input', id: 'editor-zoom', min: MIN_ZOOM, max: MAX_ZOOM, step: 0.01, value: MIN_ZOOM, }); const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, '100 %'); zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value))); const zoomRow = el( 'div', { class: 'zoom-row', hidden: true }, el('label', { class: 'zoom-label', for: 'editor-zoom' }, 'Zoom'), zoomInput, zoomValue, ); // Meldungen const errorBox = el('p', { class: 'msg msg-error', role: 'alert' }); const warningBox = el('p', { class: 'msg msg-warning', role: 'status' }); const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' }); // Knopfleiste const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen'); replaceButton.addEventListener('click', openPicker); const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen'); downloadButton.addEventListener('click', () => handlers.onDownload()); const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, SAVE_LABEL); saveButton.addEventListener('click', () => handlers.onSave()); const actions = el( 'div', { class: 'actions' }, el('span', { class: 'size-label' }, `${CARD_W} × ${CARD_H} PNG`), el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton), ); const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.'); const inner = el('div', { class: 'preview-inner' }, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input); const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner); function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void { button.disabled = disabled; if (disabled && hint) button.setAttribute('title', hint); else button.removeAttribute('title'); } function setMessage(box: HTMLElement, message: string | null): void { box.textContent = message ?? ''; } return { el: section, canvas, sync(view) { zoom = view.zoom; dropzone.hidden = view.hasImage; cardWrap.hidden = !view.hasImage; zoomRow.hidden = !view.hasImage; zoomInput.value = String(view.zoom); zoomValue.textContent = `${Math.round(view.zoom * 100)} %`; setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT); setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT); saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL; // Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen. replaceButton.disabled = view.saving; input.disabled = view.saving; downloadButton.setAttribute('aria-busy', String(view.exporting)); saveButton.setAttribute('aria-busy', String(view.saving)); }, setDragging(active) { stage.classList.toggle('is-dragging', active); }, setError(message) { setMessage(errorBox, message); }, setWarning(message) { setMessage(warningBox, message); }, setNotice(message) { setMessage(noticeBox, message); }, }; }