feat: print page with crop editor and live sheet preview
New Druck tab: upload or pick from the library, choose a target size and copy count per image, pan/zoom crop on a locked aspect ratio, and a live preview that renders the same layout the PDF will contain. Sheet controls for paper, margin, gap, bleed, crop marks, dpi and templates.
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper';
|
||||
import {
|
||||
layout, cutMarks, recommendedGap, dpiCheck,
|
||||
type SheetSpec, type PlaceSpec, type MarkMode,
|
||||
} from '../lib/printlayout';
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
|
||||
Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern;
|
||||
Pixel entstehen erst beim Rendern auf dem Server.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
|
||||
interface CropRel { x: number; y: number; w: number; h: number }
|
||||
|
||||
interface Cell {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string; // Data-URL (Upload) oder /api/items/…/file
|
||||
src?: SrcRef;
|
||||
uploading?: boolean;
|
||||
error?: string;
|
||||
natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis)
|
||||
sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom'
|
||||
customSize: string;
|
||||
landscape: boolean;
|
||||
count: number;
|
||||
allowRotate: boolean;
|
||||
crop: CropRel;
|
||||
}
|
||||
|
||||
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
|
||||
|
||||
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */
|
||||
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
|
||||
const imgA = natW / natH;
|
||||
let w = 1, h = 1;
|
||||
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); }
|
||||
const cx = around ? around.x + around.w / 2 : 0.5;
|
||||
const cy = around ? around.y + around.h / 2 : 0.5;
|
||||
return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h });
|
||||
}
|
||||
function clampCrop(c: CropRel): CropRel {
|
||||
const w = Math.min(1, Math.max(0.02, c.w));
|
||||
const h = Math.min(1, Math.max(0.02, c.h));
|
||||
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
|
||||
}
|
||||
|
||||
const sizeOfCell = (c: Cell): { w: number; h: number } | null => {
|
||||
const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId);
|
||||
if (!base) return null;
|
||||
const s = { w: base.w, h: base.h };
|
||||
return c.landscape ? { w: s.h, h: s.w } : s;
|
||||
};
|
||||
|
||||
export default function PrintApp() {
|
||||
const [cells, setCells] = useState<Cell[]>([]);
|
||||
const [paperId, setPaperId] = useState('A4');
|
||||
const [paperCustom, setPaperCustom] = useState('');
|
||||
const [paperLandscape, setPaperLandscape] = useState(false);
|
||||
const [marginMm, setMarginMm] = useState(5);
|
||||
const [autoGap, setAutoGap] = useState(true);
|
||||
const [gapMm, setGapMm] = useState(12);
|
||||
const [bleedMm, setBleedMm] = useState(0);
|
||||
const [marks, setMarks] = useState<MarkMode>('corner');
|
||||
const [markLen, setMarkLen] = useState(4);
|
||||
const [markOff, setMarkOff] = useState(3);
|
||||
const [dpi, setDpi] = useState(300);
|
||||
const [ext, setExt] = useState<'jpg' | 'png'>('jpg');
|
||||
const [center, setCenter] = useState(true);
|
||||
const [footer, setFooter] = useState(true);
|
||||
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [presets, setPresets] = useState<any[]>([]);
|
||||
const [presetSel, setPresetSel] = useState('');
|
||||
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); };
|
||||
|
||||
/* ---------------- Bogen ---------------- */
|
||||
const paper = useMemo(() => {
|
||||
const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId);
|
||||
if (!p) return null;
|
||||
return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h };
|
||||
}, [paperId, paperCustom, paperLandscape]);
|
||||
|
||||
const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm;
|
||||
|
||||
const sheet: SheetSpec | null = paper
|
||||
? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center }
|
||||
: null;
|
||||
|
||||
const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => {
|
||||
const s = sizeOfCell(c);
|
||||
if (!s || c.error || !c.src) return [];
|
||||
return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }];
|
||||
}), [cells]);
|
||||
|
||||
const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]);
|
||||
const marksPerPage = useMemo(() => (plan && sheet)
|
||||
? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm }))
|
||||
: [], [plan, sheet, marks, markLen, markOff, bleedMm]);
|
||||
|
||||
/* ---------------- Upload ---------------- */
|
||||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||
const arr = Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name))
|
||||
.slice(0, 30);
|
||||
for (const f of arr) {
|
||||
const id = uid();
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
const probe = new Image();
|
||||
probe.onload = () => setCells((p) => p.map((x) => x.id === id
|
||||
? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight,
|
||||
crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) }
|
||||
: x));
|
||||
probe.src = url;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
setCells((p) => [...p, newCell(id, f.name)]);
|
||||
const fd = new FormData(); fd.append('files', f);
|
||||
try {
|
||||
const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json());
|
||||
const info = j.files?.[0];
|
||||
setCells((p) => p.map((x) => x.id === id ? {
|
||||
...x, uploading: false, error: info?.error,
|
||||
src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined,
|
||||
natW: info?.width || x.natW, natH: info?.height || x.natH,
|
||||
} : x));
|
||||
} catch {
|
||||
setCells((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||
if (imgs.length) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||
};
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [addFiles]);
|
||||
|
||||
useEffect(() => { loadPresets(); }, []);
|
||||
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
|
||||
.then((j) => setPresets(j.presets || [])).catch(() => {});
|
||||
|
||||
const addFromLibrary = (it: any) => {
|
||||
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
|
||||
const id = uid();
|
||||
const c: Cell = {
|
||||
...newCell(id, it.filename || 'Bild'),
|
||||
uploading: false,
|
||||
src: { kind: 'item', id: it.id },
|
||||
preview: `/api/items/${it.id}/file?preview=1`,
|
||||
natW: w || 2000, natH: h || 3000,
|
||||
};
|
||||
c.crop = coverCrop(c.natW, c.natH, aspectOf(c));
|
||||
setCells((p) => [...p, c]);
|
||||
};
|
||||
|
||||
const patch = (id: string, up: Partial<Cell>) => setCells((p) => p.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next = { ...c, ...up };
|
||||
// Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen.
|
||||
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) {
|
||||
const a = aspectOf(next);
|
||||
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
|
||||
/* ---------------- Ausgabe ---------------- */
|
||||
const body = () => ({
|
||||
paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId },
|
||||
landscape: paperLandscape,
|
||||
marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer,
|
||||
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
|
||||
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
|
||||
const s = sizeOfCell(c)!;
|
||||
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate };
|
||||
}),
|
||||
});
|
||||
|
||||
const makePdf = async () => {
|
||||
if (!plan?.pages.length) return;
|
||||
setBusy('pdf');
|
||||
try {
|
||||
const res = await fetch('/api/print/sheet', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body()),
|
||||
});
|
||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
download(blob, `klarbild-druckbogen-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.`);
|
||||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const makeSingle = async (c: Cell) => {
|
||||
const s = sizeOfCell(c); if (!s || !c.src) return;
|
||||
setBusy('single');
|
||||
try {
|
||||
const res = await fetch('/api/print/single', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name }),
|
||||
});
|
||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
download(blob, `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`);
|
||||
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
||||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||||
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
||||
: `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`);
|
||||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const savePreset = async () => {
|
||||
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")');
|
||||
if (!name?.trim()) return;
|
||||
const config = {
|
||||
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
|
||||
marks, markLen, markOff, dpi, ext, center, footer,
|
||||
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
|
||||
};
|
||||
await fetch('/api/print/presets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), config }),
|
||||
});
|
||||
notify('Vorlage gespeichert.'); loadPresets();
|
||||
};
|
||||
|
||||
const applyPreset = (p: any) => {
|
||||
const c = p?.config; if (!c) return;
|
||||
setPaperId(c.paperId ?? 'A4'); setPaperCustom(c.paperCustom ?? ''); setPaperLandscape(!!c.paperLandscape);
|
||||
setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 12);
|
||||
setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 4); setMarkOff(c.markOff ?? 3);
|
||||
setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); setFooter(c.footer !== false);
|
||||
// Formate auf die vorhandenen Bilder anwenden (der Reihe nach).
|
||||
if (Array.isArray(c.formats) && c.formats.length) {
|
||||
setCells((prev) => prev.map((cell, i) => {
|
||||
const f = c.formats[Math.min(i, c.formats.length - 1)];
|
||||
const next = { ...cell, sizeId: f.sizeId, customSize: f.customSize || '', landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false };
|
||||
const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop);
|
||||
return next;
|
||||
}));
|
||||
}
|
||||
notify('Vorlage geladen.');
|
||||
};
|
||||
|
||||
const deletePreset = async (id: string) => {
|
||||
if (!id || !confirm('Vorlage löschen?')) return;
|
||||
await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' });
|
||||
setPresetSel(''); loadPresets();
|
||||
};
|
||||
|
||||
const editCell = cells.find((c) => c.id === editing) || null;
|
||||
const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c));
|
||||
const totalPieces = ready.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
return (
|
||||
<div className="druck">
|
||||
{/* ---------- 1 · Bilder ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">1 · Bilder</span>
|
||||
<div className="kopf-tools">
|
||||
<button className="link" onClick={() => setPicker(true)}>Aus Bibliothek</button>
|
||||
{cells.length > 0 && <button className="link" onClick={() => setCells([])}>Alle entfernen</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="buehne">
|
||||
{cells.length === 0 ? (
|
||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}>
|
||||
<span className="regmark" />
|
||||
<p>Bilder hierher ziehen, <span className="kbd">⌘V</span> einfügen oder klicken.<br />
|
||||
Kein KI-Schritt — nur zuschneiden, skalieren, setzen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="zellen">
|
||||
{cells.map((c) => <CellCard key={c.id} cell={c} dpi={dpi}
|
||||
onPatch={(u) => patch(c.id, u)}
|
||||
onEdit={() => setEditing(c.id)}
|
||||
onSingle={() => makeSingle(c)}
|
||||
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)}
|
||||
<button className="zelle-add" onClick={() => fileRef.current?.click()}>+ Bild</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="raster">
|
||||
{/* ---------- 2 · Bogen ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">2 · Bogen & Schnitt</span>
|
||||
<button className="link" onClick={savePreset}>Als Vorlage sichern</button>
|
||||
</div>
|
||||
<div className="steuer">
|
||||
<div className="feld">
|
||||
<label>Vorlagen</label>
|
||||
<div className="reihe">
|
||||
<select className="select" value={presetSel}
|
||||
onChange={(e) => { setPresetSel(e.target.value); applyPreset(presets.find((p) => p.id === e.target.value)); }}>
|
||||
<option value="">Vorlage wählen …</option>
|
||||
{presets.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{presetSel && <button className="pbtn" onClick={() => deletePreset(presetSel)}>✕</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Papierformat</label>
|
||||
<select className="select" value={paperId} onChange={(e) => setPaperId(e.target.value)}>
|
||||
{groupBy(PAPERS, 'group').map(([g, list]) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{list.map((p) => <option key={p.id} value={p.id}>{p.label} · {p.w} × {p.h} mm</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
<option value="custom">Eigenes Papiermaß …</option>
|
||||
</select>
|
||||
{paperId === 'custom' && (
|
||||
<input className="input" value={paperCustom} onChange={(e) => setPaperCustom(e.target.value)}
|
||||
placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" />
|
||||
)}
|
||||
<div className="schalter">
|
||||
<button className={!paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(false)}>Hoch</button>
|
||||
<button className={paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(true)}>Quer</button>
|
||||
</div>
|
||||
{paper && <div className="fein">Bogen {labelMm(paper.w, paper.h)} — die PDF-Seite hat exakt dieses Maß.</div>}
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Schnitthilfen</label>
|
||||
<div className="schalter">
|
||||
<button className={marks === 'none' ? 'an' : ''} onClick={() => setMarks('none')}>Keine</button>
|
||||
<button className={marks === 'corner' ? 'an' : ''} onClick={() => setMarks('corner')}>Eckmarken</button>
|
||||
<button className={marks === 'grid' ? 'an' : ''} onClick={() => setMarks('grid')}>Durchgehend</button>
|
||||
</div>
|
||||
<div className="fein">
|
||||
{marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.'
|
||||
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
|
||||
: 'Keine Linien — Kanten selbst anlegen.'}
|
||||
</div>
|
||||
{marks === 'corner' && (
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Länge (mm)
|
||||
<input className="input" type="number" min={1} max={20} step={0.5} value={markLen}
|
||||
onChange={(e) => setMarkLen(Number(e.target.value))} /></label>
|
||||
<label className="mini">Abstand (mm)
|
||||
<input className="input" type="number" min={0} max={20} step={0.5} value={markOff}
|
||||
onChange={(e) => setMarkOff(Number(e.target.value))} /></label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Ränder & Abstände</label>
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Rand zum Papier (mm)
|
||||
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
|
||||
onChange={(e) => setMarginMm(Number(e.target.value))} /></label>
|
||||
<label className="mini">Beschnittzugabe (mm)
|
||||
<input className="input" type="number" min={0} max={10} step={0.5} value={bleedMm}
|
||||
onChange={(e) => setBleedMm(Number(e.target.value))} /></label>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={autoGap} onChange={(e) => setAutoGap(e.target.checked)} />
|
||||
<span>Abstand automatisch ({gapEff.toFixed(1)} mm — passend zu den Marken)</span>
|
||||
</label>
|
||||
{!autoGap && (
|
||||
<input className="input" type="number" min={0} max={60} step={0.5} value={gapMm}
|
||||
onChange={(e) => setGapMm(Number(e.target.value))} />
|
||||
)}
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={center} onChange={(e) => setCenter(e.target.checked)} />
|
||||
<span>Auf dem Bogen zentrieren</span>
|
||||
</label>
|
||||
<div className="fein">Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.</div>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Qualität</label>
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Auflösung (dpi)
|
||||
<select className="select" value={dpi} onChange={(e) => setDpi(Number(e.target.value))}>
|
||||
<option value={150}>150 — Entwurf</option>
|
||||
<option value={300}>300 — Fotodruck (Standard)</option>
|
||||
<option value={600}>600 — sehr fein</option>
|
||||
</select></label>
|
||||
<label className="mini">Dateiformat im PDF
|
||||
<select className="select" value={ext} onChange={(e) => setExt(e.target.value as any)}>
|
||||
<option value="jpg">JPG — kleiner</option>
|
||||
<option value="png">PNG — verlustfrei</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={footer} onChange={(e) => setFooter(e.target.checked)} />
|
||||
<span>Fußzeile mit Maßen & Druckhinweis</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
|
||||
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut …</> : 'Druck-PDF erzeugen'}
|
||||
</button>
|
||||
<div className="fein mitte">Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- 3 · Vorschau ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">3 · Vorschau</span>
|
||||
{plan && <span className="fein nopad">{plan.pages.length} Bogen · {totalPieces} Bilder</span>}
|
||||
</div>
|
||||
<div className="vorschau">
|
||||
{!sheet ? <p className="leer">Bitte ein gültiges Papiermaß angeben.</p>
|
||||
: !plan || !plan.pages.length ? <p className="leer">Noch nichts zu zeigen — Bilder hinzufügen und Formate wählen.</p>
|
||||
: plan.pages.map((pg, i) => (
|
||||
<div key={i} className="bogen-halter">
|
||||
<div className="bogen" style={{ aspectRatio: `${sheet.wMm} / ${sheet.hMm}` }}>
|
||||
{pg.placements.map((p, k) => {
|
||||
const c = cells.find((x) => x.id === p.specId);
|
||||
if (!c) return null;
|
||||
return (
|
||||
<div key={k} className="slot" style={{
|
||||
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
|
||||
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
|
||||
}}>
|
||||
<div className="rotor" style={p.rotated ? {
|
||||
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
|
||||
transform: 'translate(-50%, -50%) rotate(90deg)',
|
||||
} : undefined}>
|
||||
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<svg className="marken" viewBox={`0 0 ${sheet.wMm} ${sheet.hMm}`} preserveAspectRatio="none">
|
||||
{(marksPerPage[i] || []).map((l, k) => (
|
||||
<line key={k} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
|
||||
stroke="#16150F" strokeWidth={0.25} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="bogen-fuss">Bogen {i + 1} · {pg.placements.length} Bilder</div>
|
||||
</div>
|
||||
))}
|
||||
{plan?.unplaced?.length ? (
|
||||
<p className="warnung">Nicht platzierbar: {plan.unplaced.map((u) => {
|
||||
const c = cells.find((x) => x.id === u.specId);
|
||||
return `${c?.name || u.specId} (${u.reason})`;
|
||||
}).join(', ')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
|
||||
onChange={(crop) => patch(editCell.id, { crop })} />}
|
||||
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<PrintStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Bausteine ---------------- */
|
||||
|
||||
function newCell(id: string, name: string): Cell {
|
||||
return {
|
||||
id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
|
||||
sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true,
|
||||
crop: { x: 0, y: 0, w: 1, h: 1 },
|
||||
};
|
||||
}
|
||||
function aspectOf(c: Cell): number {
|
||||
const s = sizeOfCell(c);
|
||||
return s ? s.w / s.h : 1;
|
||||
}
|
||||
function cropStyle(c: CropRel): React.CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
|
||||
left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`,
|
||||
objectFit: 'fill',
|
||||
};
|
||||
}
|
||||
function groupBy<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
|
||||
const m = new Map<string, T[]>();
|
||||
for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); }
|
||||
return [...m.entries()];
|
||||
}
|
||||
function download(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
|
||||
function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
cell: Cell; dpi: number;
|
||||
onPatch: (u: Partial<Cell>) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void;
|
||||
}) {
|
||||
const s = sizeOfCell(cell);
|
||||
const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null;
|
||||
return (
|
||||
<div className={`zelle ${cell.error ? 'err' : ''}`}>
|
||||
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button>
|
||||
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }}
|
||||
onClick={() => !cell.uploading && onEdit()}>
|
||||
{cell.preview
|
||||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
|
||||
: <span className="lade" />}
|
||||
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
|
||||
</div>
|
||||
<div className="zelle-steuer">
|
||||
<select className="select mini-select" value={cell.sizeId} onChange={(e) => onPatch({ sizeId: e.target.value })}>
|
||||
{groupBy(PHOTO_SIZES, 'group').map(([g, list]) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{list.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
<option value="custom">Eigenes Maß …</option>
|
||||
</select>
|
||||
{cell.sizeId === 'custom' && (
|
||||
<input className="input mini-input" value={cell.customSize} onChange={(e) => onPatch({ customSize: e.target.value })}
|
||||
placeholder="12x15 · 35x45mm · 4:3/15" />
|
||||
)}
|
||||
<div className="zelle-reihe">
|
||||
<div className="schalter winzig">
|
||||
<button className={!cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: false })}>Hoch</button>
|
||||
<button className={cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: true })}>Quer</button>
|
||||
</div>
|
||||
<div className="anzahl">
|
||||
<button onClick={() => onPatch({ count: Math.max(1, cell.count - 1) })}>−</button>
|
||||
<input type="number" min={1} max={200} value={cell.count}
|
||||
onChange={(e) => onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} />
|
||||
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="check winzig">
|
||||
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
|
||||
<span>darf gedreht platziert werden</span>
|
||||
</label>
|
||||
{cell.error ? <div className="fein warn">{cell.error}</div> : s && (
|
||||
<div className={`fein ${check && !check.ok ? 'warn' : ''}`}>
|
||||
{labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px
|
||||
{check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)}
|
||||
</div>
|
||||
)}
|
||||
<button className="pbtn breit" disabled={!cell.src} onClick={onSingle}>Einzeln herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */
|
||||
function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) {
|
||||
const s = sizeOfCell(cell);
|
||||
const aspect = s ? s.w / s.h : 1;
|
||||
const base = coverCrop(cell.natW, cell.natH, aspect);
|
||||
const [crop, setCrop] = useState<CropRel>(cell.crop);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
|
||||
|
||||
const zoom = Math.min(20, Math.max(1, base.w / crop.w));
|
||||
const setZoom = (z: number) => {
|
||||
const zz = Math.min(20, Math.max(1, z));
|
||||
const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
|
||||
const w = base.w / zz, h = base.h / zz;
|
||||
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }));
|
||||
};
|
||||
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
drag.current = { x: e.clientX, y: e.clientY, crop };
|
||||
};
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.current.x) / r.width;
|
||||
const dy = (e.clientY - drag.current.y) / r.height;
|
||||
const c = drag.current.crop;
|
||||
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }));
|
||||
};
|
||||
const onUp = () => { drag.current = null; };
|
||||
|
||||
useEffect(() => {
|
||||
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', esc);
|
||||
return () => window.removeEventListener('keydown', esc);
|
||||
}, [onClose]);
|
||||
|
||||
const apply = () => { onChange(crop); onClose(); };
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-inhalt" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Zuschnitt · {s ? labelMm(s.w, s.h) : ''}</span>
|
||||
<button className="link" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div className="crop-buehne">
|
||||
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
|
||||
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
|
||||
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
|
||||
<div className="crop-gitter" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="crop-steuer">
|
||||
<label className="mini breit">Zoom
|
||||
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
|
||||
onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
</label>
|
||||
<div className="reihe">
|
||||
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button>
|
||||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button>
|
||||
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
|
||||
</div>
|
||||
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
fetch('/api/items').then((r) => r.json())
|
||||
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-inhalt breit" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Aus der Bibliothek wählen</span>
|
||||
<button className="link" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div className="picker">
|
||||
{loading ? <p className="leer">Wird geladen …</p>
|
||||
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
|
||||
: items.map((it) => (
|
||||
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
|
||||
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span>{it.filename}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrintStyles() {
|
||||
return <style>{`
|
||||
.druck{display:flex;flex-direction:column;gap:16px;}
|
||||
.raster{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;align-items:start;}
|
||||
@media(max-width:900px){.raster{grid-template-columns:1fr;}}
|
||||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||
.kopf-tools{display:flex;gap:12px;}
|
||||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:0;}
|
||||
.buehne{padding:16px;}
|
||||
.drop{width:100%;min-height:180px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
|
||||
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
|
||||
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
|
||||
|
||||
.zellen{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;}
|
||||
.zelle{position:relative;border:1px solid var(--line);border-radius:var(--radius-sm);background:#fff;padding:9px;display:flex;flex-direction:column;gap:8px;}
|
||||
.zelle.err{border-color:var(--err);}
|
||||
.zelle-x{position:absolute;top:5px;right:5px;z-index:3;width:22px;height:22px;border:none;border-radius:50%;background:rgba(22,21,15,.72);color:#fff;cursor:pointer;font-size:13px;line-height:1;}
|
||||
.zelle-bild{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:3px;cursor:zoom-in;
|
||||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
|
||||
.zelle-bild img{display:block;}
|
||||
.zelle-lupe{position:absolute;left:0;right:0;bottom:0;background:rgba(22,21,15,.62);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;text-align:center;padding:3px 0;}
|
||||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||
.zelle-steuer{display:flex;flex-direction:column;gap:6px;}
|
||||
.zelle-reihe{display:flex;gap:6px;align-items:center;}
|
||||
.zelle-add{border:1.5px dashed var(--line);background:none;border-radius:var(--radius-sm);cursor:pointer;font-size:14px;color:var(--soft);min-height:120px;font-family:inherit;}
|
||||
|
||||
.anzahl{display:flex;align-items:center;border:1px solid var(--line);border-radius:3px;overflow:hidden;background:#fff;}
|
||||
.anzahl button{width:28px;height:30px;border:none;background:none;cursor:pointer;font-size:15px;color:var(--ink);}
|
||||
.anzahl input{width:44px;height:30px;border:none;text-align:center;font-family:var(--font-mono);font-size:13px;outline:none;-moz-appearance:textfield;}
|
||||
.anzahl input::-webkit-outer-spin-button,.anzahl input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
|
||||
|
||||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||
.input,.select{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||
.mini-select,.mini-input{padding:6px 8px;font-size:12.5px;}
|
||||
.input:focus,.select:focus{border-color:var(--accent);}
|
||||
.reihe{display:flex;gap:6px;align-items:center;margin-top:6px;}
|
||||
.reihe .select{flex:1;}
|
||||
.reihe.zwei{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||||
.mini{display:block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);}
|
||||
.mini .input,.mini .select{margin-top:4px;}
|
||||
.mini.breit{width:100%;}
|
||||
.mini input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px;}
|
||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||
.fein.nopad{margin:0;}
|
||||
.fein.mitte{text-align:center;}
|
||||
.fein.warn{color:var(--err);}
|
||||
.check{display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12.5px;color:var(--ink);text-transform:none;letter-spacing:0;font-family:inherit;}
|
||||
.check input{width:15px;height:15px;accent-color:var(--accent);margin-top:2px;flex:0 0 auto;}
|
||||
.check.winzig{font-size:11px;color:var(--soft);}
|
||||
.schalter{display:flex;gap:6px;margin-top:8px;}
|
||||
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.winzig{margin:0;flex:1;}
|
||||
.schalter.winzig button{padding:5px 4px;font-size:11.5px;}
|
||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||
.pbtn.breit{width:100%;}
|
||||
.pbtn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.knopf.schmal{width:auto;padding:9px 16px;font-size:13.5px;}
|
||||
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||||
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||||
@keyframes sp{to{transform:rotate(360deg);}}
|
||||
|
||||
.vorschau{padding:16px;display:flex;flex-direction:column;gap:16px;}
|
||||
.leer{color:var(--soft);font-size:13px;margin:0;text-align:center;padding:26px 0;}
|
||||
.bogen-halter{display:flex;flex-direction:column;gap:6px;}
|
||||
.bogen{position:relative;width:100%;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;}
|
||||
.slot{position:absolute;overflow:hidden;}
|
||||
.rotor{position:absolute;left:50%;top:50%;width:100%;height:100%;transform:translate(-50%,-50%);overflow:hidden;}
|
||||
.rotor img{position:absolute;}
|
||||
.marken{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;}
|
||||
.bogen-fuss{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);}
|
||||
.warnung{color:var(--err);font-size:12px;margin:0;}
|
||||
|
||||
.modal{position:fixed;inset:0;background:rgba(22,21,15,.55);display:flex;align-items:center;justify-content:center;z-index:70;padding:16px;}
|
||||
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
|
||||
.modal-inhalt.breit{width:min(880px,100%);}
|
||||
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
|
||||
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
|
||||
.crop-box:active{cursor:grabbing;}
|
||||
.crop-box img{position:absolute;pointer-events:none;}
|
||||
.crop-gitter{position:absolute;inset:0;pointer-events:none;
|
||||
background:linear-gradient(to right,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px)),
|
||||
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
|
||||
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
|
||||
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
|
||||
|
||||
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
|
||||
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
|
||||
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;}
|
||||
.pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.pick:hover{border-color:var(--accent);}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
|
||||
`}</style>;
|
||||
}
|
||||
Reference in New Issue
Block a user