diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index eff48d5..b726d07 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3522,3 +3522,63 @@ Undo deshalb über `execCommand('undo')`; das steht jetzt auch in `frontend/CLAUDE.md`. Dieselbe Lehre wie D25 (synthetische `TouchEvent`s) und D17-Nachtrag 4 (Bildschirmtastatur): Was die Umgebung stellt, stellt der Emulator nicht. + +## D54 — Frühere Stände: alle zehn Minuten, nur bei Änderung, die letzten zwanzig +Ein Sicherheitsnetz gegen Versehen — und der Anlass war ein echtes: Der +Tab-Fehler aus D53 konnte eine ganze Auswahl löschen und nahm dabei auch noch +das Rückgängig mit. Ohne Netz war der Text dann weg. + +**Aufbewahrt werden die letzten 20 je Dokument** (Nutzer-Entscheidung), also +rund 3½ Stunden bei gleichmäßigen Abständen und höchstens ~800 kB beim größten +Dokument. Erwogen und verworfen: **ausgedünnt** (alle der letzten Stunde, +stündlich für einen Tag, täglich für eine Woche — reicht weiter zurück, kostet +aber eine Ausdünn-Regel, die man beim Lesen erst verstehen muss) und +**lückenlos 24 Stunden** (bis zu 144 Stände, beim großen Plan ~5,8 MB und damit +über dem localStorage-Limit — es bräuchte doch wieder eine Notbremse). Der +Anspruch ist bewusst klein: Wer weiter zurück will, hat Git. + +**„Nur bei Änderung" heißt: gegen den letzten Stand, nicht gegen das Laden.** +Gibt es noch keinen Stand, wird gegen den Text beim Aktivieren des Dokuments +verglichen (`snapBase`). Ohne das legte der erste Takt nach dem Öffnen auch ein +**unverändertes** Dokument weg — beim Herumklicken durch mehrere Dokumente +sammelte sich so Ballast, den niemand erzeugt hat. Nachgemessen: Ohne Eingabe +entsteht der Schlüssel im localStorage gar nicht erst. + +**Die Dokumente sind wichtiger als ihre Stände.** Beide teilen sich den +localStorage. Läuft er über, wirft `persistSnaps()` deshalb so lange den +jeweils ältesten Stand weg, bis es passt, notfalls alle — statt eine Ausnahme +hochzureichen und damit womöglich das Speichern der **Dokumente** zu +gefährden. Ein gelöschtes Dokument nimmt seine Stände mit. + +**Zurückgeholt wird undo-fähig** (`replaceTextUndoable`, D53): ein Griff +daneben kostet ein Strg+Z, keine Rückfrage — dieselbe Haltung wie beim Falten +(D38-Nachtrag 2). Vorher wird der **aktuelle** Stand weggelegt, falls er noch +nicht drin ist; sonst wäre ausgerechnet er das Einzige, was das Zurückholen +verlöre. + +**Pad-Dokumente (D31) bleiben außen vor.** Ihr Textfeld ist schreibgeschützt — +ein alter Stand ließe sich dort gar nicht einsetzen, und Stände zu sammeln, +die niemand laden kann, wäre nur Ballast. Der Knopf ist dort verborgen +(`src.readOnly` bewacht Sammeln, Laden und Sichtbarkeit). + +**Platzierung: rechts neben dem Dokumenten-Wähler** (Nutzer-Vorgabe), beide in +einer Gruppe, die das `margin-right:auto` trägt. Der Positionsbezug des Menüs +ist der **Knopf**, nicht die Gruppe: An der Gruppe ausgerichtet (`right:0`) +begann es 190 px weiter links und lief aus dem Panel heraus — in der ersten +Fassung gebaut, im Bild sofort zu sehen und nachgemessen (linke Kante bei +−65 px). Jetzt `position:relative` an einer Hülle um den Knopf und `left:0` am +Menü: linke Kante bei 99 px, gleichauf mit dem Knopf, ganz im Panel. + +Jeder Eintrag nennt **Uhrzeit und Zeilenzahl** — die Zeilenzahl sagt auf einen +Blick, welchen der ähnlich benannten Stände man greift. Neueste zuoberst, +danach sucht man zuerst. + +**Nachgemessen** (Takt für die Prüfung auf 2 s verkürzt, danach zurückgestellt +und geprüft): ohne Eingabe kein Eintrag; nach einer Eingabe genau einer; ohne +weitere Eingabe kommt keiner dazu; nach 29 Änderungen sind es **20** (gedeckelt, +Zeitstempel aufsteigend, der älteste ist der kleinste Text). Menü: 20 Einträge, +neueste zuoberst. Klick auf einen älteren Eintrag lädt ihn (36 → 32 Zeilen), +schließt das Menü, das Diagramm zeichnet neu (30 Knoten) — und ein `undo` holt +den vorherigen Stand zeichengenau zurück. Der **Pad-Fall** ist durch die +`readOnly`-Wächter im Code abgedeckt, aber nicht live durchgespielt (er +bräuchte ein echtes Pad). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index a893d72..4922a12 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -71,6 +71,7 @@ - [^] #ed.fold.click: Fold and unfold in the diagram (S) - [^] #ed.fold.write: Folding writes the mark back into the text (S) %% one undo step - [^] #ed.fold.preset: One switch folds everything from size M downward (S) %% see D44 + - [x] #ed.snaps: Earlier states of a document, every ten minutes (S) %% only when something changed - [ ] #ed.files: Open and save .werkbaum files (S) + [^] #ed.fresh: Show what is new since your last visit (S) + [?] #ed.percolor: A pastel colour per person (S) @@ -502,6 +503,12 @@ smaller closed, L and above open. Size rather than depth — depth only says how finely somebody happened to write it down. +#ed.snaps + A safety net against mishaps: the text is put aside every ten minutes, but + only when it differs from the last one kept, and the last twenty are offered + beside the document picker. Loading one is a single undo step, so a wrong + pick costs nothing. + #ed.files Open a .werkbaum file and save one. Today the leading data format is the one thing that cannot leave the browser as a file, while the diagram can. diff --git a/frontend/index.html b/frontend/index.html index 6979134..21cf51f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -52,6 +52,9 @@ + + diff --git a/frontend/src/app.js b/frontend/src/app.js index 31efdc5..d1d3a76 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1744,6 +1744,10 @@ const I18N = { brandTooltip:"„Werkbaum“ bedeutet so viel wie ‚Werk-Baum‘ — der Baum des Projektstrukturplans (WBS).", editorTitle:"Struktur (Text)", diagramTitle:"Diagramm", docSwitchTooltip:"Dokument wählen oder verwalten", docMenuAria:"Dokumente", + /* Frühere Stände (D54) — alle 10 Minuten, nur bei Änderung. */ + snapTooltip:"Frühere Stände dieses Dokuments", snapMenuAria:"Frühere Stände", + snapNone:"Noch keine früheren Stände – gesichert wird alle 10 Minuten, sobald sich etwas geändert hat.", + snapLines:"{n} Zeilen", docNew:"Neues Dokument", docRename:"Umbenennen", docDelete:"Löschen", docNewName:"Unbenannt", docDeleteConfirm:"Dokument „{name}“ löschen?", @@ -1829,6 +1833,9 @@ const I18N = { brandTooltip:"“Werkbaum” means roughly ‘work tree’ — the tree of the work breakdown structure (WBS).", editorTitle:"Structure (text)", diagramTitle:"Diagram", docSwitchTooltip:"Choose or manage document", docMenuAria:"Documents", + snapTooltip:"Earlier states of this document", snapMenuAria:"Earlier states", + snapNone:"No earlier states yet – one is kept every 10 minutes, once something has changed.", + snapLines:"{n} lines", docNew:"New document", docRename:"Rename", docDelete:"Delete", docNewName:"Untitled", docDeleteConfirm:"Delete document “{name}”?", @@ -1914,6 +1921,9 @@ const I18N = { brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo’ — el árbol de la estructura de desglose del trabajo (EDT).", editorTitle:"Estructura (texto)", diagramTitle:"Diagrama", docSwitchTooltip:"Elegir o gestionar documento", docMenuAria:"Documentos", + snapTooltip:"Estados anteriores de este documento", snapMenuAria:"Estados anteriores", + snapNone:"Aún no hay estados anteriores: se guarda uno cada 10 minutos, cuando algo ha cambiado.", + snapLines:"{n} líneas", docNew:"Nuevo documento", docRename:"Renombrar", docDelete:"Eliminar", docNewName:"Sin título", docDeleteConfirm:"¿Eliminar el documento «{name}»?", @@ -1999,6 +2009,9 @@ const I18N = { brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — l’arbre de l’organigramme des tâches (WBS).", editorTitle:"Structure (texte)", diagramTitle:"Diagramme", docSwitchTooltip:"Choisir ou gérer le document", docMenuAria:"Documents", + snapTooltip:"États antérieurs de ce document", snapMenuAria:"États antérieurs", + snapNone:"Pas encore d’état antérieur : un état est conservé toutes les 10 minutes, dès que quelque chose a changé.", + snapLines:"{n} lignes", docNew:"Nouveau document", docRename:"Renommer", docDelete:"Supprimer", docNewName:"Sans titre", docDeleteConfirm:"Supprimer le document « {name} » ?", @@ -2084,6 +2097,9 @@ const I18N = { brandTooltip:"„Werkbaum” znaczy mniej więcej ‚drzewo pracy’ — drzewo struktury podziału pracy (WBS).", editorTitle:"Struktura (tekst)", diagramTitle:"Diagram", docSwitchTooltip:"Wybierz lub zarządzaj dokumentem", docMenuAria:"Dokumenty", + snapTooltip:"Wcześniejsze stany tego dokumentu", snapMenuAria:"Wcześniejsze stany", + snapNone:"Brak wcześniejszych stanów – zapisywany jest co 10 minut, gdy coś się zmieni.", + snapLines:"wierszy: {n}", docNew:"Nowy dokument", docRename:"Zmień nazwę", docDelete:"Usuń", docNewName:"Bez nazwy", docDeleteConfirm:"Usunąć dokument „{name}”?", @@ -2169,6 +2185,9 @@ const I18N = { brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).", editorTitle:"Структура (текст)", diagramTitle:"Диаграмма", docSwitchTooltip:"Выбрать документ или управлять им", docMenuAria:"Документы", + snapTooltip:"Прежние состояния этого документа", snapMenuAria:"Прежние состояния", + snapNone:"Прежних состояний пока нет — они сохраняются каждые 10 минут, если что-то изменилось.", + snapLines:"строк: {n}", docNew:"Новый документ", docRename:"Переименовать", docDelete:"Удалить", docNewName:"Без названия", docDeleteConfirm:"Удалить документ «{name}»?", @@ -2254,6 +2273,9 @@ const I18N = { brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।", editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख", docSwitchTooltip:"दस्तावेज़ चुनें या प्रबंधित करें", docMenuAria:"दस्तावेज़", + snapTooltip:"इस दस्तावेज़ की पिछली स्थितियाँ", snapMenuAria:"पिछली स्थितियाँ", + snapNone:"अभी कोई पिछली स्थिति नहीं — कुछ बदलने पर हर 10 मिनट में एक सहेजी जाती है।", + snapLines:"{n} पंक्तियाँ", docNew:"नया दस्तावेज़", docRename:"नाम बदलें", docDelete:"हटाएँ", docNewName:"बिना शीर्षक", docDeleteConfirm:"दस्तावेज़ „{name}“ हटाएँ?", @@ -2339,6 +2361,9 @@ const I18N = { riskTooltip:"高风险 – 工作量尚不明确。", editorTitle:"结构(文本)", diagramTitle:"图表", docSwitchTooltip:"选择或管理文档", docMenuAria:"文档", + snapTooltip:"此文档的早期状态", snapMenuAria:"早期状态", + snapNone:"暂无早期状态——内容有变动时,每 10 分钟保存一次。", + snapLines:"{n} 行", docNew:"新建文档", docRename:"重命名", docDelete:"删除", docNewName:"未命名", docDeleteConfirm:"删除文档“{name}”?", @@ -2424,6 +2449,9 @@ const I18N = { riskTooltip:"高リスク – 規模はまだ不明。", editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム", docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント", + snapTooltip:"このドキュメントの以前の状態", snapMenuAria:"以前の状態", + snapNone:"以前の状態はまだありません — 変更があれば 10 分ごとに保存されます。", + snapLines:"{n} 行", docNew:"新規ドキュメント", docRename:"名前を変更", docDelete:"削除", docNewName:"無題", docDeleteConfirm:"ドキュメント「{name}」を削除しますか?", @@ -2869,6 +2897,130 @@ function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); } Dokument sichern, dann das Ziel laden und neu rendern. */ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; } +/* ---------- Frühere Stände (Snapshots, D54) ---------- + Alle zehn Minuten wird der Text des aktiven Dokuments weggelegt — aber nur, + wenn er sich seit dem letzten Stand geändert hat. Aufgehoben werden die + letzten 20 je Dokument (rund 3½ Stunden Arbeit bei gleichmäßigen Abständen). + Es ist ein Sicherheitsnetz gegen Versehen, kein Versionsverwaltungssystem — + wer weiter zurück will, hat Git. */ +const LS_SNAPS = 'werkbaum-snaps'; +const SNAP_EVERY = 10 * 60 * 1000; +const SNAP_KEEP = 20; +let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */ +let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */ + +function loadSnaps(){ + try{ const o = JSON.parse(localStorage.getItem(LS_SNAPS) || '{}'); + snaps = o && typeof o === 'object' ? o : {}; } + catch(_){ snaps = {}; } +} +/* Der Platz im localStorage ist geteilt. Läuft er über, sollen die + **Dokumente** überleben, nicht ihre Stände — der Fehlerfall wirft deshalb + Stände weg, bis es passt, notfalls alle. */ +function persistSnaps(){ + for(;;){ + try{ localStorage.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; } + catch(_){ + if(!dropOldestSnap()){ try{ localStorage.removeItem(LS_SNAPS); }catch(_){} return false; } + } + } +} +function dropOldestSnap(){ + let id = null, t = Infinity; + for(const k in snaps){ + const l = snaps[k]; + if(l && l.length && l[0].t < t){ t = l[0].t; id = k; } + } + if(id === null) return false; + snaps[id].shift(); + if(!snaps[id].length) delete snaps[id]; + return true; +} +/* Legt den aktuellen Text weg, wenn er neu ist. Pad-Dokumente (D31) bleiben + außen vor: Ihr Text ist schreibgeschützt, ein alter Stand ließe sich dort + gar nicht wieder einsetzen — Stände zu sammeln, die niemand laden kann, + wäre nur Ballast. */ +function snapshotNow(){ + const d = activeDoc(); + if(!d || src.readOnly) return false; + const text = src.value; + const list = snaps[d.id] || (snaps[d.id] = []); + const letzter = list.length ? list[list.length-1].text : snapBase; + if(text === letzter) return false; + list.push({t: Date.now(), text}); + while(list.length > SNAP_KEEP) list.shift(); + snapBase = text; + persistSnaps(); + if(!snapMenu.hidden) renderSnapMenu(); + return true; +} +setInterval(snapshotNow, SNAP_EVERY); + +const snapBtn = document.getElementById('snapBtn'); +const snapMenu = document.getElementById('snapMenu'); + +function snapLabel(ms){ + const d = new Date(ms); + const heute = d.toDateString() === new Date().toDateString(); + try{ + return heute + ? d.toLocaleTimeString(lang, {hour: '2-digit', minute: '2-digit'}) + : d.toLocaleString(lang, {day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit'}); + }catch(_){ return d.toISOString().slice(0, 16).replace('T', ' '); } +} +function renderSnapMenu(){ + const d = activeDoc(); + const list = d ? (snaps[d.id] || []) : []; + snapMenu.innerHTML = ''; + if(!list.length){ + const p = document.createElement('div'); + p.className = 'snapempty'; + p.textContent = t('snapNone'); + snapMenu.appendChild(p); + return; + } + /* Neueste zuoberst — danach sucht man zuerst. */ + list.slice().reverse().forEach(s => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'snapitem'; + b.setAttribute('role', 'menuitem'); + b.innerHTML = ''; + b.firstChild.textContent = snapLabel(s.t); + b.lastChild.textContent = t('snapLines', {n: s.text.split('\n').length}); + b.addEventListener('click', e => { e.stopPropagation(); loadSnapshot(s); }); + snapMenu.appendChild(b); + }); +} +/* Zurückgeholt wird **undo-fähig** (D53): Ein Griff daneben kostet ein Strg+Z, + keine Rückfrage. Der aktuelle Stand wird vorher weggelegt, falls er noch + nicht drin ist — sonst wäre er das Einzige, was der Griff verlöre. */ +function loadSnapshot(s){ + if(src.readOnly) return; + snapshotNow(); + closeSnapMenu(); + if(!replaceTextUndoable(s.text)) render(); + snapBase = src.value; +} +function openSnapMenu(){ + renderSnapMenu(); + snapMenu.hidden = false; + snapBtn.setAttribute('aria-expanded', 'true'); +} +function closeSnapMenu(){ + snapMenu.hidden = true; + snapBtn.setAttribute('aria-expanded', 'false'); +} +function updateSnapBtn(){ snapBtn.hidden = src.readOnly; if(src.readOnly) closeSnapMenu(); } +snapBtn.addEventListener('click', e => { + e.stopPropagation(); + snapMenu.hidden ? openSnapMenu() : closeSnapMenu(); +}); +document.addEventListener('click', e => { + if(!snapMenu.hidden && !snapMenu.contains(e.target) && !snapBtn.contains(e.target)) closeSnapMenu(); +}); + /* ---------- „Was ist neu?" (D28) ---------- Verglichen wird gegen die zuletzt **gesehene** Fassung eines Dokuments von außen (mitgeliefert oder ?sourceUrl=), nicht gegen die letzte Auslieferung: @@ -2918,7 +3070,11 @@ function updateFreshBtn(){ } freshBtn.addEventListener('click', acknowledgeFresh); -function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; render(); updateDocName(); updateFreshBtn(); } +function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; + /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der + erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */ + snapBase = src.value; closeSnapMenu(); updateSnapBtn(); + render(); updateDocName(); updateFreshBtn(); } function switchDoc(id){ if(id === activeId) return; flushActive(); @@ -2977,6 +3133,7 @@ function deleteDoc(){ if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */ docs = docs.filter(x => x.id !== d.id); + if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(); } /* mit dem Dokument gehen seine Stände (D54) */ if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }]; activeId = docs[0].id; foldOverrides.clear(); @@ -2988,9 +3145,12 @@ function deleteDoc(){ function initDocs(){ restoring = true; loadDocs(); + loadSnaps(); const d = activeDoc(); src.value = d ? d.text : ''; + snapBase = src.value; restoring = false; + updateSnapBtn(); persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */ updateDocName(); render(); @@ -3268,6 +3428,7 @@ function updatePadLink(){ const d = activeDoc(); const isPad = !!(d && padSource && d.id === padSource.id); src.readOnly = isPad; + updateSnapBtn(); /* Pad-Dokumente sammeln keine Stände (D54) */ src.classList.toggle('readonly', isPad); if(srcWrap) srcWrap.classList.toggle('readonly', isPad); /* Streifen mit abtönen */ if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title'); diff --git a/frontend/src/style.css b/frontend/src/style.css index 1591e33..82ee21b 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -414,9 +414,22 @@ /* Dokument-Wähler: der Name des aktiven Dokuments in der Editor-Titelzeile ist zugleich der Auslöser eines Dropdowns zum Wechseln/Anlegen/Umbenennen/Löschen (localStorage-Dokumente, noch kein Backend). */ + /* Wähler + Snapshot-Knopf als eine Gruppe: Sie trägt das `margin-right:auto` + (schiebt die übrigen Knöpfe nach rechts) und ist der Positionsbezug des + Snapshot-Menüs — dadurch klappt es unter seinem Knopf auf, ohne dass JS + rechnen muss. Am Wähler selbst darf das auto-Margin nicht mehr stehen, + sonst schöbe es den Snapshot-Knopf von ihm weg. */ + .docgroup{ + display:inline-flex;align-items:center;gap:4px; + margin-right:auto;max-width:62%;min-width:0; + } + /* Bezug des Menüs ist der KNOPF, nicht die Gruppe: An der Gruppe ausgerichtet + (`right:0`) begänne es 190 px weiter links und liefe aus dem Panel heraus — + nachgemessen bei der ersten Fassung. */ + .snapwrap{position:relative;display:inline-flex;flex:0 0 auto} .doctrigger{ display:inline-flex;align-items:center;gap:5px;cursor:pointer; - margin-right:auto;max-width:60%;color:var(--muted); + min-width:0;color:var(--muted); padding:2px 6px;margin-left:-6px;border-radius:6px; text-transform:none;letter-spacing:0;font-family:'IBM Plex Mono',monospace; } @@ -450,6 +463,27 @@ padding:5px 7px;color:var(--ink);background:var(--card); font-family:inherit;font-size:.86rem;outline:none; } + /* Snapshot-Menü (D54): wie das Dokument-Menü, aber am rechten Rand seiner + Gruppe ausgerichtet — also unter dem Knopf. */ + .snapbtn{flex:0 0 auto} + .snapmenu{ + position:absolute;top:calc(100% + 6px);left:0;z-index:50;min-width:190px; + max-height:280px;overflow:auto; + display:flex;flex-direction:column;gap:2px; + background:var(--card);border:1px solid rgba(36,52,71,.15);border-radius:10px; + padding:6px;box-shadow:0 8px 24px rgba(36,52,71,.22); + text-transform:none;letter-spacing:0;font-family:'IBM Plex Sans',sans-serif; + } + .snapmenu[hidden]{display:none} + .snapitem{ + display:flex;align-items:baseline;gap:8px;width:100%; + border:0;background:transparent;cursor:pointer;text-align:left; + padding:6px 9px;border-radius:7px;color:var(--ink); + font-family:inherit;font-size:.84rem;white-space:nowrap; + } + .snapitem:hover{background:rgba(15,118,110,.08)} + .snapitem .snapsize{margin-left:auto;color:var(--muted);font-size:.74rem} + .snapempty{padding:7px 9px;color:var(--muted);font-size:.8rem} .docmenu-actions{ display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;padding-top:6px; border-top:1px solid rgba(36,52,71,.12);