- Format-Schalter 4:5 / 1:1 in Post 9, Stand auf 1:1 (Meta nimmt ein Carousel mit Video als Anzeige nur quadratisch), Video in 1:1 - animation.mjs baut das Format der Seite, Datei mit -1x1 - Variante 2/3: Kundengeschichte nach dem weclapp-Karussell, hell und dunkel - Variante 4/5: Zahlen-Karussell nach HERO Insights mit KfW- und Bitkom-Zahlen, Noah als Teaser, hell und dunkel - post9-varianten.html zeigt alle Varianten untereinander - Menüs und Übersicht um die Varianten ergänzt, Regel zu 1:1 in rules.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3796 lines
171 KiB
HTML
3796 lines
171 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<script>
|
||
/* Uebersichts-Modus: die Seite laeuft dann als Kachel in uebersicht.html.
|
||
Das Attribut muss vor dem ersten Zeichnen stehen, sonst blitzt die
|
||
Bedienoberflaeche kurz auf. Deshalb hier oben und nicht im Hauptskript. */
|
||
if (location.search.indexOf("nur=streifen") >= 0)
|
||
document.documentElement.dataset.nur = "streifen";
|
||
</script>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PostGrid Post 1 · Lösung</title>
|
||
<link rel="stylesheet" href="./assets/fonts/manrope.css">
|
||
<style>
|
||
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
|
||
Plain hex on purpose — the export libraries choke on oklch()/Tailwind.
|
||
--dark-bg #30313D is the colour of the dark story section on the site; it
|
||
carries the whole shape layer here. */
|
||
:root {
|
||
--accent-yellow: #F1B729;
|
||
--accent-teal: #0D6E6E;
|
||
--surface: #FFFFFF;
|
||
--bg-primary: #FAFAFA;
|
||
--dark-bg: #30313D;
|
||
--accent-yellow-light: #FFE082;
|
||
--accent-yellow-dark: #3E2C0A;
|
||
--text-primary: #1A1A1A;
|
||
--text-secondary:#555555;
|
||
--text-on-dark: #C8D0DC;
|
||
--text-white: #FFFFFF;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html, body { overscroll-behavior: none; }
|
||
body {
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
background: #17181F; color: #E8EAEE;
|
||
height: 100vh; overflow: hidden;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
|
||
/* ─── Menueleiste ────────────────────────────────────────────────────────
|
||
Statt einer Knopfreihe fuer die Projekte eine Leiste wie in einem
|
||
Bildprogramm. Sie bedient keine eigene Logik, sondern klickt die
|
||
vorhandenen Schalter im Bedienfeld an: sonst gaebe es zwei Wahrheiten
|
||
ueber denselben Zustand. */
|
||
.menubar {
|
||
flex: none; display: flex; align-items: stretch; gap: 2px;
|
||
height: 34px; padding: 0 8px;
|
||
background: #14161C; border-bottom: 1px solid #24262F;
|
||
font-size: 13px; user-select: none; position: relative; z-index: 60;
|
||
}
|
||
.menubar .marke {
|
||
display: flex; align-items: center; padding: 0 12px 0 4px;
|
||
font-weight: 800; letter-spacing: -0.01em; color: #E8EAEE;
|
||
}
|
||
.menu { position: relative; }
|
||
.menu > button {
|
||
height: 100%; padding: 0 11px; cursor: pointer;
|
||
background: none; border: 0; color: #C7CCD6;
|
||
font-family: inherit; font-size: 13px;
|
||
}
|
||
.menu > button:hover { background: #1D1F26; }
|
||
.menu[data-offen="ja"] > button { background: #262932; color: #fff; }
|
||
.menu > .liste { display: none; }
|
||
.menu[data-offen="ja"] > .liste {
|
||
display: block; position: absolute; top: 100%; left: 0;
|
||
min-width: 232px; padding: 5px;
|
||
background: #1D1F26; border: 1px solid #2E313B; border-radius: 9px;
|
||
}
|
||
.menu .liste button {
|
||
display: flex; width: 100%; align-items: center; gap: 14px;
|
||
padding: 6px 9px; cursor: pointer; text-align: left;
|
||
background: none; border: 0; border-radius: 6px;
|
||
color: #C7CCD6; font-family: inherit; font-size: 13px;
|
||
}
|
||
.menu .liste button:hover { background: #2A2D36; color: #fff; }
|
||
.menu .liste button:disabled { opacity: .38; cursor: default; background: none; }
|
||
.menu .liste button .kurz { margin-left: auto; color: #6E7480; font-size: 11.5px; }
|
||
.menu .liste button .haken { width: 12px; color: var(--accent-yellow); }
|
||
.menu .liste hr { height: 1px; margin: 5px 4px; background: #2E313B; border: 0; }
|
||
|
||
.arbeitsflaeche { flex: 1; display: flex; min-height: 0; }
|
||
|
||
/* Inhaltsfelder der gewaehlten Slide. Eine Spalte, damit lange Texte nicht
|
||
in einer schmalen Zelle kleben. */
|
||
#inhalt { display: flex; flex-direction: column; gap: 4px; }
|
||
.inh-label { font-size: 11px; color: #8A8F9B; letter-spacing: .04em; margin-top: 4px; }
|
||
#inhalt input, #inhalt textarea, #inhalt select {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 7px 8px;
|
||
font-family: inherit; font-size: 12px; line-height: 1.45;
|
||
}
|
||
#inhalt textarea { resize: vertical; min-height: 46px; }
|
||
#inhalt input:focus, #inhalt textarea:focus, #inhalt select:focus {
|
||
outline: none; border-color: var(--accent-yellow);
|
||
}
|
||
#slide-ops button { flex: none; padding: 8px 10px; }
|
||
|
||
/* Das Feld des gewaehlten Elements. Es steht rechts, damit links die Slide
|
||
und das Allgemeine bleiben und beides nicht durcheinandergeht. Es faehrt
|
||
nur bei einer Auswahl ein; die Buehne rechnet ihre Einpassung danach neu,
|
||
sonst haenge der Streifen schief. */
|
||
.panel-rechts {
|
||
display: none;
|
||
border-right: none; border-left: 1px solid #24262F;
|
||
padding: 22px 22px 40px 22px;
|
||
}
|
||
body[data-elem="an"] .panel-rechts { display: block; }
|
||
.el-kopf { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||
.el-kopf h1 { flex: 1; margin: 0; }
|
||
.el-zu {
|
||
flex: none; width: 26px; height: 26px; border-radius: 7px; cursor: pointer;
|
||
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
|
||
font-family: inherit; font-size: 13px; line-height: 1;
|
||
}
|
||
.el-zu:hover { background: #2A2D36; color: #C7CCD6; }
|
||
#el-inhalt textarea {
|
||
width: 100%; box-sizing: border-box; resize: vertical; min-height: 62px;
|
||
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 7px 8px;
|
||
font-family: inherit; font-size: 12px; line-height: 1.45;
|
||
}
|
||
#el-inhalt textarea:focus { outline: none; border-color: var(--accent-yellow); }
|
||
#el-inhalt .abschnitt { font-size: 11px; color: #8A8F9B; letter-spacing: .09em;
|
||
text-transform: uppercase; margin: 14px 0 6px; }
|
||
#el-inhalt .opts button { flex: 1; }
|
||
|
||
/* 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;
|
||
}
|
||
.saved.on { opacity: 1; }
|
||
.forget { font-size: 11px; color: #6E7480; background: none; border: 0;
|
||
cursor: pointer; padding: 0; font-family: inherit; }
|
||
.forget:hover { color: #EB7C6B; }
|
||
|
||
/* ─── Control panel ──────────────────────────────────────────────────── */
|
||
/* max-width statt nur width: bei einem schmalen Fenster kaempfen linkes
|
||
Feld, Buehne und rechtes Feld um denselben Platz, und ohne Obergrenze
|
||
schiebt sich das rechte aus dem Fenster. Dann ist es nicht mehr
|
||
zuzumachen, weil sein Knopf mit hinausrutscht.
|
||
overflow statt overflow-y aus demselben Grund: wird das Feld schmal,
|
||
muss sein Inhalt waagerecht erreichbar bleiben. */
|
||
.panel {
|
||
width: 306px; max-width: 42vw; flex: none; height: 100%; overflow: auto;
|
||
padding: 28px 22px 40px 28px;
|
||
border-right: 1px solid #24262F; background: #17181F;
|
||
}
|
||
.panel h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
|
||
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 22px; line-height: 1.5; }
|
||
.panel a { color: #8A8F9B; }
|
||
|
||
.group { margin-bottom: 20px; }
|
||
.group > label,
|
||
.group > summary {
|
||
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
/* Zugeklappte Kategorien: der Marker ersetzt den Standard-Pfeil, damit die
|
||
Zeile bündig zur offenen Gruppe bleibt. */
|
||
details.group > summary {
|
||
cursor: pointer; list-style: none; margin-bottom: 0;
|
||
padding: 5px 0; display: flex; align-items: center; gap: 7px;
|
||
transition: color .15s;
|
||
}
|
||
details.group > summary::-webkit-details-marker { display: none; }
|
||
details.group > summary::before {
|
||
content: "▸"; font-size: 10px; color: #4E5360; transition: transform .15s;
|
||
}
|
||
details.group[open] > summary::before { transform: rotate(90deg); }
|
||
details.group > summary:hover { color: #C7CCD6; }
|
||
details.group[open] > summary { color: #8A8F9B; margin-bottom: 9px; }
|
||
details.group { margin-bottom: 4px; }
|
||
details.group[open] { margin-bottom: 20px; }
|
||
|
||
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||
.opts.one { grid-template-columns: 1fr; }
|
||
.opts.three { grid-template-columns: 1fr 1fr 1fr; }
|
||
.opts button {
|
||
font-family: inherit; font-size: 12.5px; font-weight: 600;
|
||
padding: 10px; border-radius: 8px; cursor: pointer; text-align: left;
|
||
background: #22242C; color: #C7CCD6; border: 1px solid #2E313B;
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.opts.three button { justify-content: center; text-align: center; padding: 10px 4px; }
|
||
.opts button:hover { background: #2A2D36; }
|
||
.opts button[aria-pressed="true"] {
|
||
background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow);
|
||
}
|
||
.num {
|
||
width: 18px; height: 18px; border-radius: 4px; flex: none;
|
||
display: grid; place-items: center; font-size: 10.5px; font-weight: 800;
|
||
background: rgba(255,255,255,.09);
|
||
}
|
||
button[aria-pressed="true"] .num { background: rgba(0,0,0,.16); }
|
||
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid rgba(0,0,0,.18); }
|
||
|
||
.row { display: flex; gap: 8px; }
|
||
.row button, .btn {
|
||
font-family: inherit; font-size: 13px; font-weight: 700;
|
||
padding: 12px 13px; border-radius: 8px; cursor: pointer; border: none;
|
||
background: var(--accent-yellow); color: #1A1A1A; flex: 1; transition: filter .15s;
|
||
}
|
||
.row button:hover, .btn:hover { filter: brightness(1.07); }
|
||
.btn.secondary { background: #22242C; color: #C7CCD6; border: 1px solid #2E313B; }
|
||
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
||
|
||
.keys { font-size: 12px; color: #6E7480; line-height: 1.85; }
|
||
.keys kbd {
|
||
background: #22242C; border: 1px solid #343843; border-bottom-width: 2px;
|
||
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
||
font-size: 11px; color: #C7CCD6;
|
||
}
|
||
#el-inhalt { font-size: 12px; line-height: 1.55; color: #C7CCD6; }
|
||
#el-inhalt .dim { color: #6E7480; }
|
||
/* Ziehen ist gut fuer grob, tippen fuer genau. Ohne Eingabefelder laesst
|
||
sich ein Wert nicht ueber mehrere Slides hinweg exakt gleich setzen. */
|
||
#el-inhalt .fields {
|
||
display: grid; grid-template-columns: 42px 1fr 20px 1fr;
|
||
gap: 6px 7px; align-items: center;
|
||
}
|
||
#el-inhalt label { color: #6E7480; font-size: 11px; }
|
||
#el-inhalt input {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #14161C; border: 1px solid #343845; border-radius: 6px;
|
||
color: #E7EAF0; font: inherit; font-variant-numeric: tabular-nums;
|
||
padding: 4px 6px; text-align: right; -moz-appearance: textfield;
|
||
}
|
||
#el-inhalt input::-webkit-outer-spin-button,
|
||
#el-inhalt input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
#el-inhalt input:focus { outline: none; border-color: var(--accent-yellow); }
|
||
#el-inhalt .hint { color: #6E7480; margin-top: 6px; }
|
||
#el-inhalt .row { margin-top: 0; }
|
||
.std { margin-bottom: 22px; }
|
||
.std summary {
|
||
cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
.std table { width: 100%; border-collapse: collapse; font-size: 11.5px; color: #8A8F9B; }
|
||
.std td { padding: 3px 0; }
|
||
.std td:last-child { text-align: right; color: #C7CCD6; font-variant-numeric: tabular-nums; }
|
||
.std h4 { font-size: 11px; color: #6E7480; margin: 10px 0 4px; font-weight: 700; }
|
||
|
||
.status { margin-top: 12px; font-size: 12.5px; color: #6E7480; min-height: 18px; }
|
||
.status.ok { color: #6FCF97; }
|
||
.status.err { color: #EB7C6B; }
|
||
|
||
/* ─── Stage ──────────────────────────────────────────────────────────── */
|
||
/* ─── Arbeitsfläche ───────────────────────────────────────────────────────
|
||
Klar abgesetzt von der Sidebar: eigener Grund, eigener Scroll, eigener
|
||
Zoom. Was hier passiert, lässt die Bedienleiste unberührt. */
|
||
.stage {
|
||
/* Nicht 100vh: darueber sitzt die Menueleiste. Mit voller
|
||
Fensterhoehe rutscht die Fussleiste samt Zoom und Merkzettel
|
||
unter den unteren Rand. */
|
||
flex: 1; position: relative; height: 100%; overflow: hidden;
|
||
background-color: #101116;
|
||
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
|
||
background-size: 24px 24px;
|
||
}
|
||
/* overscroll-behavior contain hält die Wisch-Geste des Browsers zurück:
|
||
Ohne das springt horizontales Trackpad-Scrollen am linken Rand auf den
|
||
vorigen Tab, statt im Canvas zu bleiben. */
|
||
.scroller {
|
||
position: absolute; inset: 0; overflow: auto;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.scroller.panning { cursor: grabbing; }
|
||
.scroller.panready { cursor: grab; }
|
||
/* width/height: max-content ist hier entscheidend. Mit reinem
|
||
"justify-content: center" ragt breiter Inhalt über beide Seiten hinaus,
|
||
aber scrollLeft kann nicht negativ werden: Der linke Teil wäre dann
|
||
unerreichbar. So wächst die Fläche mit dem Inhalt, das Zentrieren greift
|
||
nur noch, solange etwas Platz übrig ist. */
|
||
.canvas {
|
||
width: max-content; min-width: 100%;
|
||
height: max-content; min-height: 100%;
|
||
box-sizing: border-box; padding: 60px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.hud {
|
||
position: absolute; left: 0; right: 0; bottom: 0;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 16px; padding: 14px 20px; pointer-events: none;
|
||
background: linear-gradient(transparent, rgba(16,17,22,.92) 55%);
|
||
}
|
||
.hud > * { pointer-events: auto; }
|
||
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
|
||
|
||
.zoom { display: flex; align-items: center; gap: 4px; }
|
||
.zoom button {
|
||
font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
|
||
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 6px 10px; min-width: 32px;
|
||
}
|
||
.zoom button:hover { background: #262932; }
|
||
.zoom .level { min-width: 58px; text-align: center; color: #8A8F9B; font-variant-numeric: tabular-nums; }
|
||
/* Merkzettel neben der Zoomleiste: ein Stand, den man von Hand ablegt und
|
||
bewusst zurueckholt. Der laufende Projektstand speichert dauernd von
|
||
selbst und taugt deshalb nicht als Rueckfallpunkt. */
|
||
.snap { display: flex; align-items: center; gap: 4px; margin-right: 10px; }
|
||
.snap button {
|
||
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
|
||
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 6px 11px;
|
||
}
|
||
.snap button:hover { background: #262932; }
|
||
.snap button:disabled { opacity: .4; cursor: default; }
|
||
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
|
||
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
|
||
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
|
||
.strip-wrap { overflow: hidden; flex: none; align-self: flex-start; }
|
||
/* Bei gebrochenem Maßstab (etwa 41 %) ist eine Slide 442,8 px breit. Der
|
||
Browser rundet jede Box einzeln, und zwischen ihnen blitzt für Sekunden-
|
||
bruchteile eines Pixels der dunkle Grund durch. Der Streifen bekommt
|
||
deshalb die Slide-Farbe als Untergrund: Die Fuge zeigt dann dieselbe Farbe
|
||
und verschwindet. Im Export gibt es sie ohnehin nicht, dort wird jede
|
||
Slide einzeln in exakten 1080 x 1350 gerendert. */
|
||
.strip { display: flex; transform-origin: top left; background: #30313D; }
|
||
.holder { position: relative; flex: none; cursor: pointer; }
|
||
/* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie
|
||
im Export landet. */
|
||
.holder.sel::after {
|
||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||
outline: 10px solid var(--accent-yellow); outline-offset: -5px;
|
||
}
|
||
.strip[data-view="einzeln"] .holder { display: none; }
|
||
.strip[data-view="einzeln"] .holder.sel { display: block; }
|
||
|
||
/* ─── Slide base — exactly Instagram 4:5 (1080 × 1350) ───────────────── */
|
||
.slide {
|
||
width: 1080px; height: 1350px;
|
||
position: relative; overflow: hidden;
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
}
|
||
|
||
/* ─── Grund ──────────────────────────────────────────────────────────────
|
||
Ein einziger Grund: das Raster. Formen, Waben, Punkte und die grosse
|
||
G-Marke sind raus, ebenso das durchgehende Band mit den Sechsecken auf
|
||
den Naehten. Einstellbar bleibt nur die Staerke. */
|
||
.bg { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||
.slide[data-int="dezent"] .bg { --int-mul: .55; }
|
||
.slide[data-int="kraeftig"] .bg { --int-mul: 1; }
|
||
.layer-raster { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||
|
||
/* Optische Ausrückung des Tags: Der Kasten steht bündig zur Inhaltskante,
|
||
sein TEXT dadurch um die 20 px Innenabstand eingerückt gegenüber der
|
||
Headline. Das negative Margin holt den Text auf dieselbe Linie. Zum
|
||
Vergleichen umschaltbar. */
|
||
|
||
.layer-raster {
|
||
background-image:
|
||
linear-gradient(var(--pat-color) 2px, transparent 2px),
|
||
linear-gradient(90deg, var(--pat-color) 2px, transparent 2px);
|
||
background-size: 78px 78px;
|
||
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
|
||
}
|
||
|
||
/* ─── Bearbeitung im Canvas ───────────────────────────────────────────────
|
||
Verschieben läuft über --ex/--ey, also über transform statt über den
|
||
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
|
||
jederzeit exakt auf 0 zurücksetzen. Elemente mit eigener Transformation
|
||
komponieren die Offsets in ihre bestehende Regel hinein. */
|
||
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)) rotate(var(--rot, 0deg)); }
|
||
|
||
.slide[data-editmode="an"] [data-edit] { cursor: grab; }
|
||
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
|
||
.slide[data-editmode="an"] [data-edit].picked { outline: 4px solid var(--accent-yellow); outline-offset: 6px; }
|
||
|
||
/* ─── Hilfslinien beim Ziehen ────────────────────────────────────────────
|
||
Nur solange gezogen wird und nur auf der Slide, an der gezogen wird: das
|
||
Raster deckt die 1080 × 1350 ab, nicht das Canvas. Ein Raster über alle
|
||
Slides hinweg hilft nicht, weil jede Slide ihre eigene Bühne ist.
|
||
pointer-events bleiben aus, sonst reisst die Ebene den Zug ab. */
|
||
.guides { position: absolute; inset: 0; z-index: 9; display: none; pointer-events: none; }
|
||
.slide[data-guides="an"] .guides { display: block; }
|
||
/* 90 px = 1080/12 = 1350/15, teilt beide Kanten ohne Rest. */
|
||
.guides .grid {
|
||
position: absolute; inset: 0;
|
||
background-image:
|
||
repeating-linear-gradient(to right, var(--guide-line) 0 1px, transparent 1px 90px),
|
||
repeating-linear-gradient(to bottom, var(--guide-line) 0 1px, transparent 1px 90px);
|
||
}
|
||
/* Der Innenabstand aus den Regeln, damit man sieht, wann Text ihn verlässt. */
|
||
.guides .safe { position: absolute; inset: 92px; border: 2px dashed var(--guide-safe); }
|
||
.guides .ax { position: absolute; background: var(--guide-axis); }
|
||
.guides .ax-v { left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px; }
|
||
.guides .ax-h { top: 50%; left: 0; right: 0; height: 2px; margin-top: -1px; }
|
||
/* Liegt die Mitte des Elements auf der Achse, wird sie kräftig: das ist die
|
||
Rückmeldung, auf die es beim Zentrieren ankommt. */
|
||
.guides .ax.on { background: var(--accent-yellow); }
|
||
.guides .ax-v.on { width: 4px; margin-left: -2px; }
|
||
.guides .ax-h.on { height: 4px; margin-top: -2px; }
|
||
/* Fadenkreuz des gezogenen Elements, in Slide-Koordinaten gesetzt. */
|
||
.guides .mid { position: absolute; background: var(--guide-mid); }
|
||
.guides .mid-v { top: 0; bottom: 0; width: 2px; margin-left: -1px; }
|
||
.guides .mid-h { left: 0; right: 0; height: 2px; margin-top: -1px; }
|
||
|
||
/* Jede Slide trägt nicht nur ihren eigenen Inhalt, sondern auch den ihrer
|
||
Nachbarn, verschoben um deren Position. Dadurch läuft ein nach links
|
||
geschobenes Element aus Slide 3 tatsächlich in Slide 2 hinein, statt am
|
||
Rand zu verschwinden. Der Hintergrund bleibt pro Slide, die Identität
|
||
also erhalten. */
|
||
.sheet {
|
||
position: absolute; top: 0; height: 100%;
|
||
left: calc(var(--slide-i, 0) * -1080px);
|
||
width: var(--sheet-w, 5400px); z-index: 1;
|
||
}
|
||
.page { position: absolute; top: 0; width: 1080px; height: 1350px; }
|
||
|
||
.content { position: relative; z-index: 2; padding: 92px 92px 0; }
|
||
|
||
/* Section badge, straight from the site: .chamfer-badge in globals.css with
|
||
accent-yellow-light on accent-yellow-dark. Scaled from the 12px web size to
|
||
this canvas; the 5px corner cut scales with it.
|
||
Ausgeblendet statt entfernt: das Tag steht im Textfluss, ohne seine Box
|
||
ruecken Ueberschrift und Text nach oben. visibility haelt den Platz,
|
||
display: none wuerde ihn aufgeben. */
|
||
.badge {
|
||
display: inline-block; margin-bottom: 38px;
|
||
font-size: 26px; font-weight: 600; letter-spacing: 0.1em;
|
||
padding: 9px 20px;
|
||
background: var(--accent-yellow-light);
|
||
color: var(--accent-yellow-dark);
|
||
visibility: hidden;
|
||
clip-path: polygon(
|
||
11px 0, calc(100% - 11px) 0, 100% 11px, 100% calc(100% - 11px),
|
||
calc(100% - 11px) 100%, 11px 100%, 0 calc(100% - 11px), 0 11px
|
||
);
|
||
}
|
||
.slide[data-tagout="an"] .badge { margin-left: -20px; }
|
||
|
||
.headline {
|
||
font-size: 74px; font-weight: 800;
|
||
letter-spacing: -0.03em; line-height: 1.06;
|
||
color: var(--head-color);
|
||
}
|
||
.headline .a, .title-head .a, .wabe-tagline .a {
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 48' preserveAspectRatio='none'%3E%3Cpath d='M1.5,12 C58,4.5 168,9.5 312,5 C316,7 318,11 317.5,16 C318.5,26 316,36 313,42.5 C232,47.5 104,41.5 6.5,44.5 C2.5,42 0.5,36 1,29 C0.5,22 0.8,16.5 1.5,12 Z' fill='%23F1B729'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: 0 68%;
|
||
background-size: 100% 62%;
|
||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||
padding: 0 14px; margin: 0 -6px;
|
||
}
|
||
|
||
.body {
|
||
margin-top: 30px; font-size: 30px; font-weight: 500;
|
||
line-height: 1.48; color: var(--body-color); max-width: 830px;
|
||
}
|
||
|
||
/* ─── Device mockup ──────────────────────────────────────────────────────
|
||
A chamfered frame, not a photoreal laptop: the website already frames every
|
||
screenshot this way (.screenshot-frame in globals.css), so this is the
|
||
on-brand shape and it ties the posts to the site. */
|
||
.mock {
|
||
position: absolute; z-index: 1;
|
||
left: var(--mock-left, 92px);
|
||
width: var(--mock-w, 1090px); /* default bleeds ~100px past the right edge */
|
||
bottom: 96px;
|
||
padding: 12px;
|
||
background: var(--frame-bg);
|
||
clip-path: polygon(
|
||
22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px),
|
||
calc(100% - 22px) 100%, 22px 100%, 0 calc(100% - 22px), 0 22px
|
||
);
|
||
/* Rahmenform ist umschaltbar. Bei "rund" muss clip-path weichen, sonst
|
||
gewinnt der Schnitt gegen border-radius; bei "aus" fällt auch Polsterung
|
||
und Fläche weg.
|
||
Kein Schatten: die Gestaltung arbeitet flächig, Tiefe entsteht über
|
||
Überlappung und Rahmenfarbe, nicht über Weichzeichnung. */
|
||
}
|
||
.slide[data-tilt="an"] .mock {
|
||
transform: translate(var(--ex, 0px), var(--ey, 0px))
|
||
perspective(2600px) rotateY(-7deg) rotateX(2.5deg)
|
||
rotate(calc(-1.2deg + var(--rot, 0deg)));
|
||
}
|
||
|
||
.mock-inner {
|
||
position: relative; overflow: hidden;
|
||
aspect-ratio: var(--mock-ar, 1.6); width: 100%;
|
||
background: #fff;
|
||
clip-path: polygon(
|
||
14px 0, calc(100% - 14px) 0, 100% 14px, 100% calc(100% - 14px),
|
||
calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px
|
||
);
|
||
}
|
||
|
||
/* Ein zweites Bild pro Slide. Es ist bewusst wieder ein .mock, damit alle
|
||
Rahmenformen, Schatten und Perspektiven ohne Sonderregeln greifen; nur
|
||
Position, Größe und Stapelebene kommen von aussen. */
|
||
.mock-2 {
|
||
z-index: var(--m2-z, 2);
|
||
bottom: var(--m2-bottom, 96px);
|
||
}
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-back,
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-mid { display: none; }
|
||
|
||
/* ─── Notizzettel ────────────────────────────────────────────────────────
|
||
Nachbau der StickyNote aus dem Pitch-Projekt
|
||
(ui/components/StickyNote/StickyNote.tsx), Variante "purple". Farbwerte
|
||
und Aufbau sind von dort übernommen, die Maße hochskaliert: die Vorlage
|
||
ist 200 px breit bei 14 px Text, hier liegt sie auf einem 1080er Blatt.
|
||
Bewusst echtes HTML statt Screenshot, damit die Kanten beim Export
|
||
genauso scharf sind wie die Schrift und der Text austauschbar bleibt. */
|
||
.note {
|
||
position: absolute; z-index: var(--note-z, 3);
|
||
left: var(--note-left, 60px);
|
||
bottom: var(--note-bottom, 150px);
|
||
width: var(--note-w, 320px);
|
||
border-radius: 10px; overflow: hidden;
|
||
font-family: Manrope, sans-serif;
|
||
}
|
||
.note-head {
|
||
height: 50px; background: #ce93d8;
|
||
display: flex; align-items: center; justify-content: flex-end;
|
||
padding-right: 12px;
|
||
}
|
||
.note-head svg { width: 26px; height: 26px; display: block; }
|
||
.note-body {
|
||
background: #f3e5f5; color: #424242;
|
||
padding: 18px 20px 26px; min-height: 168px;
|
||
font-size: 23px; font-weight: 400; line-height: 1.5;
|
||
}
|
||
/* Der Anfasser unten rechts: zwei ineinanderliegende Ecken, wie im Original
|
||
zwei Spans mit border-b/border-r in zwei Deckkraften. */
|
||
.note-grip { position: absolute; right: 9px; bottom: 9px; }
|
||
.note-grip i {
|
||
position: absolute; right: 0; bottom: 0; display: block;
|
||
border-right: 2px solid rgba(66,66,66,.45);
|
||
border-bottom: 2px solid rgba(66,66,66,.45);
|
||
}
|
||
.note-grip i:first-child { width: 17px; height: 17px; }
|
||
.note-grip i:last-child {
|
||
width: 10px; height: 10px;
|
||
border-right-color: rgba(66,66,66,.65);
|
||
border-bottom-color: rgba(66,66,66,.65);
|
||
}
|
||
|
||
.slide[data-frame="rund"] .mock { clip-path: none; border-radius: 30px; }
|
||
.slide[data-frame="rund"] .mock-inner { clip-path: none; border-radius: 20px; }
|
||
|
||
.slide[data-frame="aus"] .mock {
|
||
clip-path: none; padding: 0; background: none; border-radius: 0;
|
||
}
|
||
.slide[data-frame="aus"] .mock-inner { clip-path: none; border-radius: 0; }
|
||
|
||
/* ─── Stapel ─────────────────────────────────────────────────────────────
|
||
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg.
|
||
Getrennt werden die Blätter nicht durch Schatten, sondern durch den
|
||
Versatz und eine Haarlinie: auf der hellen Variante läge sonst Weiss auf
|
||
Weiss und der Stapel wäre nicht mehr zu erkennen. */
|
||
.sheet-back, .sheet-mid {
|
||
display: none; position: absolute; inset: 0; z-index: 0;
|
||
background: #fff; border-radius: 22px; overflow: hidden;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .mock {
|
||
clip-path: none; padding: 0; background: none;
|
||
}
|
||
.slide[data-frame="stapel"] .mock-inner {
|
||
clip-path: none; border-radius: 22px; z-index: 2;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-mid {
|
||
display: block; z-index: 1;
|
||
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-back {
|
||
display: block;
|
||
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
|
||
}
|
||
/* Das hinterste Blatt zeigt bewusst einen anderen Screen: der Stapel soll
|
||
nach mehreren Ansichten aussehen, nicht nach einer Kopie. */
|
||
.sheet-back img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
}
|
||
|
||
/* Screens are ~2000px wide landscape captures; showing them whole makes the
|
||
UI text unreadable at this size, so each one is zoomed onto its telling
|
||
region via the per-slide vars below. */
|
||
.mock-inner img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(var(--img-scale, 1)) translate(var(--img-x, 0%), var(--img-y, 0%));
|
||
}
|
||
|
||
/* ─── Title layout "wabe" ────────────────────────────────────────────────
|
||
The screenshot sits inside the brand hexagon. Its proportions are taken
|
||
from the logo mark itself (384.5 x 332.9, i.e. 2/sqrt3), so the shape is a
|
||
true regular hexagon rather than a squashed one. */
|
||
.hex-frame {
|
||
padding: 15px; background: var(--frame-bg);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot {
|
||
position: relative; overflow: hidden;
|
||
width: 748px; height: 648px;
|
||
background: #fff;
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.533) translate(-17.4%, 0%);
|
||
}
|
||
|
||
/* Laptop mockup. The display was knocked out of the PNG, so the screenshot
|
||
sits BEHIND the device and the notch plus the rounded corners stay on top,
|
||
the way a real mockup reads. Rect measured from the file: 739 x 489 of
|
||
1050 x 700. */
|
||
.laptop { position: relative; width: 960px; }
|
||
.laptop-frame { width: 100%; display: block; position: relative; z-index: 2; }
|
||
.laptop-screen {
|
||
position: absolute; z-index: 1; overflow: hidden;
|
||
left: 14.857%; top: 11.857%; width: 70.381%; height: 69.857%;
|
||
background: #fff;
|
||
}
|
||
/* Scoped to the screenshot: an unscoped ".laptop-screen img" also caught the
|
||
wordmark in the logo variant and forced it to width 100% at top left. */
|
||
.laptop-screen > img.laptop-shot {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.566) translate(-18.1%, 0%);
|
||
}
|
||
/* Blurred variant: just enough softening to seat the wordmark, the app stays
|
||
readable underneath. Slightly overscaled so the blur never pulls the empty
|
||
edge of the clip box into frame, and re-centred for that scale. */
|
||
.laptop-screen[data-blur="an"] > img.laptop-shot {
|
||
filter: blur(5px);
|
||
transform: scale(1.04) translate(-1.9%, -2.2%);
|
||
}
|
||
|
||
/* Variant with the wordmark on the display: dead centre, same 430px as the
|
||
lockup in the plain laptop layout. No scrim, the screenshot stays as it is. */
|
||
.laptop-screen > img.screen-logo {
|
||
position: absolute; z-index: 2;
|
||
left: 50%; top: 50%;
|
||
transform: translate(-50%, -50%) translate(var(--ex, 0px), var(--ey, 0px));
|
||
width: 430px; height: auto;
|
||
}
|
||
|
||
/* ─── Laptop mit Board ───────────────────────────────────────────────────
|
||
Fertiges Mockup: der Screen ist Teil des Bildes. Kein ausgeknocktes
|
||
Display, keine Projektion per matrix3d, kein zweiter Screenshot dahinter.
|
||
Damit fallen auch die gemessenen Vierecke und Eckradien weg, die das
|
||
vorherige Szenen-Mockup gebraucht hat. */
|
||
/* Breiter als das vorherige Mockup, weil dieses Bild links und rechts rund
|
||
ein Viertel freien Rand mitbringt: bei 880px ist das Geraet selbst wieder
|
||
etwa so gross wie vorher, und 880 bleibt innerhalb der 888px, die zwischen
|
||
den Innenraendern der Slide frei sind. */
|
||
.szene { position: relative; width: 880px; }
|
||
.szene-dev { width: 100%; height: auto; display: block; }
|
||
|
||
/* Das Mockup steht im DOM nach der Ueberschrift und lag damit darueber.
|
||
Ein gross gezogenes Geraet hat sie deshalb verdeckt und im
|
||
Bearbeiten-Modus die Klicks geschluckt, sodass man nicht mehr an sie
|
||
herankam. Die Zeile oben liegt jetzt darueber. */
|
||
.page.title[data-layout="szene"] .title-top { position: relative; z-index: 3; }
|
||
.page.title[data-layout="szene"] .szene { z-index: 1; }
|
||
|
||
.laptop-missing {
|
||
width: 940px; padding: 80px 40px; text-align: center;
|
||
border: 3px dashed var(--shape-line); color: var(--body-color);
|
||
font-size: 26px; line-height: 1.5;
|
||
}
|
||
|
||
.title-top { transform: translateX(-140px); }
|
||
.title-top[data-edit] { transform: translate(calc(-140px + var(--ex, 0px)), var(--ey, 0px)); }
|
||
/* Footer variant: everything on the centre axis, wordmark closes the slide.
|
||
No left shift here, so the lockup is not tied to the dark block. */
|
||
.page.title[data-layout="laptop-footer"] .title-top { transform: none; }
|
||
.page.title[data-layout="laptop-footer"] .wabe-claim { margin-top: 26px; }
|
||
.page.title[data-layout="laptop-footer"] .wabe-logo { margin-top: 0; }
|
||
|
||
.page.title[data-layout="laptop-logo"] .wabe-tagline,
|
||
.page.title[data-layout="laptop-blur"] .wabe-tagline { margin-top: 172px; }
|
||
.wabe-logo { width: 430px; height: auto; display: block; margin-top: 37px; }
|
||
.wabe-tagline {
|
||
white-space: nowrap;
|
||
font-size: 74px; font-weight: 800; line-height: 1.06;
|
||
letter-spacing: -0.03em; color: var(--head-color);
|
||
}
|
||
.wabe-claim {
|
||
margin-top: 4px; font-size: 31px; font-weight: 500; line-height: 1.44;
|
||
color: var(--body-color); max-width: 800px;
|
||
}
|
||
|
||
/* ─── Title slide ────────────────────────────────────────────────────── */
|
||
.page.title .content {
|
||
padding: 0 104px; height: 100%;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
text-align: center;
|
||
}
|
||
.page.title[data-layout="wabe"] .content,
|
||
.page.title[data-layout="laptop"] .content,
|
||
.page.title[data-layout="laptop-logo"] .content,
|
||
.page.title[data-layout="laptop-blur"] .content,
|
||
.page.title[data-layout="laptop-footer"] .content,
|
||
.page.title[data-layout="szene"] .content {
|
||
padding: 92px 96px; justify-content: space-between;
|
||
}
|
||
.logo-full { width: 560px; height: auto; display: block; margin-bottom: 74px; }
|
||
.slide[data-variant="dunkel"] .logo-dark,
|
||
.slide[data-variant="hell"] .logo-light { display: none; }
|
||
|
||
.title-head {
|
||
font-size: 88px; font-weight: 800;
|
||
letter-spacing: -0.035em; line-height: 1.04;
|
||
color: var(--head-color);
|
||
}
|
||
.title-sub {
|
||
margin-top: 40px; font-size: 31px; font-weight: 500; line-height: 1.45;
|
||
color: var(--body-color); max-width: 760px;
|
||
}
|
||
|
||
|
||
/* ─── Variant: dunkel ────────────────────────────────────────────────── */
|
||
.slide[data-variant="dunkel"] {
|
||
background: var(--dark-bg);
|
||
--head-color: var(--text-white);
|
||
--body-color: #B9BDC9;
|
||
--accent-on: var(--accent-yellow);
|
||
--marker: var(--accent-yellow);
|
||
--frame-bg: rgba(255,255,255,.11);
|
||
--shape-fill: #414252;
|
||
--shape-line: rgba(255,255,255,.10);
|
||
--pat-color: #FFFFFF; --pat-strength: .07;
|
||
--gmark-color: rgba(255,255,255,.13);
|
||
--guide-line: rgba(255,255,255,.16);
|
||
--guide-safe: rgba(255,255,255,.26);
|
||
--guide-axis: rgba(255,255,255,.40);
|
||
--guide-mid: rgba(241,183,41,.80);
|
||
}
|
||
.slide[data-variant="dunkel"] .headline .a,
|
||
.slide[data-variant="dunkel"] .title-head .a { color: #1A1A1A; }
|
||
|
||
/* ─── Variant: hell ──────────────────────────────────────────────────── */
|
||
.slide[data-variant="hell"] {
|
||
background: var(--surface);
|
||
--head-color: var(--text-primary);
|
||
--body-color: var(--text-secondary);
|
||
--accent-on: #C8901A;
|
||
--marker: var(--accent-yellow);
|
||
--frame-bg: #E8EBF0;
|
||
--shape-fill: var(--dark-bg);
|
||
--shape-line: rgba(48,49,61,.13);
|
||
--pat-color: #30313D; --pat-strength: .07;
|
||
--gmark-color: rgba(48,49,61,.16);
|
||
--guide-line: rgba(48,49,61,.20);
|
||
--guide-safe: rgba(48,49,61,.30);
|
||
--guide-axis: rgba(48,49,61,.45);
|
||
--guide-mid: rgba(200,144,26,.75);
|
||
}
|
||
|
||
/* ─── Uebersichts-Modus ──────────────────────────────────────────────────
|
||
Die Seite zeigt dann nur den Streifen, in natuerlicher Groesse und ohne
|
||
eigenen Rahmen: die Uebersicht setzt sie als iframe ein und skaliert
|
||
selbst. Alles Bedienbare faellt weg, auch die Hilfslinien. */
|
||
html[data-nur="streifen"] { overflow: hidden; }
|
||
html[data-nur="streifen"] .menubar,
|
||
html[data-nur="streifen"] .panel,
|
||
html[data-nur="streifen"] .hud,
|
||
html[data-nur="streifen"] .guides { display: none !important; }
|
||
/* Der Auswahlrahmen ist eine Bedienhilfe und hat in der Uebersicht nichts
|
||
verloren -- dort ist keine Slide "die aktuelle". */
|
||
html[data-nur="streifen"] .holder.sel::after { display: none; }
|
||
html[data-nur="streifen"] .stage { background: none; height: auto; }
|
||
html[data-nur="streifen"] .scroller { position: static; overflow: visible; }
|
||
html[data-nur="streifen"] .canvas { padding: 0; display: block;
|
||
width: max-content; min-width: 0; height: max-content; min-height: 0; }
|
||
html[data-nur="streifen"] body, html[data-nur="streifen"] .app {
|
||
height: auto; min-height: 0; background: none; overflow: visible;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="menubar" id="menubar">
|
||
<span class="marke">PostGrid</span>
|
||
|
||
<div class="menu" data-menu="projekt">
|
||
<button type="button">Projekt</button>
|
||
<div class="liste">
|
||
<button type="button" data-gehe="./post1.html">Post 1</button>
|
||
<button type="button" data-gehe="./post2.html">Post 2</button>
|
||
<button type="button" data-gehe="./post2-alt.html">Post 2 · Alternative</button>
|
||
<button type="button" data-gehe="./post3.html">Post 3 · Verbinden statt ersetzen</button>
|
||
<button type="button" data-gehe="./post4.html">Post 4 · Workflow-Engine</button>
|
||
<button type="button" data-gehe="./post5.html">Post 5 · Projektmanagement</button>
|
||
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
|
||
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
|
||
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
|
||
<button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
|
||
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
|
||
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
|
||
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
|
||
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
|
||
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
|
||
<hr>
|
||
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
|
||
<hr>
|
||
<button type="button" data-klick="snap-save">Stand merken</button>
|
||
<button type="button" data-klick="snap-load">Zurück zum gemerkten Stand</button>
|
||
<hr>
|
||
<button type="button" data-klick="forget">Projekt zurücksetzen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="datei">
|
||
<button type="button">Datei</button>
|
||
<div class="liste">
|
||
<button type="button" data-klick="bild-waehlen">Bild importieren</button>
|
||
<hr>
|
||
<button type="button" data-klick="dl-png">Diese Slide laden<span class="kurz">D</span></button>
|
||
<button type="button" data-klick="dl-all">Alle Slides als ZIP<span class="kurz">A</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="bearbeiten">
|
||
<button type="button">Bearbeiten</button>
|
||
<div class="liste">
|
||
<button type="button" data-tun="undo">Rückgängig<span class="kurz">⌘Z</span></button>
|
||
<button type="button" data-tun="redo">Wiederherstellen<span class="kurz">⇧⌘Z</span></button>
|
||
<hr>
|
||
<button type="button" data-klick="reset-one">Element auf Standard</button>
|
||
<button type="button" data-klick="reset-all">Alles auf Standard</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="slide">
|
||
<button type="button">Slide</button>
|
||
<div class="liste">
|
||
<button type="button" data-klick="slide-neu">Neu dahinter</button>
|
||
<button type="button" data-klick="slide-kopie">Kopieren</button>
|
||
<button type="button" data-klick="slide-weg">Löschen</button>
|
||
<hr>
|
||
<button type="button" data-klick="slide-links">Nach vorn</button>
|
||
<button type="button" data-klick="slide-rechts">Nach hinten</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="ansicht">
|
||
<button type="button">Ansicht</button>
|
||
<div class="liste">
|
||
<button type="button" data-tun="vorschau">Vorschau<span class="kurz">P</span></button>
|
||
<hr>
|
||
<button type="button" data-opt="views:w:streifen">Streifen<span class="kurz">W</span></button>
|
||
<button type="button" data-opt="views:w:einzeln">Einzeln</button>
|
||
<hr>
|
||
<button type="button" data-klick="zoom-fit">Einpassen<span class="kurz">0</span></button>
|
||
<button type="button" data-klick="zoom-in">Größer<span class="kurz">+</span></button>
|
||
<button type="button" data-klick="zoom-out">Kleiner<span class="kurz">−</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="arbeitsflaeche">
|
||
|
||
<aside class="panel">
|
||
<h1>Post 1 · Lösung</h1>
|
||
<p class="sub"><span id="kopfzeile">Carousel · 1080 × 1350 px</span><span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
|
||
|
||
<div class="group">
|
||
<label>Slide</label>
|
||
<div class="opts one" id="slides-nav"></div>
|
||
<div class="opts" id="slide-ops" style="margin-top:8px">
|
||
<button id="slide-neu" title="Neue Slide dahinter einfügen">+ Neu</button>
|
||
<button id="slide-kopie" title="Diese Slide kopieren">Kopie</button>
|
||
<button id="slide-weg" title="Diese Slide löschen">Löschen</button>
|
||
<button id="slide-links" title="Eine Position nach vorn">←</button>
|
||
<button id="slide-rechts" title="Eine Position nach hinten">→</button>
|
||
</div>
|
||
</div>
|
||
|
||
<details class="group" data-grp="inhalt-nur-diese-slide">
|
||
<summary>Inhalt (nur diese Slide)</summary>
|
||
<div id="inhalt"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="eigene-bilder">
|
||
<summary>Eigene Bilder</summary>
|
||
<input type="file" id="bild-datei" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden>
|
||
<button class="btn secondary" id="bild-waehlen" style="width:100%">Bilder importieren …</button>
|
||
<div class="hint" id="bild-hinweis">Das Bild landet mittig auf der gewählten Slide.
|
||
Auf eine andere Slide ziehen legt es dort ab. Mit <b>E</b> lässt es sich schieben
|
||
und über <b>A−</b>/<b>A+</b> in der Größe ändern.<br>
|
||
Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="variante">
|
||
<summary>Variante</summary>
|
||
<div class="opts" id="variants">
|
||
<button data-v="dunkel" aria-pressed="true"><span class="swatch" style="background:#30313D"></span>Dunkel</button>
|
||
<button data-v="hell" aria-pressed="false"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Nicht mehr in der Seitenleiste: Layout und Perspektive sind
|
||
post-spezifisch, Vorschau steht oben unter Ansicht, der Tag ist
|
||
ausgeblendet. Die Schalter bleiben trotzdem im Dokument, weil die
|
||
Menueleiste, die Tastenkuerzel und die Abgleich-Funktionen sie
|
||
betaetigen. Sonst gaebe es zwei Wahrheiten ueber denselben Zustand. -->
|
||
<div hidden>
|
||
<div class="opts" id="layouts"></div>
|
||
<div class="opts" id="tagouts">
|
||
<button data-o="an" aria-pressed="true">An</button>
|
||
<button data-o="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
<div class="opts" id="vorschau">
|
||
<button data-p="aus" aria-pressed="true">Aus</button>
|
||
<button data-p="an" aria-pressed="false">An</button>
|
||
</div>
|
||
<div class="opts" id="ints">
|
||
<button data-i="dezent" aria-pressed="true">Dezent</button>
|
||
<button data-i="kraeftig" aria-pressed="false">Kräftig</button>
|
||
</div>
|
||
<div class="opts" id="tilts">
|
||
<button data-t="an" aria-pressed="true">Gekippt</button>
|
||
<button data-t="aus" aria-pressed="false">Gerade</button>
|
||
</div>
|
||
<button id="reset-all">Alles auf Standard</button>
|
||
</div>
|
||
|
||
<details class="std">
|
||
<summary>Standardwerte</summary>
|
||
<div id="std-list"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="ansicht">
|
||
<summary>Ansicht</summary>
|
||
<div class="opts" id="views">
|
||
<button data-w="streifen" aria-pressed="true">Streifen</button>
|
||
<button data-w="einzeln" aria-pressed="false">Einzeln</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="bildrahmen">
|
||
<summary>Bildrahmen</summary>
|
||
<div class="opts three" id="frames">
|
||
<button data-f="chamfer" aria-pressed="true">Chamfer</button>
|
||
<button data-f="rund" aria-pressed="false">Rund</button>
|
||
<button data-f="stapel" aria-pressed="false">Stapel</button>
|
||
<button data-f="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="renderqualit-t">
|
||
<summary>Renderqualität</summary>
|
||
<div class="opts three" id="quality">
|
||
<button data-q="1" aria-pressed="false">1×</button>
|
||
<button data-q="2" aria-pressed="true">2×</button>
|
||
<button data-q="3" aria-pressed="false">3×</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Der Export steht oben unter Datei. Die Knoepfe bleiben trotzdem im
|
||
Dokument: die Menueleiste betaetigt sie, und sie tragen den Zustand
|
||
"noch nicht bereit", solange die Schrift nicht geladen ist. Ohne sie
|
||
muesste beides doppelt gefuehrt werden. -->
|
||
<div hidden>
|
||
<button id="dl-png" disabled>Diese Slide</button>
|
||
<button id="dl-all" disabled>Alle Slides als ZIP</button>
|
||
</div>
|
||
|
||
<details class="group" style="margin-bottom:0" data-grp="tastatur">
|
||
<summary>Tastatur</summary>
|
||
<div class="keys">
|
||
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
|
||
<kbd>L</kbd> Layout · <kbd>I</kbd> Intensität<br>
|
||
<kbd>W</kbd> Ansicht<br>
|
||
<kbd>R</kbd> Bildrahmen<br>
|
||
<kbd>T</kbd> Perspektive<br>
|
||
<kbd>P</kbd> Vorschau · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
|
||
<kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> Zoom · ⌘/Strg + Rad<br>
|
||
<kbd>Leer</kbd> + ziehen verschiebt die Fläche<br>
|
||
<kbd>⌘/Strg</kbd>+<kbd>Z</kbd> Rückgängig (<kbd>⇧</kbd> = wiederherstellen)<br>
|
||
<kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
|
||
</div>
|
||
</details>
|
||
|
||
<div class="status" id="status">Lade Schriften und Screens ...</div>
|
||
</aside>
|
||
|
||
<main class="stage" id="stage">
|
||
<div class="scroller" id="scroller">
|
||
<div class="canvas">
|
||
<div class="strip-wrap" id="strip-wrap">
|
||
<div class="strip" id="deck" data-view="streifen"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hud">
|
||
<p class="stage-meta" id="meta"></p>
|
||
<div class="snap">
|
||
<button id="snap-save" title="Diesen Stand als Rückfallpunkt ablegen">Stand merken</button>
|
||
<button id="snap-load" title="Zum gemerkten Stand zurück" disabled>Zurück zum Stand</button>
|
||
</div>
|
||
<div class="zoom">
|
||
<button id="zoom-out" title="Auszoomen">−</button>
|
||
<button class="level" id="zoom-fit" title="Einpassen">Fit</button>
|
||
<button id="zoom-in" title="Einzoomen">+</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<aside class="panel panel-rechts" id="element-panel">
|
||
<!-- Der Schliessknopf steht vor der Ueberschrift und nicht dahinter. Am
|
||
rechten Rand war er der erste, der verschwand, sobald das Feld nicht
|
||
ganz ins Fenster passte -- und damit war das Feld nicht mehr
|
||
zuzumachen. Vorn ist er immer da. -->
|
||
<div class="el-kopf">
|
||
<button class="el-zu" id="el-zu" title="Auswahl aufheben (Esc)">✕</button>
|
||
<h1 id="el-titel">Element</h1>
|
||
</div>
|
||
<div id="el-inhalt"></div>
|
||
</aside>
|
||
</div>
|
||
|
||
<script src="./vendor/html-to-image.js"></script>
|
||
<script src="./vendor/jszip.min.js"></script>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
/* A syntax or temporal-dead-zone error at this level kills the whole script
|
||
silently: the panel renders from static markup, but nothing is ever built
|
||
and no status is set. Surface it instead of showing an empty stage. */
|
||
window.addEventListener("error", e => {
|
||
const el = document.getElementById("status");
|
||
if (el) {
|
||
el.textContent = "Skriptfehler: " + (e.message || e.error);
|
||
el.className = "status err";
|
||
}
|
||
});
|
||
|
||
const deck = document.getElementById("deck");
|
||
const nav = document.getElementById("slides-nav");
|
||
const statusEl = document.getElementById("status");
|
||
const metaEl = document.getElementById("meta");
|
||
|
||
/* Slide content. Feature bodies are the website copy verbatim
|
||
(app/playground/PlaygroundClient.tsx → solutionModules) so Social and
|
||
Website say exactly the same thing; the headlines are new and short,
|
||
because a full sentence does not work as a carousel hook.
|
||
img-scale/x/y frame each capture on its telling region. */
|
||
const VORLAGEN = [
|
||
{
|
||
key: "titel", type: "title",
|
||
layouts: [{ id: "klassisch", label: "Klassisch" }, { id: "wabe", label: "Wabe" }, { id: "laptop", label: "Laptop" }, { id: "laptop-logo", label: "Laptop · Logo" }, { id: "laptop-blur", label: "Laptop · Blur" }, { id: "laptop-footer", label: "Laptop · Logo unten" }, { id: "szene", label: "Laptop · Board" }],
|
||
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr Handwerker',
|
||
sub: "Die Software, die deine Branchenprogramme nicht ersetzt, sondern verbindet.",
|
||
},
|
||
{
|
||
key: "workflow-engine", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Automation & KI",
|
||
head: '<span class="a">Wenn-dann</span><br>statt Handarbeit.',
|
||
body: "Auslöser wählen, Aktionen dranhängen, fertig. Alles automatisch und ohne eine Zeile Code.",
|
||
img: "./assets/screens/workflow.png",
|
||
mockW: 940, mockAR: 2.366,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
// Die Bausteinliste ragt über den Ablauf hinaus: sie zeigt, woraus er entsteht.
|
||
media2: {
|
||
img: "./assets/screens/workflow-bausteine.png",
|
||
w: 340, ar: 0.8326, left: 700, bottom: 96, z: 2, rot: 0,
|
||
},
|
||
// Der Zettel liegt bewusst schief und leicht über der linken Blattkante:
|
||
// er gehört zum Ablauf, ist aber sichtbar von Hand dazugelegt.
|
||
note: {
|
||
text: "Eigene Notizen direkt am Ablauf hinterlegen.",
|
||
w: 320, left: 44, bottom: 150, z: 3, rot: -3,
|
||
},
|
||
},
|
||
{
|
||
key: "projektmanagement", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Vertrieb",
|
||
head: 'Du siehst auf einen Blick,<br><span class="a">wo alle Auftr\u00e4ge stehen.</span>',
|
||
body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.",
|
||
img: "./assets/screens/pipelines.png",
|
||
// Nur die linke Bildhälfte: Zoom 2 bei Versatz 0 zeigt exakt den Anteil
|
||
// 0 bis 0,5. Das Rahmenverhältnis ist die halbierte Bildbreite.
|
||
mockW: 580, mockAR: 0.8855,
|
||
imgScale: 2, imgX: "0%", imgY: "0%",
|
||
// Detailspalte eines Projekts vor dem Board: zeigt, was hinter einer
|
||
// Karte steckt, ohne das Board zu verdecken.
|
||
media2: {
|
||
img: "./assets/screens/pipelines-detail.png",
|
||
w: 300, ar: 0.781, left: 500, bottom: 52, z: 2, rot: -2,
|
||
},
|
||
},
|
||
{
|
||
key: "chat", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Chat",
|
||
// Zweizeilig, aber knapp: Zeile 2 misst mit Marker 880 px bei 896
|
||
// verfuegbaren. Ein laengeres Wort bricht sofort um.
|
||
head: 'Projekte bekommen<br>automatisch <span class="a">ihren Kanal.</span>',
|
||
body: "Foto von der Baustelle rein, alle sehen's sofort. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
|
||
img: "./assets/screens/chat.png",
|
||
mockW: 1030, mockAR: 1.703,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
// Das hinterste Blatt zeigt sonst den Form Builder bei 3,8-fachem Zoom:
|
||
// unscharf, und die Kopieren- und Papierkorb-Symbole gehören zu einem
|
||
// anderen Feature. Stattdessen der Chat im Hochformat, ungezoomt.
|
||
back: { img: "./assets/screens/chat-tablet.png", imgScale: 1, imgX: "0%", imgY: "0%" },
|
||
// Das Anhang-Menü zeigt, was aus einer Nachricht alles werden kann.
|
||
media2: {
|
||
img: "./assets/screens/chat-menue.png",
|
||
w: 300, ar: 0.866, left: 120, bottom: 55, z: 2, rot: -3,
|
||
},
|
||
},
|
||
{
|
||
key: "form-builder", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Formulare",
|
||
head: 'Formulare selbst bauen.<br><span class="a">Per Drag-and-drop.</span>',
|
||
body: "Von der Kontaktanfrage bis zum Aufmaß mit Unterschrift vor Ort. Teil sie per Link oder QR-Code, jede Einsendung wird automatisch zu Lead, Kontakt oder Auftrag.",
|
||
img: "./assets/screens/form-builder.png",
|
||
mockW: 540, mockAR: 0.75, mockLeft: 270,
|
||
imgScale: 3.8, imgX: "-36.9%", imgY: "-10%",
|
||
},
|
||
];
|
||
|
||
/* Vorlage für eine selbst angelegte Slide. Sie startet bewusst mit Text
|
||
drin: eine leere Slide sieht aus wie ein Fehler, und man sieht nicht,
|
||
welches Feld welche Zeile füllt. */
|
||
const kopie = x => JSON.parse(JSON.stringify(x));
|
||
|
||
const NEUE_SLIDE = {
|
||
type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Neuer Tag",
|
||
head: 'Überschrift mit <span class="a">Akzent</span>',
|
||
body: "Beschreibung dieser Slide. Zwei bis drei Zeilen sitzen am besten.",
|
||
img: "./assets/screens/pipelines.png",
|
||
mockW: 1090, mockAR: 1.6,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
};
|
||
|
||
/* 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. */
|
||
/* 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" },
|
||
{ pfad: "./assets/screens/workflow.png", name: "Workflow · Ablauf" },
|
||
{ pfad: "./assets/screens/workflow-bausteine.png", name: "Workflow · Bausteine" },
|
||
{ pfad: "./assets/screens/chat.png", name: "Chat" },
|
||
{ pfad: "./assets/screens/chat-tablet.png", name: "Chat · Tablet" },
|
||
{ pfad: "./assets/screens/chat-menue.png", name: "Chat · Menü" },
|
||
{ pfad: "./assets/screens/form-builder.png", name: "Form Builder" },
|
||
];
|
||
|
||
/* ─── Das Deck ────────────────────────────────────────────────────────────
|
||
Welche Slides der Post hat, steht nicht mehr fest. Jeder Eintrag ist
|
||
entweder eine mitgelieferte Vorlage (ref) oder eine selbst angelegte, und
|
||
eigene Texte liegen als Überschreibung daneben. So bleibt eine Änderung an
|
||
einer Vorlage im Code für alle wirksam, die sie nicht überschrieben haben
|
||
— hätte man die Slides vollständig in den Stand geschrieben, wäre der dort
|
||
eingefroren. */
|
||
let slideListe = VORLAGEN.map(v => ({ ref: v.key }));
|
||
|
||
const SLIDES = [];
|
||
|
||
function slideAus(eintrag, i) {
|
||
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;
|
||
}
|
||
|
||
function slidesNeuAufbauen() {
|
||
SLIDES.length = 0;
|
||
slideListe.forEach((e, i) => SLIDES.push(slideAus(e, i)));
|
||
}
|
||
|
||
/* Im Eingabefeld tippt man Text, nicht Auszeichnung: *Wort* wird zum Akzent,
|
||
ein Zeilenumbruch zum Umbruch. Gespeichert wird trotzdem das fertige
|
||
Markup, weil die Vorlagen es schon so mitbringen. */
|
||
function textZuHtml(t) {
|
||
return String(t)
|
||
.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]))
|
||
.replace(/\*([^*\n]+)\*/g, '<span class="a">$1</span>')
|
||
.replace(/\r?\n/g, "<br>");
|
||
}
|
||
function htmlZuText(h) {
|
||
return String(h == null ? "" : h)
|
||
.replace(/<br\s*\/?>/gi, "\n")
|
||
.replace(/<span class="a">([\s\S]*?)<\/span>/gi, "*$1*")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||
}
|
||
|
||
/* Standardwerte. Reset und die Übersicht auf der Seite lesen beide von hier,
|
||
damit die Doku nicht auseinanderläuft. size = null heisst: nicht über die
|
||
Schriftgröße steuerbar (Bilder, Geräte). */
|
||
const STANDARD = {
|
||
"badge": { label: "Tag", size: 26, prop: "font-size" },
|
||
"headline": { label: "Überschrift", size: 74, prop: "font-size" },
|
||
"body": { label: "Beschreibung", size: 30, prop: "font-size" },
|
||
"wabe-tagline": { label: "Tagline", size: 74, prop: "font-size" },
|
||
"wabe-claim": { label: "Claim", size: 31, prop: "font-size" },
|
||
"title-head": { label: "Überschrift", size: 88, prop: "font-size" },
|
||
"title-sub": { label: "Claim", size: 31, prop: "font-size" },
|
||
"wabe-logo": { label: "Logo", size: 430, prop: "width" },
|
||
"logo-full": { label: "Logo", size: 560, prop: "width" },
|
||
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
|
||
"laptop": { label: "Laptop", size: 960, prop: "width" },
|
||
"szene": { label: "Laptop · Board", size: 880, prop: "width" },
|
||
"mock": { label: "Bild", size: null, prop: "width" },
|
||
"mock-2": { label: "Zweites Bild", size: null, prop: "width" },
|
||
"note": { label: "Notizzettel", size: null, prop: "width" },
|
||
"hex-frame": { label: "Wabe", size: 778, prop: "width" },
|
||
};
|
||
const EDIT_KEYS = Object.keys(STANDARD);
|
||
|
||
/* Der Standard des Bildrahmens hängt an der Slide: Der Form Builder hat ein
|
||
Hochformat von 540, alle anderen 1090. Deshalb nicht aus der Tabelle,
|
||
sondern aus der Slide selbst. */
|
||
function stdSize(key, i) {
|
||
if (key === "mock") return SLIDES[i].mockW || 1090;
|
||
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
|
||
if (key === "note") return (SLIDES[i].note || {}).w || 320;
|
||
return (STANDARD[key] || {}).size;
|
||
}
|
||
|
||
// Schriftgrößen in 2er-Schritten, Breiten in 2 % ihrer Ausgangsgröße:
|
||
// 2 px auf ein 1090 px breites Bild würde man nie sehen.
|
||
function stepFor(key, i) {
|
||
const std = STANDARD[key];
|
||
if (!std || !std.prop) return 0;
|
||
if (std.prop === "font-size") return 2;
|
||
return Math.max(4, Math.round(stdSize(key, i) * 0.02));
|
||
}
|
||
|
||
slidesNeuAufbauen();
|
||
|
||
let edits = SLIDES.map(() => ({}));
|
||
|
||
let picked = null; // { slide, key }
|
||
// Bearbeiten ist immer an. "aus" heisst Vorschau: alles festgelegt.
|
||
let editMode = "an";
|
||
|
||
let current = 0;
|
||
let variant = "dunkel", tilt = "an", superSample = 2, view = "streifen", tagOut = "an", frameStyle = "chamfer";
|
||
|
||
/* ─── Projektstand ────────────────────────────────────────────────────────
|
||
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
|
||
Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand
|
||
halten, nicht der Kopf. */
|
||
const PROJECT = "post1";
|
||
const STORE = "hivegrid.projekt." + PROJECT;
|
||
const STATE_URL = "./state/" + PROJECT + ".json";
|
||
|
||
const STATE_V = 6;
|
||
|
||
function currentState() {
|
||
return {
|
||
v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle,
|
||
current, layoutState, bgState, edits,
|
||
// Welche Slides der Post ueberhaupt hat. Ohne das waere eine selbst
|
||
// angelegte Slide nach dem Neuladen weg.
|
||
deck: slideListe,
|
||
};
|
||
}
|
||
|
||
/* ─── Angleichung des Tags (einmalig, Stand 1 → 2) ───────────────────────
|
||
Der Tag war auf jeder Slide einzeln gezogen worden und stand deshalb
|
||
überall etwas anders. Er soll auf allen Feature-Slides denselben Abstand
|
||
zur oberen und linken Kante haben; Maß ist die erste Feature-Slide.
|
||
Das läuft hier und nicht über die Datei: solange ein Tab offen ist, hält
|
||
der seinen Stand im Speicher und schreibt jede Änderung an der Datei beim
|
||
nächsten Klick wieder zu. Im Ladepfad kann er das nicht. */
|
||
function migrateState(d) {
|
||
if (!d || (d.v || 1) >= STATE_V) return false;
|
||
const alt = d.v || 1;
|
||
if (alt < 2) {
|
||
const feat = SLIDES.map((s, i) => (s.type === "feature" ? i : -1)).filter(i => i >= 0);
|
||
const ref = feat.length && Array.isArray(d.edits) ? (d.edits[feat[0]] || {}).badge : null;
|
||
if (ref) {
|
||
for (const i of feat.slice(1)) {
|
||
const e = d.edits[i] || (d.edits[i] = {});
|
||
e.badge = Object.assign({}, e.badge, { dx: ref.dx || 0, dy: ref.dy || 0 });
|
||
}
|
||
}
|
||
}
|
||
// Stand 3: Das Titel-Mockup ist ein anderes Bild mit anderem Format
|
||
// geworden. Breite und Versatz von vorher passen dazu nicht mehr und
|
||
// wuerden das Geraet ueber die Slide hinaus schieben.
|
||
if (alt < 3 && Array.isArray(d.edits)) {
|
||
for (const e of d.edits) { if (e) delete e.szene; }
|
||
}
|
||
// Stand 4: Automation & KI steht jetzt vor Vertrieb. Der Stand haengt an der
|
||
// Position, nicht am Slide-Schluessel — ohne diesen Tausch laegen die
|
||
// gezogenen Positionen, das Muster und das Layout an der falschen Slide.
|
||
if (alt < 4) {
|
||
const tausch = arr => {
|
||
if (Array.isArray(arr) && arr.length > 2) { const t = arr[1]; arr[1] = arr[2]; arr[2] = t; }
|
||
};
|
||
tausch(d.edits); tausch(d.bgState); tausch(d.layoutState);
|
||
if (d.current === 1) d.current = 2;
|
||
else if (d.current === 2) d.current = 1;
|
||
}
|
||
// Stand 5: Das Tag ist ausgeblendet, seine gespeicherte Stelle also ohne
|
||
// Wirkung. Der Eintrag wird geraeumt, damit keine Werte stehenbleiben,
|
||
// die niemand mehr nachvollziehen kann.
|
||
if (alt < 5 && Array.isArray(d.edits)) {
|
||
for (const e of d.edits) { if (e) delete e.badge; }
|
||
}
|
||
d.v = STATE_V;
|
||
return true;
|
||
}
|
||
|
||
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
|
||
Rückmeldung nicht auf das Netz wartet, und gebündelt in
|
||
state/<projekt>.json. Nur die Datei lässt sich pushen, also ist sie die
|
||
Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */
|
||
let pushTimer = null;
|
||
|
||
/* Ein vorhandener Stand, der sich nicht lesen laesst, ist ein Grund zum
|
||
Anhalten und nicht zum Weiterarbeiten: sonst laeuft das Blatt mit der
|
||
Voreinstellung an und schreibt die beim ersten Klick ueber genau die
|
||
Datei, die gerade nicht verstanden wurde. Solange die Sperre steht, geht
|
||
nichts mehr raus, weder in die Datei noch in den localStorage. */
|
||
/* Im Uebersichts-Modus wird nichts gespeichert: drei Kacheln, die beim
|
||
Schliessen ihren Stand zurueckschreiben, waeren genau die Falle aus
|
||
POST-2.md 6. Die Konstante steht hier oben, weil stateLocked sie
|
||
braucht -- weiter unten waere sie noch nicht initialisiert. */
|
||
const NUR_STREIFEN = document.documentElement.dataset.nur === "streifen";
|
||
let stateLocked = NUR_STREIFEN;
|
||
function lockState(grund) {
|
||
stateLocked = true;
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
setStatus("Stand nicht geladen (" + grund + "). Es wird nichts gespeichert, "
|
||
+ "damit die vorhandene Datei nicht ueberschrieben wird.", "err");
|
||
}
|
||
|
||
/* ─── Rueckgaengig ───────────────────────────────────────────────────────
|
||
Haengt an saveState statt an den 25 Stellen, die etwas aendern: was
|
||
gespeichert wird, ist genau das, was rueckgaengig gemacht werden soll.
|
||
Ziehen speichert erst beim Loslassen, eine Bewegung ist also bereits ein
|
||
Schritt und nicht hundert. */
|
||
const UNDO_MAX = 100;
|
||
let undoStack = [], redoStack = [], lastSnap = null, restoring = false;
|
||
|
||
// Tiefe Kopie: currentState() gibt die laufenden Arrays heraus, und beim
|
||
// Ziehen wird in edits[i][key] direkt hineingeschrieben. Ohne Kopie wuerde
|
||
// sich ein abgelegter Stand nachtraeglich mitveraendern.
|
||
|
||
// Vergleich mit sortierten Schluesseln, damit die Reihenfolge, in der dx und
|
||
// dy angelegt wurden, keinen Unterschied vortaeuscht.
|
||
function stabil(x) {
|
||
if (Array.isArray(x)) return x.map(stabil);
|
||
if (x && typeof x === "object") {
|
||
const o = {};
|
||
for (const k of Object.keys(x).sort()) o[k] = stabil(x[k]);
|
||
return o;
|
||
}
|
||
return x;
|
||
}
|
||
|
||
// Ein Slide-Wechsel allein ist nichts, was man rueckgaengig machen will.
|
||
// show() speichert bei jedem Wechsel; ohne diese Ausnahme bestuende der
|
||
// Stapel zur Haelfte aus Auswahlwechseln.
|
||
function nurAuswahlAnders(a, b) {
|
||
const ohne = s => { const c = Object.assign({}, s); delete c.current; return JSON.stringify(stabil(c)); };
|
||
return ohne(a) === ohne(b);
|
||
}
|
||
|
||
function merkeFuerUndo(state) {
|
||
const snap = kopie(state);
|
||
if (lastSnap && !nurAuswahlAnders(lastSnap, snap)) {
|
||
undoStack.push(lastSnap);
|
||
if (undoStack.length > UNDO_MAX) undoStack.shift();
|
||
redoStack.length = 0; // neuer Zweig: das Wiederherstellen verfaellt
|
||
}
|
||
lastSnap = snap;
|
||
}
|
||
|
||
/* Zurueckspielen laeuft ueber takeState, also denselben Weg wie das Laden,
|
||
und zieht danach die gesamte Oberflaeche nach. Von Hand jeden Schalter
|
||
einzeln zu setzen waere die Stelle, an der man einen vergisst. */
|
||
function restoreState(d) {
|
||
restoring = true;
|
||
try {
|
||
takeState(kopie(d));
|
||
deck.dataset.view = view;
|
||
press("variants", "v", variant);
|
||
press("tilts", "t", tilt);
|
||
press("quality", "q", String(superSample));
|
||
press("tagouts", "o", tagOut);
|
||
press("frames", "f", frameStyle);
|
||
press("views", "w", view);
|
||
// Nach einem Schritt, der Slides hinzugefuegt, geloescht oder umsortiert
|
||
// hat, stimmt der Aufbau im Canvas nicht mehr mit dem Stand ueberein.
|
||
if (deck.querySelectorAll(".holder").length !== SLIDES.length) {
|
||
picked = null;
|
||
deckNeuBauen();
|
||
} else {
|
||
SLIDES.forEach((s, i) => rebuildPage(i));
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
anzahlNachziehen();
|
||
show(current);
|
||
if (picked) pick(picked.slide, picked.key); else syncEditPanel();
|
||
} finally { restoring = false; }
|
||
// Der zurueckgespielte Stand ist ab jetzt der Bezugspunkt, sonst legt der
|
||
// naechste Speichervorgang ihn gleich wieder als Schritt ab.
|
||
lastSnap = kopie(currentState());
|
||
saveState();
|
||
}
|
||
|
||
function undo() {
|
||
if (stateLocked) return;
|
||
if (!undoStack.length) { setStatus("Nichts mehr rueckgaengig zu machen", "err"); return; }
|
||
redoStack.push(kopie(currentState()));
|
||
restoreState(undoStack.pop());
|
||
setStatus("Rueckgaengig · noch " + undoStack.length + " Schritte", "ok");
|
||
}
|
||
|
||
function redo() {
|
||
if (stateLocked) return;
|
||
if (!redoStack.length) { setStatus("Nichts wiederherzustellen", "err"); return; }
|
||
undoStack.push(kopie(currentState()));
|
||
restoreState(redoStack.pop());
|
||
setStatus("Wiederhergestellt · noch " + redoStack.length + " Schritte", "ok");
|
||
}
|
||
|
||
function saveState() {
|
||
if (stateLocked) return;
|
||
const state = currentState();
|
||
if (!restoring) merkeFuerUndo(state);
|
||
try { localStorage.setItem(STORE, JSON.stringify(state)); }
|
||
catch (err) { console.warn("Speichern fehlgeschlagen", err); }
|
||
markSaved();
|
||
clearTimeout(pushTimer);
|
||
pushTimer = setTimeout(() => pushState(state), 600);
|
||
}
|
||
|
||
async function pushState(state) {
|
||
pushTimer = null;
|
||
if (stateLocked) return;
|
||
try {
|
||
const res = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(state),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
} catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); }
|
||
}
|
||
|
||
// Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt.
|
||
addEventListener("beforeunload", () => {
|
||
if (!pushTimer || stateLocked) return;
|
||
clearTimeout(pushTimer);
|
||
navigator.sendBeacon("/state?project=" + PROJECT,
|
||
new Blob([JSON.stringify(currentState())], { type: "application/json" }));
|
||
});
|
||
|
||
async function loadState() {
|
||
let d = null, fromFile = false, fileSeen = false;
|
||
// Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch
|
||
// gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten.
|
||
try {
|
||
const res = await fetch(STATE_URL, { cache: "no-store" });
|
||
if (res.ok) { fileSeen = true; d = await res.json(); fromFile = true; }
|
||
} catch (_) {}
|
||
// Datei ist da, laesst sich aber nicht lesen: anhalten statt ueberschreiben.
|
||
if (fileSeen && !d) { lockState("Datei unlesbar"); return false; }
|
||
if (!d) {
|
||
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
|
||
}
|
||
if (!d) return false;
|
||
// Jeder Stand von 1 bis zum aktuellen ist gueltig; aeltere hebt migrateState
|
||
// an. Frueher standen hier nur 1 und 2 fest verdrahtet, dadurch wurde jeder
|
||
// frisch gespeicherte Stand beim Laden verworfen und von der Voreinstellung
|
||
// ueberschrieben.
|
||
if (!(d.v >= 1 && d.v <= STATE_V)) {
|
||
if (fromFile) { lockState("Stand " + d.v + ", dieses Blatt kennt bis " + STATE_V); }
|
||
return false;
|
||
}
|
||
const migrated = migrateState(d);
|
||
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
|
||
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
|
||
// Nach einer Angleichung ebenso, damit sie nicht bei jedem Laden erneut greift.
|
||
if (!fromFile || migrated) setTimeout(() => pushState(currentState()), 0);
|
||
takeState(d);
|
||
return true;
|
||
}
|
||
|
||
/* Einen Stand in die laufenden Variablen uebernehmen. Steht bewusst als
|
||
eigene Funktion da: der Ladepfad und das Rueckgaengigmachen gehen beide
|
||
hier durch, sonst zieht der eine ein Feld nach, das der andere vergisst. */
|
||
function takeState(d) {
|
||
/* Zuerst das Deck: Anzahl und Art der Slides stehen darin, und alle
|
||
folgenden Felder haengen an dieser Anzahl. Ein Stand ohne Deck stammt
|
||
aus der Zeit fester fuenf Slides und behaelt die Vorlagen. */
|
||
if (Array.isArray(d.deck) && d.deck.length) {
|
||
slideListe = d.deck.map(e => ({
|
||
ref: e && e.ref, key: e && e.key, werte: (e && e.werte) || {},
|
||
}));
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
}
|
||
// Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am
|
||
// Aufbau dürfen keinen kaputten Stand einspielen.
|
||
if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) {
|
||
d.layoutState.forEach((id, i) => {
|
||
if (SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = id;
|
||
});
|
||
}
|
||
if (Array.isArray(d.bgState) && d.bgState.length === SLIDES.length) {
|
||
d.bgState.forEach((b, i) => { if (b && b.bg) bgState[i] = b; });
|
||
}
|
||
if (Array.isArray(d.edits) && d.edits.length === SLIDES.length) edits = d.edits;
|
||
variant = d.variant || variant;
|
||
tilt = d.tilt || tilt;
|
||
superSample = d.superSample || superSample;
|
||
/* Der Uebersichts-Modus zeigt immer den Streifen. Steht im Stand
|
||
"einzeln", waere sonst nur die zuletzt gewaehlte Slide zu sehen --
|
||
und stripWidth() meldete 1080 statt der vollen Breite. */
|
||
view = NUR_STREIFEN ? "streifen" : (d.view || view);
|
||
tagOut = d.tagOut || tagOut;
|
||
frameStyle = d.frameStyle || frameStyle;
|
||
current = Number.isInteger(d.current) ? Math.min(d.current, SLIDES.length - 1) : 0;
|
||
}
|
||
|
||
let savedTimer = null;
|
||
function markSaved() {
|
||
const el = document.getElementById("saved");
|
||
if (!el) return;
|
||
el.textContent = "gespeichert";
|
||
el.classList.add("on");
|
||
clearTimeout(savedTimer);
|
||
savedTimer = setTimeout(() => el.classList.remove("on"), 1200);
|
||
}
|
||
|
||
function forgetProject() {
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
try { localStorage.removeItem(STORE); } catch (_) {}
|
||
// Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten
|
||
// Laden wieder ein.
|
||
fetch("/state?project=" + PROJECT, { method: "DELETE" })
|
||
.catch(() => {})
|
||
.then(() => location.reload());
|
||
}
|
||
// Per-slide so a single slide can carry a different ground.
|
||
// Der Grund ist fest das Raster. Einstellbar bleibt nur seine Staerke.
|
||
const bgState = SLIDES.map(() => ({ bg: "raster", int: "dezent" }));
|
||
// Each slide picks its own layout from its own catalogue.
|
||
const layoutState = SLIDES.map(sl => sl.layouts[0].id);
|
||
// Every asset is inlined once at boot, so re-rendering a layout stays synchronous.
|
||
const assets = {};
|
||
let fontEmbedCss = "";
|
||
|
||
/* Der Streifen wird auf die verfügbare Breite skaliert, die Einzelansicht auf
|
||
feste 45 %. Der Rahmen bekommt die skalierte Größe, damit die Seite nicht
|
||
springt. */
|
||
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
|
||
hält der Wert, bis er auf "Fit" zurückgeht. */
|
||
// Im Uebersichts-Modus steht der Streifen fest auf 100 % -- skaliert wird aussen.
|
||
let zoom = NUR_STREIFEN ? 1 : null;
|
||
|
||
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
|
||
|
||
function autoFit() {
|
||
const scroller = document.getElementById("scroller");
|
||
const availW = Math.max(320, scroller.clientWidth - 120);
|
||
const availH = Math.max(240, scroller.clientHeight - 120);
|
||
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
|
||
}
|
||
|
||
/* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl
|
||
der Slides ist je Projekt anders. Sie kommt per postMessage statt ueber
|
||
einen Griff ins iframe-Dokument: so bleibt die Uebersicht von den
|
||
Interna der Post-Seite unabhaengig. */
|
||
function groesseMelden() {
|
||
if (!NUR_STREIFEN || parent === window) return;
|
||
parent.postMessage({ hg: "streifen", w: stripWidth(), h: 1350,
|
||
titel: document.title }, "*");
|
||
}
|
||
|
||
function fitStrip() {
|
||
const wrap = document.getElementById("strip-wrap");
|
||
const k = zoom || autoFit();
|
||
deck.style.transform = "scale(" + k + ")";
|
||
wrap.style.width = Math.round(stripWidth() * k) + "px";
|
||
wrap.style.height = Math.round(1350 * k) + "px";
|
||
const lvl = document.getElementById("zoom-fit");
|
||
if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit";
|
||
groesseMelden();
|
||
}
|
||
|
||
function setZoom(k, anchor) {
|
||
const scroller = document.getElementById("scroller");
|
||
const before = zoom || autoFit();
|
||
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
|
||
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
|
||
/* Um den Mauszeiger zoomen, nicht um die linke obere Ecke — und dafür den
|
||
Punkt unter dem Zeiger messen statt ihn aus scrollLeft hochzurechnen.
|
||
Das Blatt sitzt in einer gepolsterten Fläche und wird zentriert, solange
|
||
Platz übrig ist; beides verschiebt den Ursprung. Ein reiner Dreisatz
|
||
ließ den Inhalt deshalb bei jedem Schritt um das Polster wegwandern. */
|
||
const s0 = scroller.getBoundingClientRect();
|
||
const r0 = deck.getBoundingClientRect();
|
||
const u = (s0.left + ax - r0.left) / before;
|
||
const v = (s0.top + ay - r0.top) / before;
|
||
zoom = Math.min(2, Math.max(0.05, k));
|
||
fitStrip();
|
||
const r1 = deck.getBoundingClientRect();
|
||
scroller.scrollLeft += (r1.left + u * zoom) - (s0.left + ax);
|
||
scroller.scrollTop += (r1.top + v * zoom) - (s0.top + ay);
|
||
}
|
||
|
||
const setStatus = (msg, kind) => {
|
||
statusEl.textContent = msg;
|
||
statusEl.className = "status" + (kind ? " " + kind : "");
|
||
};
|
||
|
||
// FeatherMoreVertical aus dem Kopf der StickyNote: drei Punkte, keine Linien.
|
||
const NOTE_MENU_SVG =
|
||
'<svg viewBox="0 0 24 24" fill="rgba(66,66,66,.72)">' +
|
||
'<circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/>' +
|
||
'<circle cx="12" cy="19" r="1.9"/></svg>';
|
||
|
||
|
||
// Positionen im 5400er Raum. Die Nähte liegen auf 1080 / 2160 / 3240 / 4320.
|
||
const BG_HTML =
|
||
'<div class="bg">' +
|
||
'<div class="layer-raster"></div>' +
|
||
'</div>';
|
||
|
||
function contentHtml(s, i) {
|
||
if (s.type === "feature") {
|
||
return '<p class="badge">' + s.eyebrow + '</p>' +
|
||
'<h2 class="headline">' + s.head + '</h2>' +
|
||
'<p class="body">' + s.body + '</p>';
|
||
}
|
||
const logo = variant === "dunkel" ? assets["./assets/brand/logo-white.svg"] : assets["./assets/brand/logo.svg"];
|
||
if (layoutState[i] === "szene") {
|
||
// Die Wortmarke steckt in diesem Mockup schon im Bild. Ein zweites Logo
|
||
// auf der Slide waere eine Dopplung, deshalb faellt hier die wabe-logo
|
||
// weg und die Zeile oben traegt nur die Tagline.
|
||
const dev = assets["./assets/mockups/macbook-fels.png"]
|
||
? '<div class="szene"><img class="szene-dev" alt="HiveGrid" src="' +
|
||
assets["./assets/mockups/macbook-fels.png"] + '"></div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das freigestellte PNG als <b>assets/mockups/macbook-fels.png</b> ablegen.</div>';
|
||
// Nur Ueberschrift und Mockup: der Beschreibungstext faellt hier weg.
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>für <span class="a">Handwerker</span></p>' +
|
||
'</div>' + dev;
|
||
}
|
||
if (layoutState[i] === "laptop-footer") {
|
||
const dev = assets["./assets/mockups/laptop.png"]
|
||
? '<div class="laptop">' +
|
||
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
|
||
'<div class="laptop-screen" data-blur="aus">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' +
|
||
'</div>' +
|
||
'</div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
'<p class="wabe-claim">' + s.sub + '</p>' +
|
||
'</div>' + dev +
|
||
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">';
|
||
}
|
||
if (layoutState[i].indexOf("laptop") === 0) {
|
||
// In the logo variants the wordmark moves onto the display and the tagline
|
||
// takes over its slot on the hexagon line; "laptop-blur" additionally
|
||
// blurs the app behind it.
|
||
const onScreen = layoutState[i] !== "laptop";
|
||
const blurred = layoutState[i] === "laptop-blur";
|
||
const overlay = onScreen
|
||
? '<img class="screen-logo" alt="HiveGrid" src="' + assets["./assets/brand/logo.svg"] + '">'
|
||
: '';
|
||
const dev = assets["./assets/mockups/laptop.png"]
|
||
? '<div class="laptop">' +
|
||
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
|
||
'<div class="laptop-screen" data-blur="' + (blurred ? "an" : "aus") + '">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' + overlay +
|
||
'</div>' +
|
||
'</div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
(onScreen ? '' : '<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">') +
|
||
'</div>' + dev +
|
||
'<p class="wabe-claim">' + s.sub + '</p>';
|
||
}
|
||
if (layoutState[i] === "wabe") {
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">' +
|
||
'</div>' +
|
||
'<div class="hex-frame"><div class="hex-shot">' +
|
||
'<img alt="" src="' + assets["./assets/screens/pipelines.png"] + '">' +
|
||
'</div></div>' +
|
||
'<p class="wabe-claim">' + s.sub + '</p>';
|
||
}
|
||
return '<img class="logo-full" alt="HiveGrid" src="' + logo + '">' +
|
||
'<h1 class="title-head">' + s.head + '</h1>' +
|
||
'<p class="title-sub">' + s.sub + '</p>';
|
||
}
|
||
|
||
// Eine Seite kann in mehreren Slides liegen; alle Kopien mitziehen.
|
||
function rebuildPage(j) {
|
||
deck.querySelectorAll('.page[data-j="' + j + '"]').forEach(pg => renderPage(pg, j));
|
||
}
|
||
|
||
function applyEditsAll() {
|
||
deck.querySelectorAll(".page").forEach(pg => applyEdits(pg, Number(pg.dataset.j)));
|
||
}
|
||
|
||
// Jedes bearbeitbare Element bekommt seinen Schlüssel, abgeleitet aus der Klasse.
|
||
function tagEditable(slideEl) {
|
||
for (const key of EDIT_KEYS) {
|
||
slideEl.querySelectorAll("." + key).forEach(n => { n.dataset.edit = key; });
|
||
}
|
||
// ".mock" trifft auch das zweite Bild, weil es dieselbe Klasse trägt.
|
||
// Der spezifischere Schlüssel muss gewinnen, sonst teilen sich beide
|
||
// Bilder eine Bearbeitung.
|
||
slideEl.querySelectorAll(".mock-2").forEach(n => { n.dataset.edit = "mock-2"; });
|
||
}
|
||
|
||
function applyEdits(slideEl, i) {
|
||
const set = edits[i] || {};
|
||
slideEl.querySelectorAll("[data-edit]").forEach(n => {
|
||
const e = set[n.dataset.edit] || {};
|
||
n.style.setProperty("--ex", (e.dx || 0) + "px");
|
||
n.style.setProperty("--ey", (e.dy || 0) + "px");
|
||
const std = STANDARD[n.dataset.edit];
|
||
if (std && std.prop) {
|
||
n.style[std.prop === "font-size" ? "fontSize" : "width"] =
|
||
e.size ? e.size + "px" : "";
|
||
}
|
||
// Entferntes Element: aus dem Fluss nehmen, nicht nur unsichtbar machen.
|
||
n.style.display = e.weg ? "none" : "";
|
||
/* Die Ebene wirkt nur auf positionierten Elementen. Text steht im Fluss,
|
||
deshalb kommt beim Setzen ein relative dazu -- am Layout aendert das
|
||
nichts, macht die Ebene aber erst wirksam. Ohne Wert bleibt alles,
|
||
wie es die Gestaltung vorsieht. */
|
||
if (e.z === undefined || e.z === null || e.z === "") {
|
||
n.style.zIndex = "";
|
||
if (n.dataset.zpos) { n.style.position = ""; delete n.dataset.zpos; }
|
||
} else {
|
||
n.style.zIndex = e.z;
|
||
if (getComputedStyle(n).position === "static") {
|
||
n.style.position = "relative";
|
||
n.dataset.zpos = "1";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Eine Seite = der Inhalt genau einer Slide, unabhängig davon, in welcher
|
||
// Slide sie gerade mitgerendert wird.
|
||
function buildPage(j) {
|
||
const sl = SLIDES[j];
|
||
const page = document.createElement("div");
|
||
page.className = "page";
|
||
page.dataset.j = String(j);
|
||
page.style.left = (j * 1080) + "px";
|
||
|
||
const content = document.createElement("div");
|
||
content.className = "content";
|
||
page.appendChild(content);
|
||
|
||
if (sl.type === "feature" && assets[sl.img]) {
|
||
const mock = document.createElement("div");
|
||
mock.className = "mock";
|
||
if (sl.mockW) mock.style.setProperty("--mock-w", sl.mockW + "px");
|
||
if (sl.mockAR) mock.style.setProperty("--mock-ar", sl.mockAR);
|
||
if (sl.mockLeft) mock.style.setProperty("--mock-left", sl.mockLeft + "px");
|
||
/* Hinterstes Blatt bekommt standardmässig den Screen der nächsten
|
||
Feature-Slide. Das kann danebengehen: der nächste Screen ist
|
||
womöglich stark gezoomt und damit unscharf, und er zeigt Bedienung,
|
||
die zum Feature dieser Slide gar nicht passt. Deshalb ist er über
|
||
"back" pro Slide überschreibbar. */
|
||
const withImg = SLIDES.filter(x => x.img);
|
||
const auto = withImg[(withImg.findIndex(x => x.img === sl.img) + 1) % withImg.length];
|
||
const other = sl.back || { img: auto.img, imgScale: auto.imgScale, imgX: auto.imgX, imgY: auto.imgY };
|
||
mock.innerHTML =
|
||
'<div class="sheet-back"><img alt="" src="' + assets[other.img] +
|
||
'" style="transform:scale(' + other.imgScale + ') translate(' +
|
||
other.imgX + ',' + other.imgY + ')"></div>' +
|
||
'<div class="sheet-mid"></div>' +
|
||
'<div class="mock-inner" style="--img-scale:' + sl.imgScale +
|
||
';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>';
|
||
page.appendChild(mock);
|
||
|
||
|
||
if (sl.note) {
|
||
const nt = sl.note;
|
||
const el3 = document.createElement("div");
|
||
el3.className = "note";
|
||
el3.style.cssText =
|
||
"--note-w:" + nt.w + "px;--note-left:" + nt.left +
|
||
"px;--note-bottom:" + nt.bottom + "px;--note-z:" + nt.z +
|
||
";--rot:" + (nt.rot || 0) + "deg";
|
||
el3.innerHTML =
|
||
'<div class="note-head">' + NOTE_MENU_SVG + '</div>' +
|
||
'<div class="note-body">' + nt.text + '</div>' +
|
||
'<div class="note-grip"><i></i><i></i></div>';
|
||
page.appendChild(el3);
|
||
}
|
||
}
|
||
|
||
/* Ein zweites Bild gibt es auf jeder Slide, nicht nur auf Feature-Slides:
|
||
ein importiertes Bild soll auf der gewaehlten Slide landen, egal
|
||
welche das ist. */
|
||
// Ein Bild, das es nicht mehr gibt (geloescht, nicht mitgepusht), darf die
|
||
// Slide nicht mit einem leeren <img> vergiften: das faellt sonst erst im
|
||
// Export auf, und zwar mit "1 Bild(er) nicht geladen".
|
||
if (sl.media2 && assets[sl.media2.img]) {
|
||
const m2 = sl.media2;
|
||
const el2 = document.createElement("div");
|
||
el2.className = "mock mock-2";
|
||
el2.style.cssText =
|
||
"--mock-w:" + m2.w + "px;--mock-ar:" + m2.ar +
|
||
";--mock-left:" + m2.left + "px;--m2-bottom:" + m2.bottom +
|
||
"px;--m2-z:" + m2.z + ";--rot:" + (m2.rot || 0) + "deg";
|
||
el2.innerHTML =
|
||
'<div class="mock-inner"><img class="laptop-shot" alt="" src="' +
|
||
assets[m2.img] + '"></div>';
|
||
page.appendChild(el2);
|
||
}
|
||
|
||
renderPage(page, j);
|
||
return page;
|
||
}
|
||
|
||
function renderPage(page, j) {
|
||
page.querySelector(".content").innerHTML = contentHtml(SLIDES[j], j);
|
||
if (SLIDES[j].type === "title") page.classList.add("title");
|
||
// Das Layout hängt an der Seite, damit eine Titel-Seite auch in einer
|
||
// Nachbar-Slide korrekt aussieht.
|
||
page.dataset.layout = layoutState[j];
|
||
tagEditable(page);
|
||
applyEdits(page, j);
|
||
}
|
||
|
||
function buildSlide(s, i) {
|
||
const el = document.createElement("div");
|
||
el.className = "slide" + (s.type === "title" ? " title" : "");
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
el.dataset.editmode = editMode;
|
||
el.style.setProperty("--slide-i", i);
|
||
el.innerHTML = BG_HTML;
|
||
|
||
/* Jede Slide traegt das vollstaendige Blatt, nicht nur ihre Nachbarn.
|
||
Vorher waren es i-1 bis i+1, und ein Element liess sich damit genau eine
|
||
Slide weit schieben, danach war es abgeschnitten. Der Post soll aber eine
|
||
durchgehende Flaeche sein. Der Preis sind n mal n Seiten statt n mal
|
||
drei -- bei einem Carousel dieser Groesse nicht spuerbar. */
|
||
const sheet = document.createElement("div");
|
||
sheet.className = "sheet";
|
||
for (let j = 0; j < SLIDES.length; j++) {
|
||
sheet.appendChild(buildPage(j));
|
||
}
|
||
el.appendChild(sheet);
|
||
// Über allem, damit die Linien auch auf dem Mockup lesbar bleiben.
|
||
el.insertAdjacentHTML("beforeend",
|
||
'<div class="guides"><div class="grid"></div><div class="safe"></div>' +
|
||
'<div class="mid mid-v"></div><div class="mid mid-h"></div>' +
|
||
'<div class="ax ax-v"></div><div class="ax ax-h"></div></div>');
|
||
return el;
|
||
}
|
||
|
||
/* ─── Hilfslinien ────────────────────────────────────────────────────────
|
||
Die Linien gehören zur Slide, nicht zum Canvas: gemessen wird deshalb
|
||
gegen deren Rechteck. Der Maßstab kürzt sich dabei heraus, die Werte sind
|
||
also unabhängig vom Zoom. */
|
||
const SNAP = 4; // px in Slide-Koordinaten
|
||
let guideSlide = null;
|
||
|
||
function showGuides(slideEl) {
|
||
guideSlide = slideEl;
|
||
slideEl.dataset.guides = "an";
|
||
}
|
||
|
||
function hideGuides() {
|
||
if (!guideSlide) return;
|
||
delete guideSlide.dataset.guides;
|
||
guideSlide.querySelectorAll(".guides .on").forEach(n => n.classList.remove("on"));
|
||
guideSlide = null;
|
||
}
|
||
|
||
function updateGuides(node) {
|
||
if (!guideSlide) return;
|
||
const s = guideSlide.getBoundingClientRect();
|
||
const n = node.getBoundingClientRect();
|
||
if (!s.width || !n.width) return;
|
||
const k = s.width / 1080;
|
||
const cx = (n.left + n.width / 2 - s.left) / k;
|
||
const cy = (n.top + n.height / 2 - s.top) / k;
|
||
const g = guideSlide.querySelector(".guides");
|
||
g.querySelector(".mid-v").style.left = cx + "px";
|
||
g.querySelector(".mid-h").style.top = cy + "px";
|
||
g.querySelector(".ax-v").classList.toggle("on", Math.abs(cx - 540) <= SNAP);
|
||
g.querySelector(".ax-h").classList.toggle("on", Math.abs(cy - 675) <= SNAP);
|
||
}
|
||
|
||
/* Every asset is inlined as a data URL once, before the deck is built.
|
||
Same-origin already satisfies the canvas, but inlining removes every fetch
|
||
and timing failure mode from the export path, and it lets a layout switch
|
||
re-render without touching the network. */
|
||
async function preloadAssets() {
|
||
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
|
||
.concat(SLIDES.filter(s => s.img).map(s => s.img))
|
||
.concat(SLIDES.filter(s => s.back).map(s => s.back.img))
|
||
.concat(SLIDES.filter(s => s.media2).map(s => s.media2.img));
|
||
paths.push("./assets/screens/pipelines.png", "./assets/screens/chat-tablet.png");
|
||
/* 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. */
|
||
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");
|
||
for (const path of [...new Set(paths)]) {
|
||
/* no-cache statt no-store: der Browser behaelt seine Kopie und fragt
|
||
nur nach, ob sie noch stimmt. Ein ausgetauschtes Bild kommt genauso
|
||
sofort an, ein unveraendertes kostet ein 304 statt seiner vollen
|
||
Groesse. Mit no-store lud jeder Reload alle Assets neu -- ueber
|
||
uebersicht.html, die sechs Projekte als iframes laedt, waren das
|
||
38 MB und 21 Sekunden. */
|
||
const res = await fetch(path, { cache: "no-cache" }).catch(() => null);
|
||
if (!res || !res.ok) {
|
||
if (optional.has(path)) continue;
|
||
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
||
}
|
||
const blob = await res.blob();
|
||
assets[path] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
}
|
||
|
||
function press(id, attr, value) {
|
||
document.querySelectorAll("#" + id + " button").forEach(b =>
|
||
b.setAttribute("aria-pressed", String(b.dataset[attr] === value)));
|
||
}
|
||
|
||
function applyAll() {
|
||
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
|
||
deck.querySelectorAll(".slide").forEach((el, i) => {
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
if (SLIDES[i].type === "title") rebuildPage(i);
|
||
el.dataset.editmode = editMode;
|
||
});
|
||
}
|
||
|
||
// The layout catalogue differs per slide, so the buttons are rebuilt on every
|
||
// slide change rather than just re-pressed.
|
||
function syncLayoutButtons() {
|
||
const box = document.getElementById("layouts");
|
||
box.innerHTML = "";
|
||
const cat = SLIDES[current].layouts;
|
||
box.className = "opts" + (cat.length === 1 ? " one" : "");
|
||
cat.forEach(l => {
|
||
const b = document.createElement("button");
|
||
b.dataset.l = l.id;
|
||
b.textContent = l.label;
|
||
b.setAttribute("aria-pressed", String(l.id === layoutState[current]));
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// Reflect the active slide's own ground in the panel.
|
||
function syncBgButtons() {
|
||
press("ints", "i", bgState[current].int);
|
||
}
|
||
|
||
function show(i) {
|
||
current = (i + SLIDES.length) % SLIDES.length;
|
||
saveState();
|
||
deck.querySelectorAll(".holder").forEach((h, n) =>
|
||
h.classList.toggle("sel", n === current));
|
||
document.querySelectorAll("#slides-nav button").forEach((b, n) =>
|
||
b.setAttribute("aria-pressed", String(n === current)));
|
||
syncBgButtons();
|
||
syncLayoutButtons();
|
||
syncInhalt();
|
||
fitStrip();
|
||
metaEl.textContent = view === "streifen"
|
||
? "Slide " + (current + 1) + " ausgewählt. Klick auf eine Slide wechselt die Auswahl"
|
||
: "Slide " + (current + 1) + " von " + SLIDES.length;
|
||
}
|
||
|
||
/* Copy grows, the mockup does not: once the text block reaches the frame the
|
||
slide silently breaks. Rather than discover that in an export, measure it
|
||
and say so. */
|
||
function checkCollisions() {
|
||
const problems = [];
|
||
deck.querySelectorAll(".holder").forEach((holder, i) => {
|
||
const own = holder.querySelector('.page[data-j="' + i + '"]');
|
||
if (!own) return;
|
||
const mock = own.querySelector(".mock");
|
||
if (!mock) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const content = own.querySelector(".content");
|
||
const gap = mock.offsetTop - (content.offsetTop + content.offsetHeight);
|
||
holder.style.display = restore;
|
||
if (gap < 24) problems.push("Slide " + (i + 1) + " (" + SLIDES[i].eyebrow + "): " + Math.round(gap) + "px");
|
||
});
|
||
if (problems.length) {
|
||
console.warn("Text kollidiert mit dem Mockup:\n" + problems.join("\n"));
|
||
setStatus("Achtung: Text zu nah am Mockup (" + problems.length + "x), siehe Konsole", "err");
|
||
}
|
||
return problems.length === 0;
|
||
}
|
||
|
||
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
|
||
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
|
||
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
|
||
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
|
||
wirkte 3x nicht schärfer als 1x.
|
||
|
||
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
|
||
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
|
||
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
|
||
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
|
||
function renderOptions() {
|
||
const k = superSample;
|
||
return {
|
||
width: 1080 * k,
|
||
height: 1350 * k,
|
||
pixelRatio: 1,
|
||
fontEmbedCSS: fontEmbedCss,
|
||
style: {
|
||
width: "1080px",
|
||
height: "1350px",
|
||
transform: k === 1 ? "none" : "scale(" + k + ")",
|
||
transformOrigin: "top left",
|
||
},
|
||
};
|
||
}
|
||
|
||
/* The browser download route was unreliable here: a data: URL opened a blank
|
||
tab, a blob: URL opened the image in a tab, both times ignoring the download
|
||
attribute. Since this only runs against the local server, the bytes go to
|
||
/save instead and land in the Downloads folder directly. */
|
||
async function downloadBlob(blob, filename) {
|
||
const res = await fetch("/save", {
|
||
method: "POST",
|
||
headers: { "X-Filename": filename },
|
||
body: blob,
|
||
});
|
||
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
|
||
return (await res.json()).path;
|
||
}
|
||
|
||
// Numeric prefix keeps the upload order intact in the file picker.
|
||
const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant + ".png";
|
||
|
||
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints
|
||
that as a single image. Raster children of a foreignObject decode
|
||
asynchronously and can miss that paint, which is why a screenshot went
|
||
missing every few exports while the rest of the slide was fine. It is a
|
||
race, so the fix is not to hope but to verify: render, check that every
|
||
screenshot area actually carries pixels, and repeat if it does not. */
|
||
/* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
|
||
Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
|
||
Export ohne Macbook unbemerkt. */
|
||
const SHOT_BOXES =
|
||
".mock-inner, .laptop-screen, .hex-shot, .szene-dev, .laptop-frame";
|
||
const MAX_TRIES = 5;
|
||
// Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
|
||
// abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
|
||
const LEER_SPANNE = 6;
|
||
|
||
/* Die vier Ecken eines Kindes in Slide-Koordinaten.
|
||
Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
|
||
ein gedrehtes oder perspektivisch gestelltes Bild hat ein Umrechteck, das
|
||
Raster, Hintergrund und Nachbarelemente mit einschliesst. Darin fand die
|
||
Prüfung immer genug Helligkeitsspanne und winkte eine leere Fläche durch —
|
||
genau so kam ein Export ohne Screenshots durch die Kontrolle.
|
||
getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
|
||
vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
|
||
rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
|
||
verschwinden vor dem Rendern wieder. */
|
||
function quadInSlide(el, child) {
|
||
const sr = el.getBoundingClientRect();
|
||
const k = sr.width / 1080;
|
||
let host = child, stell = null;
|
||
/* <img> kann keine Kinder tragen. Also entsteht im Elternknoten ein
|
||
deckungsgleicher Stellvertreter — mit derselben Box UND derselben
|
||
eigenen Transformation. Ohne letztere fehlt genau der Versatz
|
||
verschobener Bilder: .mock-inner > img wird per scale und translate in
|
||
seinem Fenster verschoben, das Viereck läge sonst daneben und die
|
||
Messung geriete auf Rahmen statt auf Bild. */
|
||
if (child.tagName === "IMG") {
|
||
const eltern = child.offsetParent || child.parentElement;
|
||
if (!eltern) return null;
|
||
const cs = getComputedStyle(child);
|
||
stell = document.createElement("div");
|
||
stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
|
||
"pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
|
||
"px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
|
||
"px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
|
||
eltern.appendChild(stell);
|
||
host = stell;
|
||
}
|
||
const bw = host.offsetWidth, bh = host.offsetHeight;
|
||
if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
|
||
// Absolute Marken brauchen einen positionierten Bezug.
|
||
const posVorher = host.style.position;
|
||
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
||
const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
|
||
const m = document.createElement("div");
|
||
m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
|
||
"height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
|
||
host.appendChild(m);
|
||
return m;
|
||
});
|
||
const pts = marken.map(m => {
|
||
const r = m.getBoundingClientRect();
|
||
return { x: ((r.left + r.width / 2) - sr.left) / k,
|
||
y: ((r.top + r.height / 2) - sr.top) / k };
|
||
});
|
||
marken.forEach(m => m.remove());
|
||
host.style.position = posVorher;
|
||
if (stell) stell.remove();
|
||
return pts;
|
||
}
|
||
|
||
/* A screenshot area is never a flat surface: it carries UI, text and colour.
|
||
If the sampled pixels are all but identical, the image did not make it. */
|
||
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
|
||
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
|
||
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
|
||
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
|
||
fehlend gemeldet. */
|
||
const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
|
||
y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
|
||
|
||
function quadEinruecken(q, f) {
|
||
const m = quadMitte(q);
|
||
return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
|
||
}
|
||
|
||
function quadFlaeche(q) {
|
||
let s = 0;
|
||
for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
|
||
return Math.abs(s) / 2;
|
||
}
|
||
|
||
function quadEnthaelt(q, p) {
|
||
let pos = 0, neg = 0;
|
||
for (let i = 0; i < 4; i++) {
|
||
const a = q[i], b = q[(i + 1) % 4];
|
||
const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||
if (d > 0) pos++; else if (d < 0) neg++;
|
||
}
|
||
return pos === 0 || neg === 0;
|
||
}
|
||
|
||
/* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
|
||
Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
|
||
größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
|
||
das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
|
||
und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
|
||
umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
|
||
läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
|
||
der beiden Vierecke gelegt und gegen das größere geprüft. */
|
||
function ueberlappPunkte(qBox, qImg) {
|
||
const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
|
||
const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
|
||
const gegen = raster === A ? B : A;
|
||
const pts = [];
|
||
for (let a = 0; a <= 16; a++) {
|
||
for (let b = 0; b <= 16; b++) {
|
||
const u = a / 16, v = b / 16;
|
||
const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
|
||
const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
|
||
const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
|
||
const by = raster[3].y + (raster[2].y - raster[3].y) * u;
|
||
const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
|
||
if (quadEnthaelt(gegen, p)) pts.push(p);
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function spanneAn(lum, pts) {
|
||
let min = 255, max = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < min) min = l;
|
||
if (l > max) max = l;
|
||
n++;
|
||
}
|
||
// Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
|
||
return n < 20 ? 255 : max - min;
|
||
}
|
||
|
||
/* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
|
||
Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
|
||
fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
|
||
täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
|
||
zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
|
||
damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
|
||
function dunkelAnteilVorlage(im) {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 48;
|
||
const g = c.getContext("2d", { willReadFrequently: true });
|
||
g.fillStyle = "#fff";
|
||
g.fillRect(0, 0, 48, 48);
|
||
try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
|
||
const d = g.getImageData(0, 0, 48, 48).data;
|
||
let dunkel = 0;
|
||
for (let i = 0; i < d.length; i += 4)
|
||
if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
|
||
return dunkel / (48 * 48);
|
||
}
|
||
|
||
function dunkelAn(lum, pts) {
|
||
let dunkel = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < 140) dunkel++;
|
||
n++;
|
||
}
|
||
return n < 20 ? 1 : dunkel / n;
|
||
}
|
||
|
||
|
||
/* ─── Umlaute im gezeichneten Text ───────────────────────────────────────
|
||
Auf einer Slide stand einmal "Sanitaer" und "Klima / Lueftung". Beides
|
||
kam woertlich aus der Quelle -- im Klickdummy war es dort schludrig
|
||
eingetippt --, und beides las sich im fertigen Beitrag wie ein Fehler,
|
||
weil es einer ist. Gefunden hat es niemand beim Bauen, sondern erst
|
||
jemand, der auf den fertigen Post geschaut hat.
|
||
|
||
Deshalb prueft es jetzt der Export. Ein reines Muster genuegt dafuer
|
||
nicht: Deutsch schreibt "neue", "Dauer", "Michael" voellig zu Recht mit
|
||
ae/oe/ue. Nachgezaehlt ueber den gezeichneten Text aller Projekte sind es
|
||
aber nur eine Handvoll solcher Woerter -- eine Ausnahmeliste ist also
|
||
kurz und trifft trotzdem alles andere.
|
||
|
||
ss ist der umgekehrte Fall: fast immer richtig (dass, Schluss, Adresse,
|
||
Netzanschluss), nur ein paar Woerter brauchen ein scharfes s. Die stehen
|
||
deshalb einzeln als Liste und nicht als Muster.
|
||
|
||
Kanalnamen sind Absicht: "# waermepumpe-mueller" ist ein Handle, und die
|
||
schreiben Chatwerkzeuge klein und ohne Umlaute. */
|
||
/* Woerter, in denen ae/oe/ue kein verkruepppelter Umlaut ist, sondern zwei
|
||
Laute an einer Silbengrenze. Die Eintraege wirken als Wortanfang.
|
||
|
||
Die Liste ist kurz geworden, weil die Regel darunter den groessten Teil
|
||
der Ausnahmen von selbst erledigt: ein ersatzweise geschriebener Umlaut
|
||
steht nie hinter einem Vokal. Vorher standen hier dauer, neue, steuer,
|
||
feuer, bauer -- alle nach demselben Muster, und die Liste waere mit
|
||
jedem Beitrag weitergewachsen. Was bleibt, sind die echten Sonderfaelle:
|
||
"Michael" (ae hinter h), "aktuell" (ue hinter t). */
|
||
const UMLAUT_ERLAUBT = new Set([
|
||
"michael", "aktuell", "museum",
|
||
"waermepumpe-mueller", "waermepumpe-abnahme",
|
||
]);
|
||
const BRAUCHT_SCHARFES_S = new Set([
|
||
"aufmass", "strasse", "groesse", "groesser", "fuss", "fussboden",
|
||
"mass", "masse", "weiss", "heisst", "aussen", "gross", "grosse", "spass",
|
||
]);
|
||
|
||
function umlautFehler(wurzel) {
|
||
const raus = [];
|
||
wurzel.querySelectorAll("text, .headline, .body, .badge").forEach(e => {
|
||
/* Nicht textContent, sondern die Textknoten mit Leerzeichen dazwischen.
|
||
Eine Ueberschrift ist "Formulare selbst bauen.<br><span>Per
|
||
Drag-and-drop.</span>" -- textContent klebt daraus "bauen.Per"
|
||
zusammen, ein Wort, das es nicht gibt. Ein Umbruch und eine
|
||
Elementgrenze sind Wortgrenzen. */
|
||
const stuecke = [];
|
||
const lauf = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
||
for (let k = lauf.nextNode(); k; k = lauf.nextNode()) stuecke.push(k.nodeValue);
|
||
const ganz = stuecke.join(" ");
|
||
/* Geviertstriche gehoeren nicht in unsere Texte. Der Halbgeviertstrich
|
||
als Gedankenstrich ist richtiges Deutsch und bleibt erlaubt; der
|
||
lange Strich ist eine englische Angewohnheit und liest sich auf einer
|
||
deutschen Slide fremd. Er kam nicht aus einer Quelle, sondern beim
|
||
Schreiben mit hinein. */
|
||
if (ganz.indexOf("\u2014") >= 0) {
|
||
const stelle = "Geviertstrich in „" + ganz.trim().slice(0, 40) + "“";
|
||
if (!raus.includes(stelle)) raus.push(stelle);
|
||
}
|
||
ganz.split(/[^A-Za-zÄÖÜäöüß0-9./#@-]+/).forEach(w => {
|
||
if (!w) return;
|
||
const k = w.toLowerCase().replace(/^[@#]+/, "").replace(/[.,;:!?)(]+$/, "");
|
||
if (!k || raus.includes(w)) return;
|
||
/* Mailadressen und Domains sind ausgenommen. Dort ist die
|
||
umlautfreie Schreibung keine Schludrigkeit, sondern Pflicht:
|
||
"buero@muster-shk.de" kann gar kein ue ersetzen. Ohne diese
|
||
Ausnahme meldete Post 8 drei Adressen als Fehler. */
|
||
if (/@/.test(k) || /\.(de|com|net|org|eu|at|ch|io)$/.test(k)) return;
|
||
if ([...UMLAUT_ERLAUBT].some(a => k.startsWith(a))) return;
|
||
/* ae/oe/ue nur dann als verunglueckter Umlaut, wenn kein Vokal
|
||
davorsteht. Ein ae, oe oder ue, das fuer ae, oe oder ue steht,
|
||
folgt immer einem Konsonanten oder steht am Wortanfang: "fuer",
|
||
"ueber", "gruen", "Sanitaer", "Lueftung". Hinter einem Vokal sind
|
||
es dagegen immer zwei eigene Laute: bauen, Bauer, neue, Dauer,
|
||
Steuer, Feuer, Mauer, genauer. Die Regel ersetzt sechs Eintraege
|
||
in UMLAUT_ERLAUBT und alle, die dort noch gelandet waeren. */
|
||
if (/(^|[^aeiou])(ae|oe|ue)/.test(k) || BRAUCHT_SCHARFES_S.has(k)) raus.push(w);
|
||
});
|
||
});
|
||
return raus;
|
||
}
|
||
|
||
/* Fuer animation.mjs: das Bewegtbild laeuft nicht durch renderSlide und
|
||
braucht deshalb eine eigene Tuer zu derselben Pruefung. */
|
||
window.TEXT_PRUEFUNG = () => umlautFehler(document);
|
||
|
||
async function renderSlide(i) {
|
||
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
||
// stehen, landeten sie im Bild.
|
||
clearTimeout(nudgeTimer); hideGuides();
|
||
const holder = deck.querySelectorAll(".holder")[i];
|
||
const el = holder.querySelector(".slide");
|
||
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
|
||
// Rendern muss der Holder kurz sichtbar sein.
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
|
||
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
|
||
const modeBefore = el.dataset.editmode;
|
||
el.dataset.editmode = "aus";
|
||
try {
|
||
await document.fonts.ready;
|
||
/* Den Text pruefen, bevor gerendert wird. Lieber ein Abbruch mit
|
||
klarer Meldung als ein fertiges PNG mit "Sanitaer" darauf. */
|
||
const uf = umlautFehler(el);
|
||
if (uf.length) throw new Error("Slide " + (i + 1) + ": " + uf.join(", ") +
|
||
". Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es " +
|
||
"in UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
|
||
const imgs = [...el.querySelectorAll("img")];
|
||
await Promise.all(imgs.map(im =>
|
||
im.complete ? im.decode().catch(() => {})
|
||
: new Promise(r => { im.onload = r; im.onerror = r; })));
|
||
const broken = imgs.filter(im => !im.naturalWidth);
|
||
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
|
||
|
||
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
|
||
mit dem aus der Vorlage geeichten Erwartungswert. */
|
||
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
|
||
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
|
||
const rahmen = c.tagName === "IMG";
|
||
const bild = rahmen ? c : c.querySelector("img");
|
||
if (!bild) return null;
|
||
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
|
||
const qImg = quadInSlide(el, bild);
|
||
if (!qBox || !qImg) return null;
|
||
return {
|
||
name: c.className || c.tagName.toLowerCase(),
|
||
pts: ueberlappPunkte(qBox, qImg),
|
||
rahmen,
|
||
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
|
||
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
|
||
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
|
||
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
|
||
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
|
||
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
|
||
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
|
||
kleiner rechnen kann jedes Zielsystem selbst. */
|
||
const canvas = await htmlToImage.toCanvas(el, renderOptions());
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
|
||
// auf die tatsächliche Ausgabegröße umgerechnet.
|
||
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
|
||
const px = ctx.getImageData(0, 0, AW, AH).data;
|
||
const lum = (x, y) => {
|
||
const sx = Math.round(x * af), sy = Math.round(y * af);
|
||
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
|
||
const j = (sy * AW + sx) * 4;
|
||
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
|
||
};
|
||
|
||
const befund = ziele.map(z => {
|
||
const spanne = spanneAn(lum, z.pts);
|
||
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
|
||
let fehlt = spanne <= LEER_SPANNE;
|
||
let dunkel = null;
|
||
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
|
||
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
|
||
// ankommt, gemessen gegen die Vorlage.
|
||
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
|
||
dunkel = dunkelAn(lum, z.pts);
|
||
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
|
||
}
|
||
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
|
||
});
|
||
const fehlend = befund.filter(b => b.fehlt);
|
||
|
||
if (!fehlend.length) {
|
||
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
|
||
if (blob) return blob;
|
||
}
|
||
if (attempt < MAX_TRIES) {
|
||
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
|
||
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
|
||
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
|
||
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
|
||
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
|
||
" fehlte, Versuch " + (attempt + 1) + " ...");
|
||
await new Promise(r => setTimeout(r, 180 * attempt));
|
||
}
|
||
}
|
||
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
|
||
" Versuchen unvollstaendig. Details in der Konsole.");
|
||
} finally {
|
||
el.dataset.editmode = modeBefore;
|
||
holder.style.display = restore;
|
||
}
|
||
}
|
||
|
||
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
|
||
// sichtbar ist, was tatsächlich auf der Platte landet.
|
||
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
|
||
|
||
async function downloadOne() {
|
||
setStatus("Rendere Slide " + (current + 1) + " …");
|
||
downloadBlob(await renderSlide(current), fileName(current));
|
||
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
async function downloadAll() {
|
||
const zip = new JSZip();
|
||
for (let i = 0; i < SLIDES.length; i++) {
|
||
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
|
||
zip.file(fileName(i), await renderSlide(i));
|
||
}
|
||
show(current);
|
||
setStatus("Packe ZIP …");
|
||
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
|
||
"hivegrid-post1-loesung-" + variant + ".zip");
|
||
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
const guard = fn => () => fn().catch(err => {
|
||
console.error(err);
|
||
setStatus("Fehler: " + err.message, "err");
|
||
});
|
||
|
||
/* ─── Slides verwalten ────────────────────────────────────────────────────
|
||
Die Felder pro Slide (Verschiebungen, Muster, Layout) haengen an der
|
||
Anzahl und muessen bei jeder Aenderung im Gleichschritt mitwandern. Das
|
||
laeuft ueber slideListe als Bezugsgroesse, nicht ueber SLIDES: SLIDES
|
||
wird daraus jedes Mal neu berechnet. */
|
||
function passeFelderAn() {
|
||
const n = SLIDES.length;
|
||
while (edits.length < n) edits.push({});
|
||
edits.length = n;
|
||
while (bgState.length < n) bgState.push({ bg: "formen", int: "dezent" });
|
||
bgState.length = n;
|
||
while (layoutState.length < n) layoutState.push(SLIDES[layoutState.length].layouts[0].id);
|
||
layoutState.length = n;
|
||
// Ein Layout, das es auf dieser Slide nicht gibt, faellt auf das erste
|
||
// zurueck: sonst zeigt eine Feature-Slide ein Titel-Layout.
|
||
layoutState.forEach((id, i) => {
|
||
if (!SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = SLIDES[i].layouts[0].id;
|
||
});
|
||
}
|
||
|
||
function deckNeuBauen() {
|
||
deck.innerHTML = "";
|
||
nav.innerHTML = "";
|
||
buildDeck();
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
|
||
function anzahlNachziehen() {
|
||
const n = SLIDES.length;
|
||
const kopf = document.getElementById("kopfzeile");
|
||
if (kopf) kopf.textContent =
|
||
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px";
|
||
const zip = document.getElementById("dl-all");
|
||
if (zip) {
|
||
zip.textContent = "Alle " + n + " Slides als ZIP";
|
||
// Bei einer einzigen Slide ist ein ZIP nur ein Umweg.
|
||
zip.style.display = n > 1 ? "" : "none";
|
||
}
|
||
// Das durchgehende Blatt ist so breit wie der Post lang ist.
|
||
deck.style.setProperty("--sheet-w", (n * 1080) + "px");
|
||
}
|
||
|
||
function nachDeckAenderung(an, meldung) {
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
current = Math.max(0, Math.min(an, SLIDES.length - 1));
|
||
show(current);
|
||
anzahlNachziehen();
|
||
syncInhalt();
|
||
syncEditPanel();
|
||
saveState();
|
||
if (meldung) setStatus(meldung, "ok");
|
||
}
|
||
|
||
function slideEinfuegen(eintrag, an) {
|
||
slideListe.splice(an, 0, eintrag);
|
||
edits.splice(an, 0, {});
|
||
bgState.splice(an, 0, kopie(bgState[current] || { bg: "formen", int: "dezent" }));
|
||
slidesNeuAufbauen();
|
||
layoutState.splice(an, 0, SLIDES[an].layouts[0].id);
|
||
}
|
||
|
||
function slideNeu() {
|
||
const an = current + 1;
|
||
slideEinfuegen({ werte: {} }, an);
|
||
nachDeckAenderung(an, "Slide " + (an + 1) + " angelegt");
|
||
}
|
||
|
||
function slideKopieren() {
|
||
const an = current + 1;
|
||
const q = slideListe[current];
|
||
slideEinfuegen({ ref: q.ref, key: q.key, werte: kopie(q.werte || {}) }, an);
|
||
// Auch die Feinarbeit mitnehmen, sonst ist die Kopie nur halb eine.
|
||
edits[an] = kopie(edits[current]);
|
||
bgState[an] = kopie(bgState[current]);
|
||
layoutState[an] = layoutState[current];
|
||
nachDeckAenderung(an, "Slide kopiert");
|
||
}
|
||
|
||
function slideLoeschen() {
|
||
if (SLIDES.length <= 1) { setStatus("Die letzte Slide bleibt stehen", "err"); return; }
|
||
const weg = current;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => a.splice(weg, 1));
|
||
nachDeckAenderung(Math.min(weg, SLIDES.length - 2), "Slide gelöscht");
|
||
}
|
||
|
||
function slideSchieben(um) {
|
||
const ziel = current + um;
|
||
if (ziel < 0 || ziel >= SLIDES.length) return;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => {
|
||
const [x] = a.splice(current, 1);
|
||
a.splice(ziel, 0, x);
|
||
});
|
||
nachDeckAenderung(ziel, "Slide verschoben");
|
||
}
|
||
|
||
/* ─── Inhalt der gewaehlten Slide ─────────────────────────────────────────
|
||
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 =>
|
||
'<option value="' + b.pfad + '">' +
|
||
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').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-cache" }).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(() => ({}));
|
||
/* 404 heisst hier fast immer: der Python-Server laeuft noch in einer
|
||
Fassung ohne /upload. Die Seite neu zu laden hilft dagegen nicht --
|
||
serve.py wird nur beim Start eingelesen. */
|
||
if (res.status === 404) throw new Error("Server kennt /upload nicht · ./serve.sh neu starten");
|
||
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;
|
||
});
|
||
}
|
||
|
||
/* Ein Element genau auf die Mitte der Slide setzen. Gerechnet wird nicht,
|
||
sondern gemessen: das Blatt ist gepolstert, die Mockups stehen schief im
|
||
Raum, und ein Dreisatz ueber left und bottom laege daneben. Der Versatz
|
||
wandert in die Bearbeitung, ist also nachtraeglich frei verschiebbar. */
|
||
function mittigSetzen(i, key) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
if (!node) return;
|
||
const slide = node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const dx = ((nr.left + nr.width / 2) - (sr.left + sr.width / 2)) / k;
|
||
const dy = ((nr.top + nr.height / 2) - (sr.top + sr.height / 2)) / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
set.dx = Math.round((set.dx || 0) - dx);
|
||
set.dy = Math.round((set.dy || 0) - dy);
|
||
applyEditsAll();
|
||
}
|
||
|
||
async function alsZweitesBild(i, pfad, mittig) {
|
||
const ar = await seitenverhaeltnis(pfad);
|
||
const w = werteVon(i);
|
||
const vorher = w.media2 || SLIDES[i].media2 || {};
|
||
w.media2 = {
|
||
img: pfad, w: vorher.w || 480, ar: +ar.toFixed(4),
|
||
left: vorher.left != null ? vorher.left : 300,
|
||
bottom: vorher.bottom != null ? vorher.bottom : 400,
|
||
z: vorher.z || 2, rot: mittig ? 0 : (vorher.rot || 0),
|
||
};
|
||
slidesNeuAufbauen();
|
||
deckNeuBauen();
|
||
show(i);
|
||
// Ein frisch importiertes Bild soll man sehen, nicht suchen.
|
||
if (mittig) {
|
||
edits[i]["mock-2"] = {};
|
||
mittigSetzen(i, "mock-2");
|
||
pick(i, "mock-2");
|
||
}
|
||
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.
|
||
/* Ohne Ziel gilt die gewaehlte Slide. Frueher wurde nur importiert und
|
||
die Zuordnung blieb in einem Auswahlfeld liegen -- das Bild war damit
|
||
nirgends zu sehen und der Import wirkte wie ein Fehlschlag. */
|
||
const ziel = typeof zielSlide === "number" ? zielSlide : current;
|
||
await alsZweitesBild(ziel, pfade[0], true);
|
||
syncInhalt();
|
||
setStatus(pfade.length === 1
|
||
? "Bild auf Slide " + (ziel + 1) + " gelegt · mit E verschieben"
|
||
: pfade.length + " Bilder importiert, erstes auf Slide " + (ziel + 1) +
|
||
" gelegt · Rest unter Inhalt", "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 || {});
|
||
}
|
||
|
||
function syncInhalt() {
|
||
const box = document.getElementById("inhalt");
|
||
if (!box) return;
|
||
const s = SLIDES[current];
|
||
const id = current + ":" + (s.type || "");
|
||
if (box.dataset.for !== id) {
|
||
box.dataset.for = id;
|
||
const felder = s.type === "feature"
|
||
? [["eyebrow", "Tag", "input"], ["head", "Überschrift", "area"], ["body", "Fließtext", "area"]]
|
||
: [["head", "Überschrift", "area"], ["sub", "Claim", "area"]];
|
||
box.innerHTML = felder.map(([k, label, art]) =>
|
||
'<label class="inh-label" for="in-' + k + '">' + label + '</label>' +
|
||
(art === "area"
|
||
? '<textarea id="in-' + k + '" data-feld="' + k + '" rows="2"></textarea>'
|
||
: '<input id="in-' + k + '" data-feld="' + k + '" type="text">')
|
||
).join("") +
|
||
(s.type === "feature"
|
||
? '<label class="inh-label" for="in-img">Screenshot</label>' +
|
||
'<select id="in-img" data-feld="img">' + bildOptionen() + '</select>' +
|
||
'<label class="inh-label" for="in-media2">Zweites Bild</label>' +
|
||
'<select id="in-media2" data-feld="media2">' +
|
||
'<option value="">— keins —</option>' + bildOptionen() + '</select>'
|
||
: "") +
|
||
'<div class="hint">*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
|
||
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '</div>';
|
||
}
|
||
box.querySelectorAll("[data-feld]").forEach(el => {
|
||
if (document.activeElement === el) return;
|
||
const k = el.dataset.feld;
|
||
if (k === "img") el.value = s.img || "";
|
||
else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
|
||
else el.value = htmlZuText(s[k]);
|
||
});
|
||
}
|
||
|
||
document.getElementById("inhalt").addEventListener("input", ev => {
|
||
const el = ev.target.closest("[data-feld]");
|
||
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
|
||
// Querformat-Rahmen und laesst links und rechts Flaechen frei.
|
||
const im = new Image();
|
||
im.onload = () => {
|
||
w.mockAR = +(im.naturalWidth / im.naturalHeight).toFixed(4);
|
||
w.imgScale = 1; w.imgX = "0%"; w.imgY = "0%";
|
||
slidesNeuAufbauen();
|
||
// Das Rahmenformat steckt im Aufbau der Seite, nicht in ihren Texten:
|
||
// dafuer reicht rebuildPage nicht, der Halter muss neu entstehen.
|
||
deckNeuBauen();
|
||
show(current);
|
||
saveState();
|
||
};
|
||
im.src = assets[el.value] || el.value;
|
||
return;
|
||
}
|
||
w[k] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
// Nur die betroffene Seite neu zeichnen: ein voller Neuaufbau wuerde das
|
||
// Eingabefeld unter den Fingern wegreissen.
|
||
rebuildPage(current);
|
||
applyEditsAll();
|
||
nav.querySelectorAll("button")[current].innerHTML =
|
||
'<span class="num">' + (current + 1) + '</span>' +
|
||
(SLIDES[current].type === "title" ? "Titel" : SLIDES[current].eyebrow);
|
||
saveState();
|
||
});
|
||
|
||
document.getElementById("slide-ops").addEventListener("click", ev => {
|
||
const b = ev.target.closest("button");
|
||
if (!b || stateLocked) return;
|
||
if (b.id === "slide-neu") slideNeu();
|
||
else if (b.id === "slide-kopie") slideKopieren();
|
||
else if (b.id === "slide-weg") slideLoeschen();
|
||
else if (b.id === "slide-links") slideSchieben(-1);
|
||
else if (b.id === "slide-rechts") slideSchieben(1);
|
||
});
|
||
|
||
function buildDeck() {
|
||
SLIDES.forEach((sl, i) => {
|
||
const holder = document.createElement("div");
|
||
holder.className = "holder";
|
||
holder.dataset.n = String(i);
|
||
holder.appendChild(buildSlide(sl, i));
|
||
deck.appendChild(holder);
|
||
});
|
||
SLIDES.forEach((sl, i) => {
|
||
const b = document.createElement("button");
|
||
b.dataset.n = String(i);
|
||
b.setAttribute("aria-pressed", String(i === 0));
|
||
b.innerHTML = '<span class="num">' + (i + 1) + '</span>' +
|
||
(sl.type === "title" ? "Titel" : sl.eyebrow);
|
||
nav.appendChild(b);
|
||
});
|
||
}
|
||
|
||
deck.addEventListener("click", e => {
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
const h = e.target.closest(".holder");
|
||
if (h) show(Number(h.dataset.n));
|
||
});
|
||
|
||
/* Ziehen: Die Vorschau ist skaliert, die Slide nicht. Mausdeltas werden
|
||
deshalb durch den Maßstab geteilt, sonst wandert das Element bei kleiner
|
||
Vorschau um ein Vielfaches. */
|
||
deck.addEventListener("mousedown", e => {
|
||
if (editMode !== "an") return;
|
||
const node = e.target.closest("[data-edit]");
|
||
if (!node) return;
|
||
const holder = node.closest(".holder");
|
||
const page = node.closest(".page");
|
||
const idx = Number(page.dataset.j); // Besitzer-Seite, nicht die angeklickte Slide
|
||
const key = node.dataset.edit;
|
||
e.preventDefault();
|
||
|
||
show(Number(holder.dataset.n));
|
||
pick(idx, key);
|
||
|
||
/* Den Maßstab aus dem lesen, was tatsächlich gezeichnet ist. Das Rechteck
|
||
zu messen geht nicht: .strip ist ein Flex-Kind der schon skalierten
|
||
Hülle, seine Border-Box misst Breite mal Maßstab hoch zwei. Und
|
||
autoFit() neu zu rechnen geht seit dem Element-Feld auch nicht mehr:
|
||
das fährt beim Anklicken ein, macht die Bühne schmaler und ändert den
|
||
Maßstab mitten im Klick. */
|
||
const mSc = /scale\(([-\d.]+)\)/.exec(deck.style.transform || "");
|
||
const k = mSc ? Number(mSc[1]) : (zoom || autoFit());
|
||
const set = (edits[idx][key] = edits[idx][key] || {});
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const baseX = set.dx || 0, baseY = set.dy || 0;
|
||
node.style.cursor = "grabbing";
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
|
||
const move = ev => {
|
||
set.dx = Math.round(baseX + (ev.clientX - startX) / k);
|
||
set.dy = Math.round(baseY + (ev.clientY - startY) / k);
|
||
applyEditsAll(); // zieht alle Kopien der Seite mit
|
||
updateGuides(node);
|
||
syncEditPanel();
|
||
};
|
||
const up = () => {
|
||
node.style.cursor = "";
|
||
hideGuides();
|
||
document.removeEventListener("mousemove", move);
|
||
document.removeEventListener("mouseup", up);
|
||
saveState();
|
||
};
|
||
document.addEventListener("mousemove", move);
|
||
document.addEventListener("mouseup", up);
|
||
});
|
||
|
||
function pick(idx, key) {
|
||
picked = { slide: idx, key };
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
deck.querySelectorAll('.page[data-j="' + idx + '"] [data-edit="' + key + '"]')
|
||
.forEach(n => n.classList.add("picked"));
|
||
syncEditPanel();
|
||
}
|
||
|
||
let nudgeTimer = null;
|
||
function nudge(dx, dy) {
|
||
if (!picked) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.dx = (set.dx || 0) + dx;
|
||
set.dy = (set.dy || 0) + dy;
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
// Pixelweise schieben braucht dieselbe Rueckmeldung wie das Ziehen, nur
|
||
// soll sie danach von selbst verschwinden.
|
||
const holder = deck.querySelectorAll(".holder")[current];
|
||
const node = holder && holder.querySelector('.page[data-j="' + picked.slide +
|
||
'"] [data-edit="' + picked.key + '"]');
|
||
if (node) {
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
clearTimeout(nudgeTimer);
|
||
nudgeTimer = setTimeout(hideGuides, 900);
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
function resize(dir) {
|
||
if (!picked) return;
|
||
const std = STANDARD[picked.key];
|
||
if (!std || !std.prop) return;
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.size = Math.max(8, (set.size || base) + dir * stepFor(picked.key, picked.slide));
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetOne() {
|
||
if (!picked) return;
|
||
delete edits[picked.slide][picked.key];
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetAll() {
|
||
edits = SLIDES.map(() => ({}));
|
||
applyEditsAll();
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
saveState();
|
||
setStatus("Alle Slides auf Standard zurückgesetzt", "ok");
|
||
}
|
||
|
||
/* X und Y zaehlen ab der Mitte der Slide, nicht ab der Stelle, an der das
|
||
Element von sich aus laege. Nur so bedeuten gleiche Werte bei zwei
|
||
verschiedenen Elementen auch dieselbe Stelle.
|
||
Gemessen wird die OBERE LINKE ECKE, nicht der Mittelpunkt. Eine
|
||
zweizeilige und eine dreizeilige Ueberschrift sind unterschiedlich hoch;
|
||
bei gleichem Mittelpunkt liegen ihre Oberkanten 39 px auseinander und der
|
||
hoehere Block waechst nach oben ins Tag. Ab der ersten Zeile gerechnet
|
||
stimmt es unabhaengig von der Zeilenzahl.
|
||
Gemessen wird die erste sichtbare Kopie: in der Einzelansicht sind die
|
||
uebrigen Holder ausgeblendet und liefern ein leeres Rechteck. */
|
||
function centreOf(slideIdx, key) {
|
||
const nodes = deck.querySelectorAll(
|
||
'.page[data-j="' + slideIdx + '"] [data-edit="' + key + '"]');
|
||
for (const n of nodes) {
|
||
const r = n.getBoundingClientRect();
|
||
if (!r.width) continue;
|
||
const holder = n.closest(".slide");
|
||
const sr = holder && holder.getBoundingClientRect();
|
||
if (!sr || !sr.width) continue;
|
||
const k = sr.width / 1080;
|
||
return {
|
||
x: Math.round((r.left - sr.left) / k - 540),
|
||
y: Math.round((r.top - sr.top) / k - 675),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Welches Textfeld der Slide haengt an welchem Element. Nur was hier steht,
|
||
laesst sich im Element-Feld direkt beschriften; der Rest (Logos, Bilder,
|
||
Geraete) traegt keinen Text. */
|
||
const TEXT_ZU_FELD = {
|
||
"badge": "eyebrow", "headline": "head", "body": "body",
|
||
"title-head": "head", "title-sub": "sub", "wabe-claim": "sub",
|
||
};
|
||
|
||
/* Ein geloeschtes Element laesst sich nicht mehr anklicken. Ohne diese
|
||
Liste bliebe nur "Alles auf Standard" oder Rueckgaengig -- es waere also
|
||
praktisch verloren. */
|
||
/* Der Index kommt von aussen: seit jede Slide das ganze Blatt traegt, ist
|
||
die angezeigte Slide nicht mehr zwingend die bearbeitete. */
|
||
function verstecktHtml(i) {
|
||
const versteckt = Object.keys(edits[i] || {})
|
||
.filter(k => (edits[i][k] || {}).weg);
|
||
if (!versteckt.length) return "";
|
||
return '<div class="abschnitt">Gelöscht auf Slide ' + (i + 1) + '</div>' +
|
||
'<div class="opts">' +
|
||
versteckt.map(k => '<button data-zurueck="' + k + '" data-slide="' + i + '">' +
|
||
((STANDARD[k] || {}).label || k) + ' zurück</button>').join("") + '</div>';
|
||
}
|
||
|
||
/* Ausrichten an der Slide. Gemessen statt gerechnet: die Mockups stehen
|
||
perspektivisch im Raum und das Blatt ist gepolstert, ein Dreisatz ueber
|
||
left und top laege daneben. 92 px ist der Rand, den die Gestaltung
|
||
ohnehin haelt (.content). */
|
||
const RAND = 92;
|
||
function ausrichten(i, key, waag, senk) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
const slide = node && node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const l = (nr.left - sr.left) / k, o = (nr.top - sr.top) / k;
|
||
const b = nr.width / k, h = nr.height / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (waag) {
|
||
const ziel = waag === "links" ? RAND
|
||
: waag === "mitte" ? (1080 - b) / 2
|
||
: 1080 - RAND - b;
|
||
set.dx = Math.round((set.dx || 0) + (ziel - l));
|
||
}
|
||
if (senk) {
|
||
const ziel = senk === "oben" ? RAND
|
||
: senk === "mitte" ? (1350 - h) / 2
|
||
: 1350 - RAND - h;
|
||
set.dy = Math.round((set.dy || 0) + (ziel - o));
|
||
}
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
}
|
||
|
||
function syncEditPanel() {
|
||
const koerper = document.getElementById("el-inhalt");
|
||
const titel = document.getElementById("el-titel");
|
||
// Nur beim Auf- und Zugehen neu einpassen: sonst springt der Streifen bei
|
||
// jedem Wechsel der Auswahl.
|
||
const vorher = document.body.dataset.elem;
|
||
document.body.dataset.elem = picked ? "an" : "aus";
|
||
const wechsel = vorher !== document.body.dataset.elem;
|
||
if (!picked) { koerper.dataset.for = ""; if (wechsel) fitStrip(); return; }
|
||
|
||
const std = STANDARD[picked.key] || {};
|
||
const e = (edits[picked.slide] || {})[picked.key] || {};
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
const id = picked.slide + ":" + picked.key + ":" + (edits[picked.slide][picked.key] || {}).weg;
|
||
// Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
|
||
// pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
|
||
// wegreissen.
|
||
if (koerper.dataset.for !== id) {
|
||
koerper.dataset.for = id;
|
||
titel.textContent = (std.label || picked.key);
|
||
koerper.innerHTML =
|
||
'<p class="sub">Slide ' + (picked.slide + 1) + " von " + SLIDES.length + '</p>' +
|
||
(feld
|
||
? '<div class="abschnitt">Text</div>' +
|
||
'<textarea id="f-text" rows="3"></textarea>' +
|
||
'<div class="hint">*Wort* setzt den Akzent, Zeilenumbruch bricht um.</div>'
|
||
: "") +
|
||
'<div class="abschnitt">Maße</div>' +
|
||
'<div class="fields">' +
|
||
(std.prop
|
||
? '<label for="f-size">' + (std.prop === "font-size" ? "Schrift" : "Breite") +
|
||
'</label><input id="f-size" type="number" step="1" style="grid-column:2/5">'
|
||
: "") +
|
||
'<label for="f-dx">X</label><input id="f-dx" type="number" step="1">' +
|
||
'<label for="f-dy">Y</label><input id="f-dy" type="number" step="1">' +
|
||
'<label for="f-z">Ebene</label>' +
|
||
'<input id="f-z" type="number" step="1" placeholder="Standard" style="grid-column:2/5">' +
|
||
'</div>' +
|
||
'<div class="hint">' + (std.prop ? "Standard " + base + " px · " : "") +
|
||
'Obere linke Ecke, 0 / 0 ist die Mitte der Slide. Ebene wie z-index, ' +
|
||
'leer heißt Standard.</div>' +
|
||
(std.prop
|
||
? '<div class="row" style="margin-top:8px">' +
|
||
'<button class="btn secondary" id="sz-minus">A−</button>' +
|
||
'<button class="btn secondary" id="sz-plus">A+</button></div>'
|
||
: "") +
|
||
'<div class="abschnitt">Ausrichten an der Slide</div>' +
|
||
'<div class="opts">' +
|
||
'<button data-waag="links">Links</button>' +
|
||
'<button data-waag="mitte">Mitte</button>' +
|
||
'<button data-waag="rechts">Rechts</button></div>' +
|
||
'<div class="opts" style="margin-top:4px">' +
|
||
'<button data-senk="oben">Oben</button>' +
|
||
'<button data-senk="mitte">Mitte</button>' +
|
||
'<button data-senk="unten">Unten</button></div>' +
|
||
'<div class="abschnitt">Auf allen Slides</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="align-pos" title="Dieses Element auf allen Slides an dieselbe Stelle">Position angleichen</button>' +
|
||
'<button class="btn secondary" id="align-size" title="Dieses Element auf allen Slides gleich groß">Größe angleichen</button>' +
|
||
'</div>' +
|
||
'<div class="abschnitt">Element</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="reset-one">Zurücksetzen</button>' +
|
||
'<button class="btn secondary" id="f-weg">Löschen</button>' +
|
||
'</div>' +
|
||
verstecktHtml(picked.slide);
|
||
if (wechsel) fitStrip();
|
||
}
|
||
const put = (sel, v) => {
|
||
const el = koerper.querySelector(sel);
|
||
if (el && document.activeElement !== el) el.value = v;
|
||
};
|
||
if (feld) put("#f-text", htmlZuText(SLIDES[picked.slide][feld]));
|
||
if (std.prop) put("#f-size", e.size || base);
|
||
put("#f-z", e.z == null ? "" : e.z);
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
|
||
}
|
||
|
||
/* Auswahl aufheben, und damit das rechte Feld zu. Zwei Wege, weil einer
|
||
zu wenig war: der Knopf setzt voraus, dass man ihn sieht, und genau das
|
||
war nicht immer der Fall. Escape geht immer.
|
||
|
||
Nicht wenn gerade in einem Feld getippt wird -- dort soll Escape die
|
||
Eingabe verlassen und nicht die Auswahl wegnehmen. */
|
||
function auswahlAufheben() {
|
||
if (!picked) return;
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
document.getElementById("el-zu").addEventListener("click", auswahlAufheben);
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key !== "Escape") return;
|
||
const z = document.activeElement;
|
||
if (z && /^(INPUT|TEXTAREA|SELECT)$/.test(z.tagName)) return;
|
||
auswahlAufheben();
|
||
});
|
||
|
||
document.getElementById("el-inhalt").addEventListener("click", ev => {
|
||
const zurueck = ev.target.closest("[data-zurueck]");
|
||
if (zurueck) {
|
||
const k = zurueck.dataset.zurueck;
|
||
const i = Number(zurueck.dataset.slide);
|
||
const set = (edits[i] || {})[k];
|
||
if (set) delete set.weg;
|
||
applyEditsAll();
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
pick(i, k);
|
||
saveState();
|
||
setStatus(((STANDARD[k] || {}).label || k) + " ist wieder da", "ok");
|
||
return;
|
||
}
|
||
const b = ev.target.closest("button");
|
||
if (!b || !picked) return;
|
||
if (b.id === "sz-plus") { resize(1); return; }
|
||
if (b.id === "sz-minus") { resize(-1); return; }
|
||
if (b.id === "reset-one") { resetOne(); return; }
|
||
if (b.id === "align-pos") { alignAll("pos"); return; }
|
||
if (b.id === "align-size") { alignAll("size"); return; }
|
||
if (b.dataset.waag) { ausrichten(picked.slide, picked.key, b.dataset.waag, null); return; }
|
||
if (b.dataset.senk) { ausrichten(picked.slide, picked.key, null, b.dataset.senk); return; }
|
||
if (b.id !== "f-weg") return;
|
||
const i = picked.slide, key = picked.key;
|
||
if (key === "mock-2") {
|
||
// Beim zweiten Bild wirklich entfernen, nicht nur ausblenden: sonst
|
||
// stuende es weiter im Stand und im Auswahlfeld.
|
||
werteVon(i).media2 = null;
|
||
slidesNeuAufbauen();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
show(i);
|
||
syncInhalt();
|
||
} else {
|
||
(edits[i][key] = edits[i][key] || {}).weg = true;
|
||
applyEditsAll();
|
||
// Ausgewaehlt lassen: dann steht "Element zurueck" gleich bereit. Der
|
||
// Aufbau muss aber erzwungen werden, sonst fehlt die Rueckhol-Liste.
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
syncEditPanel();
|
||
}
|
||
saveState();
|
||
setStatus("Element entfernt", "ok");
|
||
});
|
||
|
||
/* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und
|
||
der Fokus waere weg. */
|
||
document.getElementById("el-inhalt").addEventListener("input", ev => {
|
||
const el = ev.target;
|
||
// textarea zaehlt nicht als input: ohne das faellt das Textfeld durch.
|
||
if (!picked || !el.matches("input, textarea")) return;
|
||
/* Der Text gehoert der Slide, nicht der Bearbeitung: er wandert in
|
||
dieselbe Ueberschreibung, die auch das Inhalt-Feld links beschreibt. */
|
||
if (el.id === "f-text") {
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
if (!feld) return;
|
||
werteVon(picked.slide)[feld] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
rebuildPage(picked.slide);
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
syncInhalt();
|
||
saveState();
|
||
return;
|
||
}
|
||
const raw = el.value.trim();
|
||
const n = Number(raw);
|
||
if (raw !== "" && !Number.isFinite(n)) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
if (el.id === "f-dx" || el.id === "f-dy") {
|
||
// Der Wert ist eine Stelle auf der Slide, kein Verschiebebetrag. Der
|
||
// Unterschied zur jetzigen Stelle wandert deshalb in den Versatz.
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (!c) return;
|
||
const ziel = raw === "" ? 0 : Math.round(n);
|
||
if (el.id === "f-dx") set.dx = (set.dx || 0) + (ziel - c.x);
|
||
else set.dy = (set.dy || 0) + (ziel - c.y);
|
||
}
|
||
else if (el.id === "f-z") {
|
||
if (raw === "") delete set.z;
|
||
else set.z = Math.round(n);
|
||
}
|
||
// Leere Groesse heisst Standard, nicht null.
|
||
else if (el.id === "f-size") {
|
||
if (raw === "") delete set.size;
|
||
else set.size = Math.max(8, Math.round(n));
|
||
}
|
||
applyEditsAll();
|
||
saveState();
|
||
});
|
||
/* Welche Kategorien offen sind, ist Bedienvorliebe und gehört nicht in den
|
||
Projektstand: gilt für beide Projekte gleichermaßen. */
|
||
(function rememberGroups() {
|
||
const KEY = "hivegrid.ui.gruppen";
|
||
let open = [];
|
||
try { open = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (_) {}
|
||
document.querySelectorAll("details.group[data-grp]").forEach(d => {
|
||
if (open.includes(d.dataset.grp)) d.open = true;
|
||
d.addEventListener("toggle", () => {
|
||
const now = [...document.querySelectorAll("details.group[open][data-grp]")]
|
||
.map(x => x.dataset.grp);
|
||
try { localStorage.setItem(KEY, JSON.stringify(now)); } catch (_) {}
|
||
});
|
||
});
|
||
})();
|
||
|
||
document.getElementById("forget").addEventListener("click", forgetProject);
|
||
document.getElementById("vorschau").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
setVorschau(b.dataset.p === "an");
|
||
});
|
||
|
||
/* In der Vorschau ist alles festgelegt: keine Auswahl, kein Ziehen, keine
|
||
Rahmen. Blaettern und Zoomen bleiben, das ist der Sinn der Sache. */
|
||
function setVorschau(an) {
|
||
editMode = an ? "aus" : "an";
|
||
press("vorschau", "p", an ? "an" : "aus");
|
||
deck.querySelectorAll(".slide").forEach(el => { el.dataset.editmode = editMode; });
|
||
if (an) {
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
}
|
||
|
||
/* ─── Angleichen über alle Slides ────────────────────────────────────────
|
||
Das gewählte Element ist die Vorlage, alle gleichnamigen auf den anderen
|
||
Slides ziehen nach. Angeglichen wird die Stelle, nicht der Versatz: jedes
|
||
Element liegt von sich aus woanders, und nur die Stelle ist vergleichbar.
|
||
In der Einzelansicht haben die übrigen Holder kein Layout, weil sie
|
||
ausgeblendet sind. Jeder wird deshalb kurz sichtbar gemacht — derselbe
|
||
Weg, den auch der Export geht. */
|
||
function alignAll(was) {
|
||
if (!picked) return;
|
||
const key = picked.key;
|
||
const ziel = was === "size" ? null : centreOf(picked.slide, key);
|
||
if (was !== "size" && !ziel) { setStatus("Element nicht messbar", "err"); return; }
|
||
const size = (edits[picked.slide][key] || {}).size;
|
||
const holders = deck.querySelectorAll(".holder");
|
||
let n = 0;
|
||
SLIDES.forEach((_, i) => {
|
||
if (i === picked.slide) return;
|
||
const holder = holders[i];
|
||
if (!holder || !holder.querySelector('.page[data-j="' + i + '"] [data-edit="' + key + '"]')) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (was === "size") {
|
||
if (size == null) delete set.size; else set.size = size;
|
||
} else {
|
||
const c = centreOf(i, key);
|
||
if (c) {
|
||
set.dx = (set.dx || 0) + (ziel.x - c.x);
|
||
set.dy = (set.dy || 0) + (ziel.y - c.y);
|
||
}
|
||
}
|
||
holder.style.display = restore;
|
||
n++;
|
||
});
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
const label = (STANDARD[key] || {}).label || key;
|
||
setStatus(label + ": " + (was === "size" ? "Größe" : "Position") +
|
||
" auf " + n + " weitere Slide" + (n === 1 ? "" : "s") + " übertragen", "ok");
|
||
}
|
||
|
||
|
||
/* ─── Merkzettel ─────────────────────────────────────────────────────────
|
||
Der gemerkte Stand liegt als eigenes Projekt in state/, also im Repo und
|
||
damit auch auf der anderen Maschine. Zurueckgeholt wird er, indem er zum
|
||
laufenden Stand gemacht und die Seite neu geladen wird: dann greift
|
||
derselbe Ladepfad wie sonst auch, statt dass ich hier von Hand jede
|
||
Einstellung nachziehe und dabei eine vergesse. */
|
||
const SNAP_PROJECT = PROJECT + "-merk";
|
||
const snapSave = document.getElementById("snap-save");
|
||
const snapLoad = document.getElementById("snap-load");
|
||
|
||
// Was aus der Merkdatei kommt, wird geprueft, bevor es den laufenden Stand
|
||
// ersetzt: Slide-Anzahl und Version muessen zu diesem Blatt passen.
|
||
function snapUsable(d) {
|
||
return !!d && typeof d.v === "number" && d.v >= 1 && d.v <= STATE_V
|
||
&& Array.isArray(d.edits) && d.edits.length === SLIDES.length
|
||
&& Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length
|
||
&& Array.isArray(d.bgState) && d.bgState.length === SLIDES.length;
|
||
}
|
||
|
||
// Immer frisch: der Merkzettel wird waehrend der Arbeit ueberschrieben, ein
|
||
// gecachter Treffer wuerde den Stand von vorhin zurueckholen.
|
||
function snapUrl() {
|
||
return "./state/" + SNAP_PROJECT + ".json?t=" + Date.now();
|
||
}
|
||
|
||
async function readSnap() {
|
||
const res = await fetch(snapUrl(), { cache: "no-store" });
|
||
if (!res.ok) throw new Error("kein gemerkter Stand");
|
||
return await res.json();
|
||
}
|
||
|
||
async function snapExists() {
|
||
try { return snapUsable(await readSnap()); }
|
||
catch (_) { return false; }
|
||
}
|
||
snapExists().then(ok => { snapLoad.disabled = !ok; });
|
||
|
||
snapSave.addEventListener("click", async () => {
|
||
snapSave.disabled = true;
|
||
try {
|
||
const mine = currentState();
|
||
const res = await fetch("/state?project=" + SNAP_PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(mine),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
// Gegengelesen: gemerkt ist erst, was auch wirklich in der Datei steht.
|
||
// Ein "ok" vom Server allein hat hier schon einmal zu wenig bewiesen.
|
||
const back = await readSnap();
|
||
if (!snapUsable(back)) throw new Error("Datei unbrauchbar zurueckgelesen");
|
||
if (JSON.stringify(back.edits) !== JSON.stringify(mine.edits)
|
||
|| JSON.stringify(back.bgState) !== JSON.stringify(mine.bgState)
|
||
|| JSON.stringify(back.layoutState) !== JSON.stringify(mine.layoutState)) {
|
||
throw new Error("Datei weicht vom Stand ab");
|
||
}
|
||
snapLoad.disabled = false;
|
||
setStatus("Stand gemerkt und gegengelesen", "ok");
|
||
} catch (err) {
|
||
snapLoad.disabled = !(await snapExists());
|
||
setStatus("Merken fehlgeschlagen: " + err.message, "err");
|
||
} finally { snapSave.disabled = false; }
|
||
});
|
||
|
||
snapLoad.addEventListener("click", async () => {
|
||
snapLoad.disabled = true;
|
||
try {
|
||
const d = await readSnap();
|
||
// Erst pruefen, dann ersetzen. Ein Merkzettel, der nicht passt, darf
|
||
// nicht den Stand kosten, der gerade auf dem Schirm steht.
|
||
if (!snapUsable(d)) throw new Error("gemerkter Stand passt nicht zu diesem Blatt");
|
||
// Rueckfahrkarte: der Stand von jetzt wird als <projekt>-vorher abgelegt,
|
||
// bevor er ueberschrieben wird. Ein Fehlklick ist damit umkehrbar.
|
||
await fetch("/state?project=" + PROJECT + "-vorher", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(currentState()),
|
||
}).catch(() => {});
|
||
// Den laufenden Timer entschaerfen und dichtmachen, sonst schreibt der
|
||
// Reload den alten Stand ueber den gerade zurueckgeholten.
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
stateLocked = true;
|
||
const put = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(d),
|
||
});
|
||
if (!put.ok) throw new Error("HTTP " + put.status);
|
||
// Gegenlesen, bevor neu geladen wird: erst wenn die Datei steht, ist der
|
||
// Reload sicher. Sonst startet das Blatt ohne Stand.
|
||
const back = await fetch(STATE_URL + "?t=" + Date.now(), { cache: "no-store" });
|
||
if (!back.ok || !snapUsable(await back.json())) throw new Error("Stand nicht geschrieben");
|
||
try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {}
|
||
location.reload();
|
||
} catch (err) {
|
||
stateLocked = false;
|
||
snapLoad.disabled = false;
|
||
setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err");
|
||
}
|
||
});
|
||
|
||
/* ─── Menueleiste ────────────────────────────────────────────────────────
|
||
Die Eintraege fuehren nichts selbst aus, sondern betaetigen die Schalter
|
||
im Bedienfeld. Damit gibt es weiter genau eine Stelle, an der ein
|
||
Verhalten steht, und die Leiste kann nicht auseinanderlaufen.
|
||
data-klick Knopf mit dieser id anklicken
|
||
data-opt Knopf einer Optionsgruppe: gruppe:attribut:wert
|
||
data-tun benannte Aktion
|
||
data-gehe anderes Projekt oeffnen */
|
||
(function menueleiste() {
|
||
const bar = document.getElementById("menubar");
|
||
if (!bar) return;
|
||
const menues = [...bar.querySelectorAll(".menu")];
|
||
const zu = () => menues.forEach(m => delete m.dataset.offen);
|
||
|
||
const AKTION = { undo, redo, vorschau: () => setVorschau(editMode === "an") };
|
||
|
||
// Haken und graue Eintraege erst beim Oeffnen setzen: vorher weiss die
|
||
// Leiste nicht, was gerade gilt.
|
||
function stand(m) {
|
||
m.querySelectorAll("button[data-opt]").forEach(b => {
|
||
const [gruppe, attr, wert] = b.dataset.opt.split(":");
|
||
const q = document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]');
|
||
const an = q && q.getAttribute("aria-pressed") === "true";
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (an ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll('button[data-tun="vorschau"]').forEach(b => {
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (editMode === "aus" ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll("button[data-klick]").forEach(b => {
|
||
const q = document.getElementById(b.dataset.klick);
|
||
b.disabled = !q || q.disabled;
|
||
});
|
||
m.querySelectorAll("button[data-gehe]").forEach(b => {
|
||
const hier = b.dataset.gehe.endsWith(location.pathname.split("/").pop() || "post1.html");
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (hier ? "✓" : "") + "</span>");
|
||
});
|
||
}
|
||
|
||
bar.addEventListener("click", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (titel) {
|
||
const m = titel.parentElement;
|
||
const war = m.dataset.offen === "ja";
|
||
zu();
|
||
if (!war) { m.dataset.offen = "ja"; stand(m); }
|
||
return;
|
||
}
|
||
const el = e.target.closest(".liste button");
|
||
if (!el || el.disabled) return;
|
||
zu();
|
||
if (el.dataset.gehe) { location.href = el.dataset.gehe; return; }
|
||
if (el.dataset.tun) { (AKTION[el.dataset.tun] || (() => {}))(); return; }
|
||
if (el.dataset.klick) { document.getElementById(el.dataset.klick)?.click(); return; }
|
||
if (el.dataset.opt) {
|
||
const [gruppe, attr, wert] = el.dataset.opt.split(":");
|
||
document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]')?.click();
|
||
}
|
||
});
|
||
|
||
// Ueberfahren wechselt das offene Menue, wie gewohnt.
|
||
bar.addEventListener("mouseover", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (!titel || !menues.some(m => m.dataset.offen === "ja")) return;
|
||
const m = titel.parentElement;
|
||
if (m.dataset.offen === "ja") return;
|
||
zu(); m.dataset.offen = "ja"; stand(m);
|
||
});
|
||
|
||
document.addEventListener("mousedown", e => { if (!bar.contains(e.target)) zu(); });
|
||
document.addEventListener("keydown", e => { if (e.key === "Escape") zu(); });
|
||
})();
|
||
|
||
document.getElementById("reset-all").addEventListener("click", resetAll);
|
||
|
||
// Übersicht der Standards, direkt aus derselben Tabelle wie der Reset.
|
||
(function renderStandards() {
|
||
const groups = [
|
||
["Schrift", ["badge", "headline", "body", "wabe-tagline", "wabe-claim", "title-head", "title-sub"]],
|
||
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop", "szene"]],
|
||
];
|
||
let html = "";
|
||
for (const [title, keys] of groups) {
|
||
html += "<h4>" + title + "</h4><table>";
|
||
for (const k of keys) {
|
||
const st = STANDARD[k];
|
||
html += "<tr><td>" + st.label + "</td><td>" + st.size + " px</td></tr>";
|
||
}
|
||
html += "</table>";
|
||
}
|
||
html += '<h4>Fest</h4><table>' +
|
||
'<tr><td>Slide</td><td>1080 × 1350</td></tr>' +
|
||
'<tr><td>Innenabstand</td><td>92 px</td></tr>' +
|
||
'<tr><td>Tag ausgerückt</td><td>−20 px</td></tr>' +
|
||
'<tr><td>Mockup</td><td>1090 px · 16:10</td></tr>' +
|
||
'</table>';
|
||
document.getElementById("std-list").innerHTML = html;
|
||
})();
|
||
|
||
document.getElementById("views").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); saveState(); }
|
||
});
|
||
window.addEventListener("resize", fitStrip);
|
||
|
||
/* deltaMode: 0 = Pixel, 1 = Zeilen (Firefox), 2 = Seiten. Ohne Umrechnung
|
||
zoomt Firefox pro Rastung um ein Vielfaches weniger als Chrome. */
|
||
function wheelPx(v, mode) { return v * (mode === 1 ? 16 : mode === 2 ? 400 : 1); }
|
||
|
||
/* Blankes Rad zoomt — eine gewöhnliche PC-Maus soll ohne Zusatztaste
|
||
auskommen. Strg/Cmd + Rad tut dasselbe, damit der Trackpad-Pinch (der
|
||
ctrlKey mitschickt) weiter greift. Waagerechtes Wischen und ⇧ + Rad
|
||
schieben dagegen den Ausschnitt: ein Trackpad darf sich nicht wie eine
|
||
Zoomwippe anfühlen. */
|
||
document.getElementById("scroller").addEventListener("wheel", e => {
|
||
const sc = e.currentTarget;
|
||
const dx = wheelPx(e.deltaX, e.deltaMode), dy = wheelPx(e.deltaY, e.deltaMode);
|
||
if (e.shiftKey || (!dy && dx)) {
|
||
e.preventDefault();
|
||
sc.scrollLeft += (dx || dy);
|
||
return;
|
||
}
|
||
if (!dy) return;
|
||
e.preventDefault();
|
||
const r = sc.getBoundingClientRect();
|
||
// Exponentiell gerechnet fühlen sich Rastung (~100 px auf einen Schlag)
|
||
// und Trackpad (viele kleine Werte) gleich an. Der Deckel hält die
|
||
// Schwung-Schübe im Zaum, die macOS nach dem Loslassen nachschiebt.
|
||
const k = Math.min(3, Math.max(1 / 3, Math.exp(-dy * 0.0015)));
|
||
setZoom((zoom || autoFit()) * k,
|
||
{ x: e.clientX - r.left, y: e.clientY - r.top });
|
||
}, { passive: false });
|
||
|
||
/* Panning: linke Maustaste auf der Fläche reicht. Leertaste und mittlere
|
||
Taste bleiben als gewohnte Wege bestehen — im Bearbeiten-Modus sind sie
|
||
der einzige, denn dort gehört der linke Zug dem Element unter dem Zeiger. */
|
||
(function panning() {
|
||
const sc = document.getElementById("scroller");
|
||
let space = false, active = null, dragged = false;
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.code !== "Space" || e.target.matches("input, textarea")) return;
|
||
e.preventDefault();
|
||
if (!space) { space = true; sc.classList.add("panready"); }
|
||
});
|
||
document.addEventListener("keyup", e => {
|
||
if (e.code !== "Space") return;
|
||
space = false; sc.classList.remove("panready");
|
||
});
|
||
|
||
sc.addEventListener("mousedown", e => {
|
||
dragged = false;
|
||
if (!space && e.button !== 1) {
|
||
// Linke Taste zieht die Fläche, außer sie hat im Bearbeiten-Modus
|
||
// ein Element erwischt: das verschiebt sich selbst.
|
||
if (e.button !== 0) return;
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
}
|
||
e.preventDefault();
|
||
active = { x: e.clientX, y: e.clientY, l: sc.scrollLeft, t: sc.scrollTop };
|
||
sc.classList.add("panning");
|
||
});
|
||
document.addEventListener("mousemove", e => {
|
||
if (!active) return;
|
||
const dx = e.clientX - active.x, dy = e.clientY - active.y;
|
||
// Erst ab ein paar Pixeln ist es ein Zug und kein Klick, sonst
|
||
// verschluckt jede zittrige Hand die Slide-Auswahl.
|
||
if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true;
|
||
sc.scrollLeft = active.l - dx;
|
||
sc.scrollTop = active.t - dy;
|
||
});
|
||
document.addEventListener("mouseup", () => {
|
||
active = null; sc.classList.remove("panning");
|
||
});
|
||
// Nach echtem Ziehen den Klick schlucken: sonst springt die Auswahl auf
|
||
// die Slide, über der die Maus zufällig loslässt.
|
||
sc.addEventListener("click", e => {
|
||
if (!dragged) return;
|
||
dragged = false;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}, true);
|
||
})();
|
||
|
||
document.getElementById("zoom-in").addEventListener("click", () => setZoom((zoom || autoFit()) * 1.25));
|
||
document.getElementById("zoom-out").addEventListener("click", () => setZoom((zoom || autoFit()) / 1.25));
|
||
document.getElementById("zoom-fit").addEventListener("click", () => { zoom = null; fitStrip(); });
|
||
|
||
nav.addEventListener("click", e => {
|
||
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
|
||
});
|
||
document.getElementById("variants").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { variant = b.dataset.v; press("variants", "v", variant); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("layouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { layoutState[current] = b.dataset.l; syncLayoutButtons(); rebuildPage(current); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("ints").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tagouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tagOut = b.dataset.o; press("tagouts", "o", tagOut); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("frames").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tilts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
});
|
||
|
||
document.getElementById("quality").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); }
|
||
});
|
||
|
||
document.getElementById("dl-png").addEventListener("click", guard(downloadOne));
|
||
document.getElementById("dl-all").addEventListener("click", guard(downloadAll));
|
||
|
||
/* ⌘/Strg + Z. Eigener Zuhoerer, weil der allgemeine unten alles mit
|
||
Sondertaste durchlaesst. In Eingabefeldern hat das Zurueckgehen des
|
||
Browsers Vorrang, sonst kaeme man aus einem Tippfehler nicht mehr heraus. */
|
||
document.addEventListener("keydown", e => {
|
||
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k !== "z" && k !== "y") return;
|
||
const t = e.target;
|
||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||
e.preventDefault();
|
||
if (k === "y" || e.shiftKey) redo(); else undo();
|
||
});
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
// In einem Eingabefeld gehoeren die Tasten dem Feld, nicht den Kuerzeln.
|
||
// Ist nichts fokussiert, ist das Ziel das Dokument selbst — das kennt
|
||
// kein matches. Seit Slides hinzukommen und wegfallen koennen, ist genau
|
||
// das erreichbar: der Neuaufbau entfernt den zuletzt fokussierten Knopf.
|
||
if (e.target && e.target.matches && e.target.matches("input, textarea, select")) return;
|
||
const k = e.key.toLowerCase();
|
||
if (editMode === "an" && picked && e.key.startsWith("Arrow")) {
|
||
const step = e.shiftKey ? 10 : 1;
|
||
if (e.key === "ArrowRight") nudge(step, 0);
|
||
else if (e.key === "ArrowLeft") nudge(-step, 0);
|
||
else if (e.key === "ArrowDown") nudge(0, step);
|
||
else if (e.key === "ArrowUp") nudge(0, -step);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "ArrowRight") show(current + 1);
|
||
else if (e.key === "ArrowLeft") show(current - 1);
|
||
else if (k === "v") { variant = variant === "dunkel" ? "hell" : "dunkel"; press("variants", "v", variant); applyAll(); saveState(); }
|
||
else if (k === "l") {
|
||
const cat = SLIDES[current].layouts;
|
||
const n = cat.findIndex(x => x.id === layoutState[current]);
|
||
layoutState[current] = cat[(n + 1) % cat.length].id;
|
||
syncLayoutButtons(); rebuildPage(current); applyAll(); saveState();
|
||
}
|
||
else if (k === "i") {
|
||
const st = bgState[current];
|
||
st.int = st.int === "dezent" ? "kraeftig" : "dezent";
|
||
syncBgButtons(); applyAll();
|
||
}
|
||
else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
else if (e.key === "+" || e.key === "=") { setZoom((zoom || autoFit()) * 1.25); }
|
||
else if (e.key === "-") { setZoom((zoom || autoFit()) / 1.25); }
|
||
else if (e.key === "0") { zoom = null; fitStrip(); }
|
||
else if (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); }
|
||
else if (k === "p") { setVorschau(editMode === "an"); }
|
||
else if (k === "r") {
|
||
const cyc = ["chamfer", "rund", "stapel", "aus"];
|
||
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
|
||
press("frames", "f", frameStyle); applyAll(); saveState();
|
||
}
|
||
else if (k === "d") guard(downloadOne)();
|
||
else if (k === "a") guard(downloadAll)();
|
||
});
|
||
|
||
/* Boot: inline the screens, wait for Manrope to actually be resident
|
||
(otherwise the first export silently falls back to a system font), and
|
||
pre-compute the font CSS once instead of on every render. */
|
||
(async function boot() {
|
||
try {
|
||
setStatus("Lade Screens ...");
|
||
await bilderNachladen();
|
||
await preloadAssets();
|
||
const restored = await loadState();
|
||
/* Verweise auf Bilder, die es nicht mehr gibt, hier aufloesen statt sie
|
||
bis in den Export zu schleppen. Sie entstehen, wenn eine importierte
|
||
Datei geloescht oder nicht mitgepusht wurde. */
|
||
const verloren = [];
|
||
slideListe.forEach((e, i) => {
|
||
const w = e.werte || {};
|
||
if (w.media2 && w.media2.img && !assets[w.media2.img]) {
|
||
verloren.push(w.media2.img.split("/").pop());
|
||
w.media2 = null;
|
||
}
|
||
if (w.img && !assets[w.img]) { verloren.push(w.img.split("/").pop()); delete w.img; }
|
||
});
|
||
if (verloren.length) {
|
||
slidesNeuAufbauen();
|
||
saveState();
|
||
}
|
||
buildDeck();
|
||
deck.dataset.view = view;
|
||
press("variants", "v", variant);
|
||
press("tilts", "t", tilt);
|
||
press("quality", "q", String(superSample));
|
||
press("tagouts", "o", tagOut);
|
||
press("frames", "f", frameStyle);
|
||
press("views", "w", view);
|
||
anzahlNachziehen();
|
||
show(current);
|
||
if (verloren.length)
|
||
setStatus("Fehlende Bilder entfernt: " + verloren.join(", ") +
|
||
" · bitte neu importieren", "err");
|
||
else if (restored) setStatus("Projektstand geladen", "ok");
|
||
await document.fonts.ready;
|
||
fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide"));
|
||
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
|
||
if (checkCollisions()) setStatus("Bereit. " + SLIDES.length +
|
||
(SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × 1350 px", "ok");
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("Fehler beim Laden: " + err.message, "err");
|
||
}
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|