diff --git a/post1.html b/post1.html index c149e33..148fa7a 100644 --- a/post1.html +++ b/post1.html @@ -129,6 +129,23 @@ padding: 9px 11px; margin-bottom: 9px; min-height: 56px; } .edit-info .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. */ + .edit-info .fields { + display: grid; grid-template-columns: 42px 1fr 20px 1fr; + gap: 6px 7px; align-items: center; margin-top: 8px; + } + .edit-info label { color: #6E7480; font-size: 11px; } + .edit-info 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; + } + .edit-info input::-webkit-outer-spin-button, + .edit-info input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + .edit-info input:focus { outline: none; border-color: var(--accent-yellow); } + .edit-info .hint { color: #6E7480; margin-top: 6px; } .std { margin-bottom: 22px; } .std summary { cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em; @@ -191,6 +208,17 @@ } .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. */ @@ -354,6 +382,35 @@ .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 @@ -738,6 +795,10 @@ --shape-accent-2: rgba(255,255,255,.14); --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; } @@ -756,6 +817,10 @@ --shape-accent-2: rgba(48,49,61,.16); --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); } @@ -803,6 +868,12 @@ +
+ + +
@@ -914,6 +985,10 @@

+
+ + +
@@ -999,7 +1074,10 @@ key: "chat", type: "feature", layouts: [{ id: "standard", label: "Standard" }], eyebrow: "Team-Chat", - head: 'Jedes Projekt bekommt
automatisch seinen Kanal.', + // "automatisch seinen Kanal." misst mit Marker 933 px bei 896 + // verfuegbaren und bricht deshalb in eine dritte Zeile. "von selbst" + // sagt dasselbe und bleibt mit 846 px in der Zeile. + head: 'Jedes Projekt bekommt
von selbst seinen Kanal.', 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, @@ -1080,13 +1158,36 @@ const STORE = "hivegrid.projekt." + PROJECT; const STATE_URL = "./state/" + PROJECT + ".json"; + const STATE_V = 2; + function currentState() { return { - v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, + v: STATE_V, variant, tilt, superSample, continuous, view, tagOut, frameStyle, current, layoutState, bgState, edits, }; } + /* ─── 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) >= 2) return false; + 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 }); + } + } + d.v = 2; + 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/.json. Nur die Datei lässt sich pushen, also ist sie die @@ -1132,10 +1233,12 @@ if (!d) { try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} } - if (!d || d.v !== 1) return false; + if (!d || !(d.v === 1 || d.v === 2)) 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. - if (!fromFile) setTimeout(() => pushState(currentState()), 0); + // Nach einer Angleichung ebenso, damit sie nicht bei jedem Laden erneut greift. + if (!fromFile || migrated) setTimeout(() => pushState(currentState()), 0); // 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) { @@ -1543,9 +1646,48 @@ sheet.appendChild(buildPage(j)); } el.appendChild(sheet); + // Über allem, damit die Linien auch auf dem Mockup lesbar bleiben. + el.insertAdjacentHTML("beforeend", + '
' + + '
' + + '
'); 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 @@ -1873,6 +2015,9 @@ } 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 @@ -2047,15 +2192,19 @@ 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(); @@ -2072,6 +2221,7 @@ syncEditPanel(); } + let nudgeTimer = null; function nudge(dx, dy) { if (!picked) return; const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {}); @@ -2079,6 +2229,17 @@ 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(); } @@ -2112,19 +2273,98 @@ 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; + } + function syncEditPanel() { const box = document.getElementById("edit-info"); - if (!picked) { box.innerHTML = 'Nichts ausgewählt'; return; } + if (!picked) { + box.dataset.for = ""; + box.innerHTML = 'Nichts ausgewählt'; + return; + } const std = STANDARD[picked.key] || {}; const e = (edits[picked.slide] || {})[picked.key] || {}; const base = stdSize(picked.key, picked.slide); - const val = e.size || base; - const unit = std.prop === "font-size" ? "px Schrift" : "px breit"; - box.innerHTML = - '' + (std.label || picked.key) + ' · Slide ' + (picked.slide + 1) + '
' + - (std.prop ? val + " " + unit + (e.size ? " (Standard " + base + ")" : "") + "
" : "") + - "Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px"; + const id = picked.slide + ":" + picked.key; + // Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier + // pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern + // wegreissen. + if (box.dataset.for !== id) { + box.dataset.for = id; + box.innerHTML = + '' + (std.label || picked.key) + ' · Slide ' + (picked.slide + 1) + + '
' + + (std.prop + ? '' + : '') + + '' + + '' + + '
' + + '
' + (std.prop ? 'Standard ' + base + ' px · ' : '') + + 'Obere linke Ecke, 0 / 0 ist die Mitte der Slide
'; + } + const put = (sel, v) => { + const el = box.querySelector(sel); + if (el && document.activeElement !== el) el.value = v; + }; + if (std.prop) put("#f-size", e.size || base); + const c = centreOf(picked.slide, picked.key); + if (c) { put("#f-dx", c.x); put("#f-dy", c.y); } } + + /* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und + der Fokus waere weg. */ + document.getElementById("edit-info").addEventListener("input", ev => { + const el = ev.target; + if (!picked || !el.matches("input")) 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); + } + // 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() { @@ -2157,6 +2397,107 @@ document.getElementById("sz-plus").addEventListener("click", () => resize(1)); document.getElementById("sz-minus").addEventListener("click", () => resize(-1)); document.getElementById("reset-one").addEventListener("click", resetOne); + /* ─── 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"); + } + document.getElementById("align-pos").addEventListener("click", () => alignAll("pos")); + document.getElementById("align-size").addEventListener("click", () => alignAll("size")); + + /* ─── 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"); + + async function snapExists() { + try { + const res = await fetch("./state/" + SNAP_PROJECT + ".json", { cache: "no-store" }); + return res.ok; + } catch (_) { return false; } + } + snapExists().then(ok => { snapLoad.disabled = !ok; }); + + snapSave.addEventListener("click", async () => { + snapSave.disabled = true; + try { + const res = await fetch("/state?project=" + SNAP_PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(currentState()), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + snapLoad.disabled = false; + setStatus("Stand gemerkt", "ok"); + } catch (err) { + setStatus("Merken fehlgeschlagen: " + err.message, "err"); + } finally { snapSave.disabled = false; } + }); + + snapLoad.addEventListener("click", async () => { + snapLoad.disabled = true; + try { + const res = await fetch("./state/" + SNAP_PROJECT + ".json", { cache: "no-store" }); + if (!res.ok) throw new Error("kein gemerkter Stand"); + const d = await res.json(); + // Erst den laufenden Timer entschaerfen, sonst schreibt er beim Entladen + // den alten Stand ueber den gerade zurueckgeholten. + clearTimeout(pushTimer); pushTimer = null; + 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); + try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {} + location.reload(); + } catch (err) { + snapLoad.disabled = false; + setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err"); + } + }); + document.getElementById("reset-all").addEventListener("click", resetAll); // Übersicht der Standards, direkt aus derselben Tabelle wie der Reset. @@ -2319,6 +2660,8 @@ const BG_CYCLE = ["formen", "formen-g", "nur-g", "waben", "punkte", "raster", "aus"]; document.addEventListener("keydown", e => { if (e.metaKey || e.ctrlKey || e.altKey) return; + // In einem Eingabefeld gehoeren die Tasten dem Feld, nicht den Kuerzeln. + if (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;