Canvas-Bearbeitung, Projektspeicher und Arbeitsfläche

Der Generator ist von einer Vorschau zu einer Werkbank geworden: Slides lassen
sich direkt im Canvas verschieben und skalieren, der Stand bleibt erhalten.

- Streifenansicht mit allen Slides nebeneinander, Auswahl per Klick
- Durchlaufendes Blatt: jede Slide trägt die Inhalte ihrer Nachbarn, damit
  verschobene Elemente über die Kante hinweg weiterlaufen
- Bearbeiten im Canvas: ziehen, Schriftgrößen und Bildbreiten, Reset je
  Element oder komplett
- Projektstand pro Post im Browser gespeichert, Navigation zwischen den Posts
- Arbeitsfläche vom Bedienfeld getrennt, eigener Zoom und Panning
- Bildrahmen als Chamfer, rund, Stapel oder aus; Schatten schaltbar
- Screenshots zeigen den vollen Screen statt eines Ausschnitts
- Kategorien in der Sidebar standardmäßig zugeklappt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-02 14:07:23 +02:00
1 parent 124524482b
commit 170b72a538
2 files changed
+379 -66

No files matched your search

+17 -4
View File
@@ -24,11 +24,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overscroll-behavior: none; }
body { body {
font-family: 'Manrope', system-ui, sans-serif; font-family: 'Manrope', system-ui, sans-serif;
background: #17181F; color: #E8EAEE; background: #17181F; color: #E8EAEE;
min-height: 100vh; display: flex; gap: 40px; padding: 32px; height: 100vh; overflow: hidden; display: flex;
align-items: flex-start;
} }
/* ─── Projekt-Navigation ─────────────────────────────────────────────── */ /* ─── Projekt-Navigation ─────────────────────────────────────────────── */
@@ -50,7 +50,11 @@
.forget:hover { color: #EB7C6B; } .forget:hover { color: #EB7C6B; }
/* ─── Control panel ──────────────────────────────────────────────────── */ /* ─── Control panel ──────────────────────────────────────────────────── */
.panel { width: 300px; flex: none; position: sticky; top: 32px; } .panel {
width: 306px; flex: none; height: 100vh; overflow-y: 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 h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 24px; line-height: 1.5; } .panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 24px; line-height: 1.5; }
@@ -95,7 +99,16 @@
.status.err { color: #EB7C6B; } .status.err { color: #EB7C6B; }
/* ─── Stage ──────────────────────────────────────────────────────────── */ /* ─── Stage ──────────────────────────────────────────────────────────── */
.stage { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 14px; } /* Arbeitsfläche, klar abgesetzt von der Bedienleiste. */
.stage {
flex: 1; position: relative; height: 100vh; overflow: auto;
overscroll-behavior: contain;
background-color: #101116;
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
background-size: 24px 24px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 48px;
}
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; } .stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
/* The wrapper reserves the *scaled* footprint so the page doesn't jump; /* The wrapper reserves the *scaled* footprint so the page doesn't jump;
+362 -62
View File
@@ -26,11 +26,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overscroll-behavior: none; }
body { body {
font-family: 'Manrope', system-ui, sans-serif; font-family: 'Manrope', system-ui, sans-serif;
background: #17181F; color: #E8EAEE; background: #17181F; color: #E8EAEE;
min-height: 100vh; display: flex; gap: 36px; padding: 28px; height: 100vh; overflow: hidden; display: flex;
align-items: flex-start;
} }
/* ─── Projekt-Navigation ─────────────────────────────────────────────── */ /* ─── Projekt-Navigation ─────────────────────────────────────────────── */
@@ -52,16 +52,37 @@
.forget:hover { color: #EB7C6B; } .forget:hover { color: #EB7C6B; }
/* ─── Control panel ──────────────────────────────────────────────────── */ /* ─── Control panel ──────────────────────────────────────────────────── */
.panel { width: 290px; flex: none; position: sticky; top: 28px; } .panel {
width: 306px; flex: none; height: 100vh; overflow-y: 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 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 .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 22px; line-height: 1.5; }
.panel a { color: #8A8F9B; } .panel a { color: #8A8F9B; }
.group { margin-bottom: 20px; } .group { margin-bottom: 20px; }
.group > label { .group > label,
.group > summary {
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em; display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: #6E7480; margin-bottom: 9px; 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 { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.opts.one { grid-template-columns: 1fr; } .opts.one { grid-template-columns: 1fr; }
@@ -123,13 +144,64 @@
.status.err { color: #EB7C6B; } .status.err { color: #EB7C6B; }
/* ─── Stage ──────────────────────────────────────────────────────────── */ /* ─── Stage ──────────────────────────────────────────────────────────── */
.stage { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; } /* ─── Arbeitsfläche ───────────────────────────────────────────────────────
Klar abgesetzt von der Sidebar: eigener Grund, eigener Scroll, eigener
Zoom. Was hier passiert, lässt die Bedienleiste unberührt. */
.stage {
flex: 1; position: relative; height: 100vh; 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; } .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; }
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der /* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */ skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
.strip-wrap { overflow: hidden; flex: none; align-self: flex-start; } .strip-wrap { overflow: hidden; flex: none; align-self: flex-start; }
.strip { display: flex; transform-origin: top left; } /* 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; } .holder { position: relative; flex: none; cursor: pointer; }
/* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie /* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie
im Export landet. */ im Export landet. */
@@ -345,6 +417,9 @@
22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px), 22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px),
calc(100% - 22px) 100%, 22px 100%, 0 calc(100% - 22px), 0 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, der Schatten bleibt als einzige Tiefe. */
/* clip-path also clips box-shadow, so depth has to come from a filter — /* clip-path also clips box-shadow, so depth has to come from a filter —
documented caveat in lib/chamfer.ts. */ documented caveat in lib/chamfer.ts. */
filter: drop-shadow(0 34px 60px rgba(0,0,0,.30)); filter: drop-shadow(0 34px 60px rgba(0,0,0,.30));
@@ -363,6 +438,57 @@
calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px
); );
} }
.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,
jedes Blatt trägt seinen eigenen Schatten: box-shadow statt drop-shadow,
weil ohne clip-path nichts mehr beschnitten wird und jede Karte einzeln
abheben soll. */
.sheet-back, .sheet-mid {
display: none; position: absolute; inset: 0; z-index: 0;
background: #fff; border-radius: 22px; overflow: hidden;
}
.slide[data-frame="stapel"] .mock {
clip-path: none; padding: 0; background: none; filter: none;
}
.slide[data-frame="stapel"] .mock-inner {
clip-path: none; border-radius: 22px; z-index: 2;
box-shadow: 0 34px 64px rgba(0,0,0,.30), 0 3px 10px rgba(0,0,0,.14);
}
.slide[data-frame="stapel"] .sheet-mid {
display: block; z-index: 1;
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
box-shadow: 0 22px 44px rgba(0,0,0,.20);
}
.slide[data-frame="stapel"] .sheet-back {
display: block;
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
box-shadow: 0 18px 38px rgba(0,0,0,.16);
}
/* 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;
}
/* Schatten global abschaltbar. Zwei Techniken im Spiel: drop-shadow für die
chamferten Rahmen (clip-path würde box-shadow wegschneiden) und box-shadow
für den Stapel. Beide müssen mit. */
.slide[data-shadow="aus"] .mock,
.slide[data-shadow="aus"] .laptop,
.slide[data-shadow="aus"] .hex-frame { filter: none; }
.slide[data-shadow="aus"] .mock-inner,
.slide[data-shadow="aus"] .sheet-mid,
.slide[data-shadow="aus"] .sheet-back { box-shadow: none; }
/* Screens are ~2000px wide landscape captures; showing them whole makes the /* 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 UI text unreadable at this size, so each one is zoomed onto its telling
region via the per-slide vars below. */ region via the per-slide vars below. */
@@ -539,29 +665,29 @@
<div class="opts one" id="slides-nav"></div> <div class="opts one" id="slides-nav"></div>
</div> </div>
<div class="group"> <details class="group" data-grp="variante">
<label>Variante</label> <summary>Variante</summary>
<div class="opts" id="variants"> <div class="opts" id="variants">
<button data-v="dunkel" aria-pressed="true"><span class="swatch" style="background:#30313D"></span>Dunkel</button> <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> <button data-v="hell" aria-pressed="false"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="layout-nur-diese-slide">
<label>Layout (nur diese Slide)</label> <summary>Layout (nur diese Slide)</summary>
<div class="opts" id="layouts"></div> <div class="opts" id="layouts"></div>
</div> </details>
<div class="group"> <details class="group" data-grp="tag-optisch-ausger-ckt">
<label>Tag optisch ausgerückt</label> <summary>Tag optisch ausgerückt</summary>
<div class="opts" id="tagouts"> <div class="opts" id="tagouts">
<button data-o="an" aria-pressed="true">An</button> <button data-o="an" aria-pressed="true">An</button>
<button data-o="aus" aria-pressed="false">Aus</button> <button data-o="aus" aria-pressed="false">Aus</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="bearbeiten">
<label>Bearbeiten</label> <summary>Bearbeiten</summary>
<div class="opts" id="editmodes" style="margin-bottom:8px"> <div class="opts" id="editmodes" style="margin-bottom:8px">
<button data-e="an" aria-pressed="false">An</button> <button data-e="an" aria-pressed="false">An</button>
<button data-e="aus" aria-pressed="true">Aus</button> <button data-e="aus" aria-pressed="true">Aus</button>
@@ -575,31 +701,31 @@
<button class="btn secondary" id="reset-one">Element zurück</button> <button class="btn secondary" id="reset-one">Element zurück</button>
</div> </div>
<button class="btn secondary" id="reset-all" style="width:100%;margin-top:8px">Alles auf Standard</button> <button class="btn secondary" id="reset-all" style="width:100%;margin-top:8px">Alles auf Standard</button>
</div> </details>
<details class="std"> <details class="std">
<summary>Standardwerte</summary> <summary>Standardwerte</summary>
<div id="std-list"></div> <div id="std-list"></div>
</details> </details>
<div class="group"> <details class="group" data-grp="ansicht">
<label>Ansicht</label> <summary>Ansicht</summary>
<div class="opts" id="views"> <div class="opts" id="views">
<button data-w="streifen" aria-pressed="true">Streifen</button> <button data-w="streifen" aria-pressed="true">Streifen</button>
<button data-w="einzeln" aria-pressed="false">Einzeln</button> <button data-w="einzeln" aria-pressed="false">Einzeln</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="durchgehend-alle-slides">
<label>Durchgehend (alle Slides)</label> <summary>Durchgehend (alle Slides)</summary>
<div class="opts" id="conts"> <div class="opts" id="conts">
<button data-c="an" aria-pressed="false">An</button> <button data-c="an" aria-pressed="false">An</button>
<button data-c="aus" aria-pressed="true">Aus</button> <button data-c="aus" aria-pressed="true">Aus</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="muster-nur-diese-slide">
<label>Muster (nur diese Slide)</label> <summary>Muster (nur diese Slide)</summary>
<div class="opts three" id="bgs"> <div class="opts three" id="bgs">
<button data-b="formen" aria-pressed="true">Formen</button> <button data-b="formen" aria-pressed="true">Formen</button>
<button data-b="formen-g" aria-pressed="false">Formen + G</button> <button data-b="formen-g" aria-pressed="false">Formen + G</button>
@@ -609,32 +735,47 @@
<button data-b="raster" aria-pressed="false">Raster</button> <button data-b="raster" aria-pressed="false">Raster</button>
<button data-b="aus" aria-pressed="false">Aus</button> <button data-b="aus" aria-pressed="false">Aus</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="intensit-t">
<label>Intensität</label> <summary>Intensität</summary>
<div class="opts" id="ints"> <div class="opts" id="ints">
<button data-i="dezent" aria-pressed="true">Dezent</button> <button data-i="dezent" aria-pressed="true">Dezent</button>
<button data-i="kraeftig" aria-pressed="false">Kräftig</button> <button data-i="kraeftig" aria-pressed="false">Kräftig</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="bildrahmen">
<label>Mockup-Perspektive</label> <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>
<label style="display:block;margin:14px 0 9px">Schatten</label>
<div class="opts" id="shadows">
<button data-s="an" aria-pressed="true">An</button>
<button data-s="aus" aria-pressed="false">Aus</button>
</div>
</details>
<details class="group" data-grp="mockup-perspektive">
<summary>Mockup-Perspektive</summary>
<div class="opts" id="tilts"> <div class="opts" id="tilts">
<button data-t="an" aria-pressed="true">Gekippt</button> <button data-t="an" aria-pressed="true">Gekippt</button>
<button data-t="aus" aria-pressed="false">Gerade</button> <button data-t="aus" aria-pressed="false">Gerade</button>
</div> </div>
</div> </details>
<div class="group"> <details class="group" data-grp="renderqualit-t">
<label>Renderqualität</label> <summary>Renderqualität</summary>
<div class="opts three" id="quality"> <div class="opts three" id="quality">
<button data-q="1" aria-pressed="false">1×</button> <button data-q="1" aria-pressed="false">1×</button>
<button data-q="2" aria-pressed="true">2×</button> <button data-q="2" aria-pressed="true">2×</button>
<button data-q="3" aria-pressed="false">3×</button> <button data-q="3" aria-pressed="false">3×</button>
</div> </div>
</div> </details>
<div class="group"> <div class="group">
<label>Download</label> <label>Download</label>
@@ -644,26 +785,40 @@
<button id="dl-all" class="btn secondary" style="width:100%" disabled>Alle 5 Slides als ZIP</button> <button id="dl-all" class="btn secondary" style="width:100%" disabled>Alle 5 Slides als ZIP</button>
</div> </div>
<div class="group" style="margin-bottom:0"> <details class="group" style="margin-bottom:0" data-grp="tastatur">
<label>Tastatur</label> <summary>Tastatur</summary>
<div class="keys"> <div class="keys">
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br> <kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
<kbd>L</kbd> Layout · <kbd>M</kbd> Muster · <kbd>I</kbd> Intensität<br> <kbd>L</kbd> Layout · <kbd>M</kbd> Muster · <kbd>I</kbd> Intensität<br>
<kbd>C</kbd> Durchgehend · <kbd>W</kbd> Ansicht · <kbd>G</kbd> Tag<br> <kbd>C</kbd> Durchgehend · <kbd>W</kbd> Ansicht · <kbd>G</kbd> Tag<br>
<kbd>R</kbd> Bildrahmen · <kbd>S</kbd> Schatten<br>
<kbd>T</kbd> Perspektive<br> <kbd>T</kbd> Perspektive<br>
<kbd>E</kbd> Bearbeiten · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br> <kbd>E</kbd> Bearbeiten · 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>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP <kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
</div> </div>
</div> </details>
<div class="status" id="status">Lade Schriften und Screens ...</div> <div class="status" id="status">Lade Schriften und Screens ...</div>
</aside> </aside>
<main class="stage"> <main class="stage" id="stage">
<div class="strip-wrap" id="strip-wrap"> <div class="scroller" id="scroller">
<div class="strip" id="deck" data-view="streifen"></div> <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="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> </div>
<p class="stage-meta" id="meta">Vorschau 45 %, Export in voller Auflösung</p>
</main> </main>
<script src="./vendor/html-to-image.js"></script> <script src="./vendor/html-to-image.js"></script>
@@ -707,7 +862,8 @@
head: 'Du siehst auf einen Blick,<br><span class="a">wo jeder Auftrag steht.</span>', head: 'Du siehst auf einen Blick,<br><span class="a">wo jeder Auftrag steht.</span>',
body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.", body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.",
img: "./assets/screens/pipelines.png", img: "./assets/screens/pipelines.png",
imgScale: 1.28, imgX: "-2%", imgY: "-4%", mockAR: 1.8519, // 2000 x 1080
imgScale: 1, imgX: "0%", imgY: "0%",
}, },
{ {
key: "workflow-engine", type: "feature", key: "workflow-engine", type: "feature",
@@ -716,7 +872,8 @@
head: '<span class="a">Wenn-dann</span><br>statt Handarbeit.', head: '<span class="a">Wenn-dann</span><br>statt Handarbeit.',
body: "Ausl\u00f6ser w\u00e4hlen, Aktionen dranh\u00e4ngen, fertig. Anfrage rein, Aufgabe raus, Best\u00e4tigung an den Kunden, alles automatisch und ohne eine Zeile Code.", body: "Ausl\u00f6ser w\u00e4hlen, Aktionen dranh\u00e4ngen, fertig. Anfrage rein, Aufgabe raus, Best\u00e4tigung an den Kunden, alles automatisch und ohne eine Zeile Code.",
img: "./assets/screens/workflow.png", img: "./assets/screens/workflow.png",
imgScale: 1.34, imgX: "-8%", imgY: "-22%", mockAR: 1.5038, // 2000 x 1330
imgScale: 1, imgX: "0%", imgY: "0%",
}, },
{ {
key: "chat", type: "feature", key: "chat", type: "feature",
@@ -725,7 +882,9 @@
head: 'Jedes Projekt bekommt<br>automatisch <span class="a">seinen Kanal.</span>', head: 'Jedes Projekt bekommt<br>automatisch <span class="a">seinen Kanal.</span>',
body: "Foto von der Baustelle rein, alle sehen\u2019s sofort und es landet direkt in den Projektdateien. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.", body: "Foto von der Baustelle rein, alle sehen\u2019s sofort und es landet direkt in den Projektdateien. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
img: "./assets/screens/chat.png", img: "./assets/screens/chat.png",
imgScale: 1.30, imgX: "-14%", imgY: "-8%", // 2000 x 1357. Etwas schmaler, sonst reicht die Hoehe unter dem Text nicht.
mockW: 1070, mockAR: 1.4738,
imgScale: 1, imgX: "0%", imgY: "0%",
}, },
{ {
key: "form-builder", type: "feature", key: "form-builder", type: "feature",
@@ -754,18 +913,35 @@
"logo-full": { label: "Logo", size: 560, prop: "width" }, "logo-full": { label: "Logo", size: 560, prop: "width" },
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" }, "screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
"laptop": { label: "Laptop", size: 960, prop: "width" }, "laptop": { label: "Laptop", size: 960, prop: "width" },
"mock": { label: "Bild", size: null }, "mock": { label: "Bild", size: null, prop: "width" },
"hex-frame": { label: "Wabe", size: null }, "hex-frame": { label: "Wabe", size: 778, prop: "width" },
}; };
const EDIT_KEYS = Object.keys(STANDARD); 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;
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));
}
let edits = SLIDES.map(() => ({})); let edits = SLIDES.map(() => ({}));
let picked = null; // { slide, key } let picked = null; // { slide, key }
let editMode = "aus"; let editMode = "aus";
let current = 0; let current = 0;
let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an"; let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an", frameStyle = "chamfer", shadow = "an";
/* ─── Projektstand ──────────────────────────────────────────────────────── /* ─── Projektstand ────────────────────────────────────────────────────────
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts, Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
@@ -777,7 +953,7 @@
function saveState() { function saveState() {
try { try {
localStorage.setItem(STORE, JSON.stringify({ localStorage.setItem(STORE, JSON.stringify({
v: 1, variant, tilt, superSample, continuous, view, tagOut, v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, shadow,
current, layoutState, bgState, edits, current, layoutState, bgState, edits,
})); }));
markSaved(); markSaved();
@@ -805,6 +981,8 @@
continuous = d.continuous || continuous; continuous = d.continuous || continuous;
view = d.view || view; view = d.view || view;
tagOut = d.tagOut || tagOut; tagOut = d.tagOut || tagOut;
frameStyle = d.frameStyle || frameStyle;
shadow = d.shadow || shadow;
current = Number.isInteger(d.current) ? d.current : 0; current = Number.isInteger(d.current) ? d.current : 0;
return true; return true;
} }
@@ -834,14 +1012,40 @@
/* Der Streifen wird auf die verfügbare Breite skaliert, die Einzelansicht auf /* 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 feste 45 %. Der Rahmen bekommt die skalierte Größe, damit die Seite nicht
springt. */ springt. */
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
hält der Wert, bis er auf "Fit" zurückgeht. */
let zoom = 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);
}
function fitStrip() { function fitStrip() {
const wrap = document.getElementById("strip-wrap"); const wrap = document.getElementById("strip-wrap");
const total = 1080 * SLIDES.length; const k = zoom || autoFit();
const avail = Math.max(320, wrap.parentElement.clientWidth);
const k = view === "streifen" ? Math.min(avail / total, 0.45) : 0.45;
deck.style.transform = "scale(" + k + ")"; deck.style.transform = "scale(" + k + ")";
wrap.style.width = Math.round((view === "streifen" ? total : 1080) * k) + "px"; wrap.style.width = Math.round(stripWidth() * k) + "px";
wrap.style.height = Math.round(1350 * 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";
}
function setZoom(k, anchor) {
const scroller = document.getElementById("scroller");
const before = zoom || autoFit();
zoom = Math.min(2, Math.max(0.05, k));
// Um den Mauszeiger zoomen, nicht um die linke obere Ecke.
const ratio = zoom / before;
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
fitStrip();
scroller.scrollLeft = (scroller.scrollLeft + ax) * ratio - ax;
scroller.scrollTop = (scroller.scrollTop + ay) * ratio - ay;
} }
const setStatus = (msg, kind) => { const setStatus = (msg, kind) => {
@@ -1030,7 +1234,14 @@
if (sl.mockW) mock.style.setProperty("--mock-w", sl.mockW + "px"); if (sl.mockW) mock.style.setProperty("--mock-w", sl.mockW + "px");
if (sl.mockAR) mock.style.setProperty("--mock-ar", sl.mockAR); if (sl.mockAR) mock.style.setProperty("--mock-ar", sl.mockAR);
if (sl.mockLeft) mock.style.setProperty("--mock-left", sl.mockLeft + "px"); if (sl.mockLeft) mock.style.setProperty("--mock-left", sl.mockLeft + "px");
// Hinterstes Blatt bekommt den Screen der nächsten Feature-Slide.
const withImg = SLIDES.filter(x => x.img);
const other = withImg[(withImg.findIndex(x => x.img === sl.img) + 1) % withImg.length];
mock.innerHTML = 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 + '<div class="mock-inner" style="--img-scale:' + sl.imgScale +
';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' + ';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' +
'<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>'; '<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>';
@@ -1066,6 +1277,8 @@
el.dataset.layout = layoutState[i]; el.dataset.layout = layoutState[i];
el.dataset.cont = continuous; el.dataset.cont = continuous;
el.dataset.tagout = tagOut; el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
el.dataset.shadow = shadow;
el.dataset.editmode = editMode; el.dataset.editmode = editMode;
el.style.setProperty("--slide-i", i); el.style.setProperty("--slide-i", i);
el.innerHTML = BG_HTML; el.innerHTML = BG_HTML;
@@ -1115,6 +1328,7 @@
} }
function applyAll() { function applyAll() {
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
deck.querySelectorAll(".slide").forEach((el, i) => { deck.querySelectorAll(".slide").forEach((el, i) => {
el.dataset.variant = variant; el.dataset.variant = variant;
el.dataset.tilt = tilt; el.dataset.tilt = tilt;
@@ -1123,6 +1337,8 @@
el.dataset.layout = layoutState[i]; el.dataset.layout = layoutState[i];
el.dataset.cont = continuous; el.dataset.cont = continuous;
el.dataset.tagout = tagOut; el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
el.dataset.shadow = shadow;
if (SLIDES[i].type === "title") rebuildPage(i); if (SLIDES[i].type === "title") rebuildPage(i);
el.dataset.editmode = editMode; el.dataset.editmode = editMode;
}); });
@@ -1431,12 +1647,13 @@
saveState(); saveState();
} }
function resize(delta) { function resize(dir) {
if (!picked) return; if (!picked) return;
const std = STANDARD[picked.key]; const std = STANDARD[picked.key];
if (!std || !std.prop) return; if (!std || !std.prop) return;
const base = stdSize(picked.key, picked.slide);
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {}); const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
set.size = Math.max(8, (set.size || std.size) + delta); set.size = Math.max(8, (set.size || base) + dir * stepFor(picked.key, picked.slide));
applyEditsAll(); applyEditsAll();
pick(picked.slide, picked.key); pick(picked.slide, picked.key);
saveState(); saveState();
@@ -1465,13 +1682,30 @@
if (!picked) { box.innerHTML = '<span class="dim">Nichts ausgewählt</span>'; return; } if (!picked) { box.innerHTML = '<span class="dim">Nichts ausgewählt</span>'; return; }
const std = STANDARD[picked.key] || {}; const std = STANDARD[picked.key] || {};
const e = (edits[picked.slide] || {})[picked.key] || {}; const e = (edits[picked.slide] || {})[picked.key] || {};
const val = e.size || std.size; const base = stdSize(picked.key, picked.slide);
const val = e.size || base;
const unit = std.prop === "font-size" ? "px Schrift" : "px breit"; const unit = std.prop === "font-size" ? "px Schrift" : "px breit";
box.innerHTML = box.innerHTML =
'<b>' + (std.label || picked.key) + '</b> · Slide ' + (picked.slide + 1) + '<br>' + '<b>' + (std.label || picked.key) + '</b> · Slide ' + (picked.slide + 1) + '<br>' +
(std.prop ? val + " " + unit + (e.size ? " (Standard " + std.size + ")" : "") + "<br>" : "") + (std.prop ? val + " " + unit + (e.size ? " (Standard " + base + ")" : "") + "<br>" : "") +
"Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px"; "Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px";
} }
/* 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("forget").addEventListener("click", forgetProject);
document.getElementById("editmodes").addEventListener("click", e => { document.getElementById("editmodes").addEventListener("click", e => {
const b = e.target.closest("button"); const b = e.target.closest("button");
@@ -1485,8 +1719,8 @@
syncEditPanel(); syncEditPanel();
} }
}); });
document.getElementById("sz-plus").addEventListener("click", () => resize(2)); document.getElementById("sz-plus").addEventListener("click", () => resize(1));
document.getElementById("sz-minus").addEventListener("click", () => resize(-2)); document.getElementById("sz-minus").addEventListener("click", () => resize(-1));
document.getElementById("reset-one").addEventListener("click", resetOne); document.getElementById("reset-one").addEventListener("click", resetOne);
document.getElementById("reset-all").addEventListener("click", resetAll); document.getElementById("reset-all").addEventListener("click", resetAll);
@@ -1520,10 +1754,56 @@
}); });
document.getElementById("views").addEventListener("click", e => { document.getElementById("views").addEventListener("click", e => {
const b = e.target.closest("button"); const b = e.target.closest("button");
if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); show(current); saveState(); } if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); saveState(); }
}); });
window.addEventListener("resize", fitStrip); window.addEventListener("resize", fitStrip);
/* Strg/Cmd + Rad zoomt, blankes Rad scrollt. Trackpad-Pinch schickt
ctrlKey mit, verhält sich also automatisch richtig. */
document.getElementById("scroller").addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setZoom((zoom || autoFit()) * (e.deltaY < 0 ? 1.12 : 1 / 1.12),
{ x: e.clientX - r.left, y: e.clientY - r.top });
}, { passive: false });
/* Panning als verlässlicher Weg, unabhängig von Trackpad-Gesten:
Leertaste gedrückt halten und ziehen, oder mittlere Maustaste. */
(function panning() {
const sc = document.getElementById("scroller");
let space = false, active = null;
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 => {
if (!space && e.button !== 1) 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;
sc.scrollLeft = active.l - (e.clientX - active.x);
sc.scrollTop = active.t - (e.clientY - active.y);
});
document.addEventListener("mouseup", () => {
active = null; sc.classList.remove("panning");
});
})();
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 => { nav.addEventListener("click", e => {
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n)); const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
}); });
@@ -1547,6 +1827,14 @@
const b = e.target.closest("button"); const b = e.target.closest("button");
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); } if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); 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("shadows").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { shadow = b.dataset.s; press("shadows", "s", shadow); applyAll(); saveState(); }
});
document.getElementById("tilts").addEventListener("click", e => { document.getElementById("tilts").addEventListener("click", e => {
const b = e.target.closest("button"); const b = e.target.closest("button");
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); } if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
@@ -1593,8 +1881,18 @@
syncBgButtons(); applyAll(); syncBgButtons(); applyAll();
} }
else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); } else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); }
else if (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); show(current); } 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 === "e") { document.querySelector('#editmodes button[data-e="' + (editMode === "an" ? "aus" : "an") + '"]').click(); } else if (k === "e") { document.querySelector('#editmodes button[data-e="' + (editMode === "an" ? "aus" : "an") + '"]').click(); }
else if (k === "r") {
const cyc = ["chamfer", "rund", "stapel", "aus"];
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
press("frames", "f", frameStyle);
press("shadows", "s", shadow); applyAll(); saveState();
}
else if (k === "s") { shadow = shadow === "an" ? "aus" : "an"; press("shadows", "s", shadow); applyAll(); saveState(); }
else if (k === "g") { tagOut = tagOut === "an" ? "aus" : "an"; press("tagouts", "o", tagOut); applyAll(); saveState(); } else if (k === "g") { tagOut = tagOut === "an" ? "aus" : "an"; press("tagouts", "o", tagOut); applyAll(); saveState(); }
else if (k === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); } else if (k === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); }
else if (k === "d") guard(downloadOne)(); else if (k === "d") guard(downloadOne)();
@@ -1616,6 +1914,8 @@
press("quality", "q", String(superSample)); press("quality", "q", String(superSample));
press("conts", "c", continuous); press("conts", "c", continuous);
press("tagouts", "o", tagOut); press("tagouts", "o", tagOut);
press("frames", "f", frameStyle);
press("shadows", "s", shadow);
press("views", "w", view); press("views", "w", view);
show(current); show(current);
if (restored) setStatus("Projektstand geladen", "ok"); if (restored) setStatus("Projektstand geladen", "ok");