diff --git a/README.md b/README.md index b2044ed..8ca2644 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,30 @@ Wechsel automatisch dem Seitenverhältnis der gewählten Aufnahme. > Der eingetippte Tag beschriftet deshalb vorerst nur den Slide-Knopf im > Bedienfeld. +### Eigene Bilder importieren + +Unter **Eigene Bilder** wählst du Dateien aus dem Finder, oder du ziehst sie +direkt auf eine Slide. Beim Ablegen wird das Bild dort zum **zweiten Bild** und +lässt sich im Bearbeiten-Modus frei schieben und in der Größe ändern; über den +Knopf importierst du nur und ordnest danach unter *Inhalt* zu. + +Die Datei geht **byteweise unverändert** an den Server und liegt danach unter +`assets/eigene/`. Nichts wird neu kodiert, skaliert oder umbenannt — nur +Zeichen, die in einem Dateinamen nichts zu suchen haben, weichen einem +Bindestrich. + +``` +assets/eigene/ importierte Bilder, gehören mit in den Commit +``` + +> Der Umweg über den Server ist Absicht. Ein Foto als Base64 im Projektstand +> wäre größer als der gesamte übrige Stand und machte jeden Diff unlesbar. So +> steht dort nur der Pfad. + +Die Auswahllisten unter *Inhalt* holt der Generator beim Start vom Server +(`/bilder`), importierte Bilder stehen deshalb sofort darin — mit `★` +gekennzeichnet. + **Mitgelieferte Slides bleiben Vorlagen.** Der Stand speichert für sie nur einen Verweis und die Felder, die du überschrieben hast — eine Änderung an der Vorlage im Code kommt dadurch weiterhin bei allen an, die sie nicht angefasst @@ -107,10 +131,11 @@ assets/ ├── partner/ DATEV, KWP, Google Calendar, Google Drive, MS Outlook, MS Teams ├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter) ├── screens/ Software-Screenshots +├── eigene/ selbst importierte Bilder └── fonts/ Manrope 400–800, lokal vendor/ html-to-image, JSZip state/ Projektstände, gehören ins Repo -serve.py Statischer Server + /save- und /state-Endpunkt +serve.py Statischer Server + /save, /state, /upload, /bilder rules.md Gestaltungsregeln, Farbwerte, Export-Wissen ``` diff --git a/post1.html b/post1.html index ea43f1d..3f9144a 100644 --- a/post1.html +++ b/post1.html @@ -49,6 +49,13 @@ } #slide-ops button { flex: none; padding: 8px 10px; } + /* Zielmarke beim Ablegen einer Datei. */ + .holder.ablegen::before { + content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none; + outline: 12px dashed var(--accent-yellow); outline-offset: -6px; + background: rgba(241,183,41,.10); + } + .saved { font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s; margin-left: 6px; @@ -803,6 +810,15 @@
+
+ Eigene Bilder + + +
Oder eine Datei auf eine Slide ziehen — sie wird + dort das zweite Bild und lässt sich im Bearbeiten-Modus frei schieben. + Die Datei landet unverändert unter assets/eigene/ und gehört mit in den Commit.
+
+
Variante
@@ -1090,7 +1106,10 @@ /* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon wählen, deshalb werden sie vollständig geladen und nicht nur die, die eine Vorlage zufällig gerade benutzt. */ - const SCREENS = [ + /* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen + Dateien nach, damit importierte Bilder sofort auftauchen und die Liste + nicht im Code gepflegt werden muss. */ + let BILDER = [ { pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" }, { pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" }, { pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" }, @@ -1117,6 +1136,9 @@ const basis = eintrag.ref ? VORLAGEN.find(v => v.key === eintrag.ref) : null; const s = kopie(basis || NEUE_SLIDE); Object.assign(s, eintrag.werte || {}); + // null heisst "weg", nicht "leeres Objekt": nur so laesst sich ein zweites + // Bild, das die Vorlage mitbringt, wieder abwaehlen. + if (s.media2 === null) delete s.media2; // Der Schlüssel landet im Dateinamen und muss eindeutig bleiben. s.key = (basis ? basis.key : (eintrag.key || "eigen")) + (eintrag.ref ? "" : "-" + (i + 1)); return s; @@ -1893,7 +1915,7 @@ /* Alle Screens, nicht nur die gerade benutzten: eine selbst angelegte Slide kann jeden davon waehlen, und ein Bild, das erst beim Auswaehlen geladen wuerde, fehlt im Export. */ - SCREENS.forEach(sc => paths.push(sc.pfad)); + BILDER.forEach(sc => paths.push(sc.pfad)); // Optional: the layout falls back to a hint if the file is not there yet. const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]); paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"); @@ -2443,6 +2465,148 @@ Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt Aenderungen an der Vorlage im Code weiterhin mit. */ + /* ─── Eigene Bilder ─────────────────────────────────────────────────────── + Die Datei geht unveraendert an den Server und liegt danach unter + assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere + ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff + unbrauchbar. So steht dort nur der Pfad. */ + function bildOptionen() { + return BILDER.map(b => + '').join(""); + } + + async function bilderNachladen() { + try { + const res = await fetch("/bilder", { cache: "no-store" }); + if (!res.ok) return; + const liste = await res.json(); + if (Array.isArray(liste) && liste.length) BILDER = liste; + } catch (_) { /* ohne Server bleibt die eingebaute Liste */ } + } + + // Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt. + async function bildBereitstellen(pfad) { + if (assets[pfad]) return true; + const res = await fetch(pfad, { cache: "no-store" }).catch(() => null); + if (!res || !res.ok) return false; + const blob = await res.blob(); + assets[pfad] = await new Promise((ok, fail) => { + const fr = new FileReader(); + fr.onload = () => ok(fr.result); + fr.onerror = fail; + fr.readAsDataURL(blob); + }); + return true; + } + + async function bildImportieren(datei) { + const res = await fetch("/upload", { + method: "POST", + // Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen. + headers: { "X-Filename": encodeURIComponent(datei.name) }, + body: datei, + }); + if (!res.ok) { + const w = await res.json().catch(() => ({})); + throw new Error(w.error || ("HTTP " + res.status)); + } + const { pfad } = await res.json(); + await bilderNachladen(); + await bildBereitstellen(pfad); + return pfad; + } + + // Masse aus dem Bild selbst: ein Hochformat soll nicht in einem + // Querformat-Rahmen stehen. + function seitenverhaeltnis(pfad) { + return new Promise(ok => { + const im = new Image(); + im.onload = () => ok(im.naturalWidth / im.naturalHeight); + im.onerror = () => ok(1.6); + im.src = assets[pfad] || pfad; + }); + } + + async function alsZweitesBild(i, pfad) { + const ar = await seitenverhaeltnis(pfad); + const w = werteVon(i); + const alt = w.media2 || SLIDES[i].media2 || {}; + w.media2 = { + img: pfad, w: alt.w || 400, ar: +ar.toFixed(4), + left: alt.left != null ? alt.left : 500, + bottom: alt.bottom != null ? alt.bottom : 60, + z: alt.z || 2, rot: alt.rot || 0, + }; + slidesNeuAufbauen(); + deckNeuBauen(); + show(i); + saveState(); + } + + async function dateienAufnehmen(dateien, zielSlide) { + const bilder = [...dateien].filter(f => /^image\//.test(f.type)); + if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; } + setStatus("Importiere " + bilder.length + " Bild(er) …"); + const pfade = []; + try { + for (const f of bilder) pfade.push(await bildImportieren(f)); + } catch (err) { + setStatus("Import fehlgeschlagen: " + err.message, "err"); + return; + } + // Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber + // den Knopf importiert man nur; zugeordnet wird dann in den Feldern. + if (typeof zielSlide === "number") { + await alsZweitesBild(zielSlide, pfade[0]); + setStatus(pfade.length + " Bild(er) importiert, erstes auf Slide " + + (zielSlide + 1) + " gelegt", "ok"); + } else { + syncInhalt(); + setStatus(pfade.length + " Bild(er) importiert · unter Inhalt zuweisen", "ok"); + } + } + + document.getElementById("bild-waehlen").addEventListener("click", + () => document.getElementById("bild-datei").click()); + document.getElementById("bild-datei").addEventListener("change", ev => { + const f = ev.target.files; + if (f && f.length) dateienAufnehmen(f); + ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben + }); + + /* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei + sonst einfach im Tab und der Projektstand waere verloren. */ + (function ablegen() { + const sc = document.getElementById("scroller"); + const zielAus = e => { + const h = e.target.closest && e.target.closest(".holder"); + return h ? Number(h.dataset.n) : null; + }; + ["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => { + if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + const n = zielAus(e); + deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n)); + })); + sc.addEventListener("dragleave", e => { + if (e.relatedTarget && sc.contains(e.relatedTarget)) return; + deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen")); + }); + sc.addEventListener("drop", e => { + if (!e.dataTransfer || !e.dataTransfer.files.length) return; + e.preventDefault(); + const n = zielAus(e); + deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen")); + dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n); + }); + // Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen. + ["dragover", "drop"].forEach(art => window.addEventListener(art, e => { + if (!sc.contains(e.target)) e.preventDefault(); + })); + })(); + function werteVon(i) { const e = slideListe[i]; return (e.werte = e.werte || {}); @@ -2466,9 +2630,10 @@ ).join("") + (s.type === "feature" ? '' + - '' + '' + + '' + + '' : "") + '
*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' + (s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '
'; @@ -2476,7 +2641,9 @@ box.querySelectorAll("[data-feld]").forEach(el => { if (document.activeElement === el) return; const k = el.dataset.feld; - el.value = k === "img" ? (s.img || "") : htmlZuText(s[k]); + if (k === "img") el.value = s.img || ""; + else if (k === "media2") el.value = (s.media2 && s.media2.img) || ""; + else el.value = htmlZuText(s[k]); }); } @@ -2485,6 +2652,16 @@ if (!el) return; const k = el.dataset.feld; const w = werteVon(current); + if (k === "media2") { + if (!el.value) { + // null statt weglassen: sonst kaeme das Bild der Vorlage zurueck. + w.media2 = null; + slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState(); + } else { + alsZweitesBild(current, el.value); + } + return; + } if (k === "img") { w.img = el.value; // Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem @@ -3157,6 +3334,7 @@ (async function boot() { try { setStatus("Lade Screens ..."); + await bilderNachladen(); await preloadAssets(); const restored = await loadState(); buildDeck(); diff --git a/serve.py b/serve.py index 353c74b..e07988a 100755 --- a/serve.py +++ b/serve.py @@ -11,6 +11,12 @@ Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern unter state/.json im Repo. Nur so ueberlebt er einen Rechnerwechsel: gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage bleibt als Zwischenspeicher, die Datei ist die Wahrheit. + +Eigene Bilder kommen ueber /upload herein und landen unverfaelscht als Datei +unter assets/eigene/. Der Weg ueber den Projektstand waere der falsche: ein +2-MB-PNG als Base64 blaeht state/post1.json auf und macht jeden Diff +unlesbar. So steht im Stand nur der Pfad, und das Bild liegt mit im Repo -- +dasselbe Prinzip wie bei allen anderen Assets. """ import http.server @@ -25,9 +31,15 @@ PORT = 4321 ROOT = Path(__file__).resolve().parent TARGET = Path.home() / "Downloads" STATE_DIR = ROOT / "state" +EIGENE_DIR = ROOT / "assets" / "eigene" SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$") SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$") MAX_STATE = 4 * 1024 * 1024 +MAX_BILD = 40 * 1024 * 1024 +# Nur verlustfreie oder unveraenderte Rasterbilder. Die Bytes werden nicht +# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat. +BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"} +BILD_ORDNER = ("assets/screens", "assets/eigene") class Handler(http.server.SimpleHTTPRequestHandler): @@ -90,11 +102,70 @@ class Handler(http.server.SimpleHTTPRequestHandler): tmp.replace(p) self._json(200, '{"ok":true}') + def do_GET(self): + """Vor der Dateiauslieferung nur die Bilderliste abfangen.""" + if urllib.parse.urlparse(self.path).path == "/bilder": + self._liste_bilder() + return + super().do_GET() + + def _liste_bilder(self): + """Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im + Code gepflegt werden muss und importierte sofort auftauchen.""" + raus = [] + for ordner in BILD_ORDNER: + verz = ROOT / ordner + if not verz.is_dir(): + continue + for f in sorted(verz.iterdir()): + if f.is_file() and f.suffix.lower() in BILD_ENDUNGEN: + raus.append({"pfad": "./" + ordner + "/" + f.name, + "name": f.stem, "ordner": ordner.split("/")[-1]}) + self._json(200, json.dumps(raus, ensure_ascii=False)) + + def _upload(self): + """Ein eigenes Bild aufnehmen. Die Bytes gehen unveraendert auf die + Platte: nicht neu kodiert, nicht skaliert, nicht umbenannt ausser um + gefaehrliche Zeichen. Verlustfrei heisst hier woertlich unangetastet.""" + name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename", ""))) + stamm, endung = os.path.splitext(name) + endung = endung.lower() + if endung not in BILD_ENDUNGEN: + self._json(400, '{"error":"kein Bildformat"}') + return + # Aus dem Stamm wird ein unverfaenglicher Dateiname, der Rest faellt weg. + stamm = re.sub(r"[^A-Za-z0-9._-]+", "-", stamm).strip("-.") or "bild" + stamm = stamm[:80] + + length = int(self.headers.get("Content-Length", 0)) + if length <= 0 or length > MAX_BILD: + self._json(400, '{"error":"ungueltige Groesse"}') + return + daten = self.rfile.read(length) + + EIGENE_DIR.mkdir(parents=True, exist_ok=True) + ziel = (EIGENE_DIR / (stamm + endung)).resolve() + if ziel.parent != EIGENE_DIR.resolve(): + self._json(400, '{"error":"ungueltiger Pfad"}') + return + # Gleicher Inhalt, gleicher Name: nicht doppelt ablegen. + n = 1 + while ziel.exists() and ziel.read_bytes() != daten: + ziel = EIGENE_DIR / (stamm + "-" + str(n) + endung) + n += 1 + ziel.write_bytes(daten) + pfad = "./assets/eigene/" + ziel.name + print(f"importiert: {ziel.name} ({len(daten)/1024:.0f} KB)") + self._json(200, json.dumps({"pfad": pfad, "name": ziel.stem})) + def do_POST(self): path = urllib.parse.urlparse(self.path).path if path == "/state": self._put_state() return + if path == "/upload": + self._upload() + return if path != "/save": self.send_error(404) return