diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0262969..6c6c092 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2232,6 +2232,65 @@ per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts u wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im Druck entfernt — es ist Bedienelement, keine Aussage über den Plan. +**Nachtrag 2 — Umklappen im Diagramm schreibt jetzt in den Text.** Damit wird +die oben getroffene Festlegung („der Eingriff gilt für die Sitzung; er wird +nicht gespeichert — die dauerhafte Aussage steht im Text") **umgekehrt**, und +mit ihr die D34-Formulierung, die Marken bestimmten nur den Anfangszustand. +Anlass ist der Wunsch nach bidirektionaler Interaktion zwischen Diagramm und +Text; der Faltzustand ist dafür der richtige erste Fall: verlustfrei umkehrbar, +ohne inhaltliche Aussage, und die Notation dafür gibt es schon. + +Die Entscheidungen im Einzelnen, alle vom Nutzer getroffen: + +- **Bei jedem Umschalten**, nicht auf Ansage. Der direkteste Zusammenhang + zwischen Bild und Text; der Preis (unten) wurde bewusst in Kauf genommen. +- **`<` bleibt erhalten, wo es noch stimmt.** Nicht auf reine `>` + normalisieren: Handgeschriebene Marken sollen stehen bleiben. +- **Geschrieben wird, wo der Text beschreibbar ist** — auch bei `?sourceUrl=`, + wo es wie jede lokale Änderung bis zum nächsten Laden hält (D23). Nur beim + Pad bleibt es sitzungsweise, weil das Textfeld dort schreibgeschützt ist + (D31). +- **Falten gilt als Änderung.** Ein mitgeliefertes Dokument wird dadurch als + bearbeitet geführt, bekommt also keine neuen Fassungen mehr und zeigt + „Original wiederherstellen" (D27). Bewusst keine Sonderregel: Wer faltet, + ändert den Text, und der Text ist der Vertrag (D14). +- **Undo-fähig geschrieben.** Nachgemessen: `value =` **und** `setRangeText` + machen Strg+Z wirkungslos — wer tippt und dann faltet, käme an sein + Getipptes nicht mehr heran. Nur `document.execCommand('insertText')` erhält + die Historie; die API gilt als veraltet, funktioniert aber überall. Falten + ist damit ein eigener Undo-Schritt (geprüft: Strg+Z nimmt die Faltung + zurück, ein zweites das Getippte). + +**Das Verfahren: minimal patchen, dann nachrechnen.** Die Ableitung Text → +Zustand ist **nicht umkehrbar** — mehrere Markensätze ergeben denselben +Zustand, und `<` erzeugt Faltungen an Knoten, die gar keine Marke tragen +(oben). Statt sie zu invertieren, wird der Kandidat schlicht **befragt**: +`>` an der einen Zeile setzen oder entfernen, `initialCollapsed()` darauf +laufen lassen, mit dem Sollzustand vergleichen. Stimmt es, bleibt alles andere +unangetastet — das ist der `<`-Erhalt. Stimmt es nicht, werden alle Marken neu +gesetzt und erneut geprüft. Stimmt auch das nicht — etwa weil ein `!!!` seinen +Knoten immer wieder hervorholt und „eingeklappt" dort gar nicht ausdrückbar +ist —, wird **nicht geschrieben** und die Sitzungs-Überlagerung bleibt stehen. +So kann nie ein Text entstehen, der etwas anderes sagt als das Bild. +`initialCollapsed()` bleibt dadurch die einzige Stelle, die die Bedeutung der +Marken kennt. + +**Zwei Fallen, beide gemessen statt vermutet.** Erstens: Auf kleinem +Bildschirm ist der Editor `display:none`, wenn das Diagramm vorn ist — und +`execCommand` tut dann **nichts**, es liefert `false`, obwohl `activeElement` +das Textfeld meldet. Für die Dauer des synchronen Schreibens wird der Editor +deshalb absolut positioniert aus dem Bild geschoben sichtbar geschaltet +(Klasse `writing-fold`, derselbe Griff wie `exporting` im Grafikexport); +gezeichnet wird davon nichts. Zweitens: `execCommand` braucht den Fokus im +Textfeld, und der zöge auf dem Telefon die Bildschirmtastatur hoch — dagegen +`inputmode="none"` wie beim Sprung (D25), das der erste echte Tipp ins Feld +wieder aufhebt. Anschließend geht der Fokus an den Knoten zurück. + +Schreibmarke und Scrollstand werden gesichert und zurückgesetzt; verschoben +wird nur, was **hinter** der Änderung lag. Gelingt das Schreiben, werden die +Sitzungs-Überlagerungen geleert — sonst könnten sie den Text maskieren, der +jetzt die Wahrheit ist. + **Nachtrag — der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem günstigsten Pfad.** Oben steht, ein eingeklappter Zweig dürfe Pfadknoten verbergen, „die Inversion an den sichtbaren Knoten bleibt richtig". Das war zu diff --git a/docs/SPEC.md b/docs/SPEC.md index 52293e0..ec03029 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -34,9 +34,10 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit): - `>` heißt: der Teilbaum dieses Knotens ist beim Öffnen **eingeklappt**. - `<` innerhalb eines eingeklappten Bereichs holt den **eigenen Teilbaum** gezielt wieder hervor (Mechanik: §9). -- Die Marken bestimmen nur den **Anfangszustand** — im Diagramm wird danach - unabhängig vom Text gefaltet (§9). Sie sagen nichts über Fortschritt (§4) - oder Notwendigkeit (§3) und ändern weder Kosten noch Warnungen. +- Die Marken beschreiben den **Faltzustand des Dokuments**: Beim Öffnen stellen + sie ihn her, und Umklappen im Diagramm schreibt sie zurück (§9) — Text und + Bild sagen dasselbe. Sie sagen nichts über Fortschritt (§4) oder + Notwendigkeit (§3) und ändern weder Kosten noch Warnungen. **Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig; sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11). @@ -665,9 +666,22 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom). hervorgeholten Teilbaums bleibt respektiert. - Auch ein **`!!!`-markierter Knoten** (§1) holt sich auf diese Weise hervor — ein Zeigefinger auf etwas Unsichtbares zeigte ins Leere. -- Der interaktive Eingriff gilt **je Knoten** (Identität = Label-Pfad, wie bei - „Was ist neu?“) und **für die Sitzung**; er wird nicht gespeichert — die - dauerhafte Aussage steht im Text (D34). Ein Dokumentwechsel setzt zurück. +- **Umklappen im Diagramm schreibt die Marke in den Text zurück.** Damit ist + der Text auch für die Faltung die eine Quelle der Wahrheit: Was du siehst, + steht geschrieben, und ein Neuladen stellt es wieder her. Die Änderung ist + eine gewöhnliche Textänderung — sie lässt sich mit **Rückgängig** zurücknehmen + und macht ein mitgeliefertes Dokument zu einem bearbeiteten (§9, D27). + Geschrieben wird **minimal**: Nur die Zeile des umgeklappten Knotens wird + angefasst, solange die übrigen Marken den Zustand noch richtig beschreiben — + ein von Hand gesetztes `<` bleibt also stehen. Trifft es nicht mehr zu, werden + alle Marken neu gesetzt und das `<` dabei aufgelöst. +- Wo der Text **nicht beschreibbar** ist — bei einem Pad-Dokument (§9) —, gilt + der Eingriff **je Knoten** (Identität = Label-Pfad, wie bei „Was ist neu?“) + und **nur für die Sitzung**; ein Dokumentwechsel setzt ihn zurück. Dasselbe + gilt für einen Zustand, der sich in Marken gar nicht ausdrücken lässt (etwa + weil eine Fokusmarke `!!!` ihren Knoten immer wieder hervorholt): Dann wird + lieber nichts geschrieben, als einen Text zu hinterlassen, der etwas anderes + sagt als das Bild. - Faltung ist **reine Ansicht**: Warnungen aus eingeklappten Teilbäumen werden weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet unverändert über den ganzen Baum. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9feb662..03e07de 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -311,7 +311,23 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Pfad-Mitgliedschaft (ein per `:#…` gezogenes Ziel unter einem `+`-Knoten): Dann ist er der einzige sichtbare Griff darauf und zählt als `cheap`. `extraCls()` muss dieselbe Eingeklappt-Bedingung bilden wie `itemHtml` - (nur mit sichtbaren Kindern). Umklappen: Klick aufs + (nur mit sichtbaren Kindern). + **Umklappen schreibt in den TEXT zurück** (`writeFoldToText`, D38-Nachtrag 2): + Die Ableitung Text → Zustand ist nicht umkehrbar (mehrere Markensätze ergeben + denselben Zustand; `<` faltet Knoten ohne eigene Marke), deshalb **minimal + patchen und nachrechnen** — `setFoldMark()` auf die eine Zeile, dann + `initialCollapsed()` auf dem Kandidaten gegen den Soll vergleichen; erst bei + Abweichung alle Marken neu setzen; passt auch das nicht (z. B. `!!!` holt den + Knoten immer hervor), gar nicht schreiben. Nie eine eigene Umkehrfunktion + bauen — `initialCollapsed()` bleibt die einzige Stelle, die die Marken + versteht. Geschrieben wird mit `execCommand('insertText')`: `value =` und + `setRangeText` zerstören die Undo-Historie (gemessen). Zwei Fallen: Ein + Textfeld mit `display:none`-Vorfahr nimmt **kein** `execCommand` an (Klasse + `writing-fold` schaltet es kurz sichtbar), und der nötige Fokus zieht auf + Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad, + D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die + Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren + sie den Text. Umklappen: Klick aufs `.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im ``) oder ←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line` wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg diff --git a/frontend/src/app.js b/frontend/src/app.js index 36d0e7b..3942849 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,5 +1,5 @@ import './style.css'; -import { parse } from './parser.js'; +import { parse, setFoldMark } from './parser.js'; import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; @@ -856,15 +856,136 @@ function nodeFromEvent(e){ return el && out.contains(el) ? el : null; } -/* Faltung umklappen (SPEC §9, D38). Der Eingriff überlagert den Anfangszustand - aus dem Text; nach dem Neubau bekommt derselbe Knoten den Fokus zurück, +/* ---------- Faltung zurück in den Text (D38-Nachtrag 2) ---------- + Umklappen im Diagramm ändert den Notationstext. Der Text ist damit auch für + die Faltung die eine Quelle der Wahrheit (D14) — die Sitzungs-Überlagerung + `foldOverrides` bleibt nur für Dokumente, in die nicht geschrieben werden + kann (Pad, schreibgeschützt nach D31). */ + +/* Der Sollzustand als Menge von Label-Pfad-Schlüsseln: heutiger Anfangszustand + aus dem Text, überlagert von den Eingriffen — dieselbe Rechnung wie in + render(), damit beide nie auseinanderlaufen. */ +function desiredFoldKeys(roots){ + const initFold = initialCollapsed(roots, true); + const keys = nodeKeys(roots); + const want = new Set(); + keys.forEach((key, n) => { + const ov = foldOverrides.get(key); + if((ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0) want.add(key); + }); + return want; +} + +/* Erzeugt ein Text ZWEIMAL denselben Faltzustand? Statt die Ableitung + Text -> Zustand umzukehren (sie ist nicht eindeutig: mehrere Markensätze + ergeben denselben Zustand), wird der Kandidat schlicht nachgerechnet. + `initialCollapsed()` bleibt so die einzige Wahrheit über die Bedeutung der + Marken — die Umkehrung muss sie nicht kennen, nur befragen. */ +function foldStateMatches(txt, want){ + const r = parse(txt).roots; + const keys = nodeKeys(r); + const got = new Set(); + initialCollapsed(r, true).forEach(n => { if(n.children.length > 0) got.add(keys.get(n)); }); + if(got.size !== want.size) return false; + for(const k of want) if(!got.has(k)) return false; + return true; +} + +/* Schreibt den Text undo-fähig. `execCommand` ist die EINZIGE Art, ein Textfeld + programmatisch zu ändern, ohne dessen Rückgängig-Historie zu zerstören + (nachgemessen: `value =` und `setRangeText` machen Strg+Z wirkungslos). Sie + ersetzt die Auswahl — geändert wird deshalb nur das wirklich abweichende + Stück zwischen gemeinsamem Anfang und Ende. */ +function replaceTextUndoable(neu){ + const alt = src.value; + if(alt === neu) return true; + let s = 0; + while(s < alt.length && s < neu.length && alt[s] === neu[s]) s++; + let e = 0; + while(e < alt.length - s && e < neu.length - s && + alt[alt.length-1-e] === neu[neu.length-1-e]) e++; + const von = s, bis = alt.length - e, ein = neu.slice(s, neu.length - e); + const cs = src.selectionStart, ce = src.selectionEnd, top = src.scrollTop; + /* Der Fokus muss ins Textfeld — sonst greift execCommand nicht. Auf dem + Telefon zöge das die Bildschirmtastatur hoch; `inputmode="none"` hält sie + unten (derselbe Griff wie beim Sprung, D25), und der erste echte Tipp ins + Feld hebt die Sperre wieder auf. */ + keyboardOnJump(true); + src.focus({preventScroll: true}); + src.setSelectionRange(von, bis); + let ok = false; + try{ ok = document.execCommand('insertText', false, ein); }catch(_){} + if(!ok){ + /* Rückfall: Der richtige Zustand geht vor der Undo-Historie (D14). */ + src.value = neu; + src.dispatchEvent(new Event('input', {bubbles: true})); + } + /* Schreibmarke und Scrollstand zurück — sonst risse das Falten den Nutzer + aus seiner Textstelle. Nur was HINTER der Änderung lag, verschiebt sich. */ + const d = ein.length - (bis - von); + const fix = p => p >= bis ? p + d : Math.min(p, von + ein.length); + src.setSelectionRange(fix(cs), fix(ce)); + src.scrollTop = top; + return true; +} + +/* Auf kleinem Bildschirm ist der Editor `display:none`, wenn das Diagramm vorn + ist — und dann tut `execCommand` NICHTS (gemessen: liefert `false`, obwohl + `activeElement` das Feld meldet). Für die Dauer des synchronen Schreibens + wird er deshalb aus dem Fluss heraus sichtbar geschaltet; gezeichnet wird + davon nichts, wie bei `exporting` im Grafikexport. */ +function withEditorWritable(fn){ + const versteckt = editorPanel.offsetParent === null; + if(versteckt) document.body.classList.add('writing-fold'); + try{ return fn(); } + finally{ if(versteckt) document.body.classList.remove('writing-fold'); } +} + +/* Schreibt den Faltzustand in den Text. Liefert false, wenn nicht geschrieben + werden konnte — dann bleibt die Sitzungs-Überlagerung stehen. */ +function writeFoldToText(line, collapsed){ + if(src.readOnly) return false; /* Pad-Dokument (D31) */ + const roots = parse(src.value).roots; + if(!roots.length) return false; + const want = desiredFoldKeys(roots); + const zeilen = src.value.split('\n'); + + /* 1) Minimal: nur die umgeklappte Zeile anfassen. So bleiben von Hand + gesetzte `<` stehen, solange sie den Zustand noch richtig beschreiben. */ + const klein = zeilen.slice(); + klein[line-1] = setFoldMark(klein[line-1], collapsed ? '>' : null); + let neu = klein.join('\n'); + + if(!foldStateMatches(neu, want)){ + /* 2) Vollständig: alle Marken neu setzen. Nötig, wenn ein `<` den Zustand + nicht mehr trifft — dann wird es hier aufgelöst. */ + const keys = nodeKeys(roots); + const ganz = zeilen.slice(); + keys.forEach((key, n) => { + ganz[n.line-1] = setFoldMark(ganz[n.line-1], want.has(key) ? '>' : null); + }); + neu = ganz.join('\n'); + /* 3) Nicht ausdrückbar — etwa weil ein `!!!` im Zweig seinen Knoten immer + wieder hervorholt (SPEC §9). Dann NICHT schreiben: ein Text, der + etwas anderes sagt als das Bild, wäre schlimmer als keine Marke. */ + if(!foldStateMatches(neu, want)) return false; + } + return withEditorWritable(() => replaceTextUndoable(neu)); +} + +/* Faltung umklappen (SPEC §9, D38). Gelingt das Zurückschreiben, ist der Text + der Zustand — die Überlagerungen werden dann geleert, damit sie ihn nicht + maskieren können. Nach dem Neubau bekommt derselbe Knoten den Fokus zurück, sonst risse die Tastaturbedienung ab (das alte Element ist weg). */ function toggleFold(el){ - const st = foldByLine.get(+el.dataset.line); + const line = +el.dataset.line; + const st = foldByLine.get(line); if(!st || !st.canFold) return; foldOverrides.set(st.key, !st.collapsed); - render(); - const again = out.querySelector('.node[data-line="' + el.dataset.line + '"]'); + /* Das Schreiben löst per `input`-Ereignis schon ein render() aus. */ + if(writeFoldToText(line, !st.collapsed)) foldOverrides.clear(); + else render(); + const again = out.querySelector('.node[data-line="' + line + '"]'); if(again) again.focus({preventScroll: true}); } diff --git a/frontend/src/parser.js b/frontend/src/parser.js index 95ad23a..87c5850 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -20,6 +20,18 @@ export const STATUS_BY_CODE = { '!': {code:'!', key:'highrisk', name:'High Risk – Aufwand unklar'} }; +/* Setzt (`'>'`) oder entfernt (`null`) die Faltmarke einer Zeile — die Umkehrung + der Extraktion aus §1, gebraucht fürs Zurückschreiben aus dem Diagramm + (D38-Nachtrag). Angefasst wird NUR die Marke samt ihrem Leerraum: Einrückung, + Zerlegungszeichen, Statusbox und Label bleiben zeichengenau stehen, auch bei + ungewöhnlicher Spaltung wie `- [x] X`. Wurzelzeilen (ohne Zeichen) bekommen + die Marke am Zeilenanfang, wie SPEC §1 es verlangt. */ +export function setFoldMark(line, mark){ + const m = line.match(/^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)((?:[><](?=[ \t])[ \t]*)?)/); + const head = m[1], alt = m[2]; + return head + (mark ? mark + ' ' : '') + line.slice(head.length + alt.length); +} + /* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und neutrale Knoten zählen nicht. */ diff --git a/frontend/src/style.css b/frontend/src/style.css index 17bd737..5f56a60 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -399,6 +399,18 @@ .dltrigger{display:none} .legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */ .panetoggle{display:none} /* ebenso: dort gibt es nur einen Bereich */ + /* Zurückschreiben der Faltung (D38-Nachtrag 2): Für die Dauer des SYNCHRONEN + Schreibens braucht das Textfeld einen Kasten — ohne ihn tut `execCommand` + nichts (gemessen: liefert `false`, obwohl `activeElement` das Feld meldet). + Auf kleinem Bildschirm ist der Editor `display:none`, wenn das Diagramm + vorn ist. Absolut aus dem Fluss genommen und aus dem Bild geschoben, damit + der sichtbare Bereich seine Größe behält; gezeichnet wird davon nichts — + derselbe Griff wie `exporting` im Grafikexport. */ + body.writing-fold .panel.editor{ + display:flex!important; + position:absolute!important;left:-10000px!important;top:0!important; + width:360px!important;height:400px!important; + } /* 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). */ diff --git a/frontend/tests/fold.test.js b/frontend/tests/fold.test.js index ab6dea3..e4cfc9a 100644 --- a/frontend/tests/fold.test.js +++ b/frontend/tests/fold.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parse } from '../src/parser.js'; +import { parse, setFoldMark } from '../src/parser.js'; import { initialCollapsed, computeCheapSet } from '../src/model.js'; import { renderTreeHtml } from '../src/render.js'; @@ -43,6 +43,44 @@ describe('Parser — Faltmarke zwischen Zeichen und Statusbox', () => { }); }); +/* Umkehrung der Marken-Extraktion fürs Zurückschreiben aus dem Diagramm + (SPEC §1, D38-Nachtrag 2): Angefasst wird nur die Marke samt Leerraum. */ +describe('setFoldMark — Faltmarke setzen und entfernen', () => { + it('setzt `>` hinter das Zerlegungszeichen', () => { + expect(setFoldMark(' - [x] Concept (M)', '>')).toBe(' - > [x] Concept (M)'); + }); + + it('entfernt eine vorhandene Marke', () => { + expect(setFoldMark(' - > [x] Concept (M)', null)).toBe(' - [x] Concept (M)'); + }); + + it('ersetzt `<` durch `>`', () => { + expect(setFoldMark(' - < [ ] B1', '>')).toBe(' - > [ ] B1'); + }); + + it('setzt die Marke bei Wurzelzeilen an den Zeilenanfang', () => { + expect(setFoldMark('[~] Wurzel', '>')).toBe('> [~] Wurzel'); + expect(setFoldMark('> [~] Wurzel', null)).toBe('[~] Wurzel'); + }); + + it('lässt ungewöhnliche Spaltung stehen', () => { + expect(setFoldMark(' - [x] X', '>')).toBe(' - > [x] X'); + expect(setFoldMark('\t= [ ] Cloud', '>')).toBe('\t= > [ ] Cloud'); + }); + + it('fasst ein `>` im Label nicht an (Leerraum-Regel)', () => { + /* `- >Achtung` ist ein Label, keine Marke — die neue Marke kommt davor. */ + expect(setFoldMark(' - >Achtung', '>')).toBe(' - > >Achtung'); + expect(setFoldMark(' - >Achtung', null)).toBe(' - >Achtung'); + }); + + it('ist verlustfrei umkehrbar', () => { + for(const l of ['[ ] W', ' - [x] A (M) @anna', ' | [?] B', ' + [-] C']){ + expect(setFoldMark(setFoldMark(l, '>'), null)).toBe(l); + } + }); +}); + describe('initialCollapsed — `>` klappt ein, `<` wandert die Faltung hinunter', () => { it('klappt `>`-Knoten ein', () => { expect(collapsedLabels(`[ ] W\n - > [ ] A\n - [ ] A1\n - [ ] B`))