wo jeder Auftrag steht.', body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.", 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", @@ -716,7 +872,8 @@ head: 'Wenn-dann
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.", 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", @@ -725,7 +882,9 @@ head: 'Jedes Projekt bekommt
automatisch seinen Kanal.', 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", - 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", @@ -754,18 +913,35 @@ "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" }, - "mock": { label: "Bild", size: null }, - "hex-frame": { label: "Wabe", size: null }, + "mock": { label: "Bild", 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; + 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 picked = null; // { slide, key } let editMode = "aus"; 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 ──────────────────────────────────────────────────────── Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts, @@ -777,7 +953,7 @@ function saveState() { try { 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, })); markSaved(); @@ -805,6 +981,8 @@ continuous = d.continuous || continuous; view = d.view || view; tagOut = d.tagOut || tagOut; + frameStyle = d.frameStyle || frameStyle; + shadow = d.shadow || shadow; current = Number.isInteger(d.current) ? d.current : 0; return true; } @@ -834,14 +1012,40 @@ /* 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. */ + 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() { const wrap = document.getElementById("strip-wrap"); - const total = 1080 * SLIDES.length; - const avail = Math.max(320, wrap.parentElement.clientWidth); - const k = view === "streifen" ? Math.min(avail / total, 0.45) : 0.45; + const k = zoom || autoFit(); 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"; + 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) => { @@ -1030,7 +1234,14 @@ 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 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 = + '
' + - (std.prop ? val + " " + unit + (e.size ? " (Standard " + std.size + ")" : "") + "
" : "") + + (std.prop ? val + " " + unit + (e.size ? " (Standard " + base + ")" : "") + "
" : "") + "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("editmodes").addEventListener("click", e => { const b = e.target.closest("button"); @@ -1485,8 +1719,8 @@ syncEditPanel(); } }); - document.getElementById("sz-plus").addEventListener("click", () => resize(2)); - document.getElementById("sz-minus").addEventListener("click", () => resize(-2)); + document.getElementById("sz-plus").addEventListener("click", () => resize(1)); + document.getElementById("sz-minus").addEventListener("click", () => resize(-1)); document.getElementById("reset-one").addEventListener("click", resetOne); document.getElementById("reset-all").addEventListener("click", resetAll); @@ -1520,10 +1754,56 @@ }); 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); 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); + /* 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 => { const b = e.target.closest("button"); if (b) show(Number(b.dataset.n)); }); @@ -1547,6 +1827,14 @@ const b = e.target.closest("button"); 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 => { const b = e.target.closest("button"); if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); } @@ -1593,8 +1881,18 @@ syncBgButtons(); applyAll(); } 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 === "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 === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); } else if (k === "d") guard(downloadOne)(); @@ -1616,6 +1914,8 @@ press("quality", "q", String(superSample)); press("conts", "c", continuous); press("tagouts", "o", tagOut); + press("frames", "f", frameStyle); + press("shadows", "s", shadow); press("views", "w", view); show(current); if (restored) setStatus("Projektstand geladen", "ok");