Files
AirCardIOS/web/src/editor/preview.ts
T
ClaudeandClaude Opus 5.5 ba4d9f76d4 feat(cards): Karten speichern und Meine Karten
- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber
  Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface,
  Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über
  client.prepareRequest (Projekt-Header + Cookie, kein getFileView)
- account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen
- editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal
  wiederholen, deutsche Fehlermeldung)
- Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über
  ?karte=<id>, Bildwechsel während des Speicherns gesperrt
- pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen,
  Löschen (eigener Dialog), Abmelde- und Leer-Ansicht
- Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:18:27 +00:00

259 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, [number, number]> = {
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);
},
};
}