fix(editor): „nur ansehen" gilt jetzt dem Dokument, nicht nur dem Textfeld — auch das Diagramm faltet dort nicht mehr

Nutzer-Einwand, folgerichtig aus dem Vorgänger-Commit: Wenn Falten eine
Änderung ist, muss der Nur-Ansehen-Modus auch das Diagramm stillstellen —
im haltenden Fenster natürlich weiter nicht.

Es war schlimmer als eine fehlende Sperre: `readonly` hielt gar nichts auf.
`replaceTextUndoable` fällt auf `src.value = neu` zurück, wenn `execCommand`
scheitert, und eine Zuweisung an `value` schreibt auch in ein
schreibgeschütztes Feld. Nachgemessen: Ein Klick auf das Falt-Zeichen im
Nur-Ansehen-Fenster setzte `- > Ast A (M)` in Textfeld UND Speicher — der
Modus war nur eine Beschriftung.

Gesperrt wird jetzt an der Schreibstelle: eine Zeile in
`replaceTextUndoable` weist jeden programmatischen Schreibzugriff ab
(Falten, Durchschalter, „aus Taiga übernehmen", früherer Stand,
ID-Kurzform). Dazu ruhen die Gesten selbst, damit nichts still ins Leere
geht — toggleFold und der Durchschalter kehren früh um, Falt-Zeichen und
Knopf treten zurück und nehmen keinen Klick an (body.viewonly), und im
Knoten-Fenster entfallen die Aktionen, die in die Zeile schreiben.
„Nach Taiga schreiben" bleibt: Es fasst den Plan nicht an. Ansicht bleibt
Ansicht — Zoom, Modus, Pfad, Export, Sprünge unverändert bedienbar.

Nachgemessen mit zwei Fenstern: Chip, Tastatur und Durchschalter lassen im
Nur-Ansehen-Fenster Text und Speicher unangetastet; im haltenden Fenster
faltet dieselbe Geste weiter; verlässt es das Dokument, wird das andere von
selbst wieder beschreibbar und faltbar. SPEC §9, D94-Nachtrag 3 und
CHANGELOG nachgezogen; 666 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-02 20:28:20 +02:00
co-authored by Claude Opus 5
parent 2a58951138
commit 2d78baf407
5 changed files with 84 additions and 8 deletions
+1
View File
@@ -25,6 +25,7 @@ reverse.
- The other-window warning now names the document and says whether it is open in the app or in the browser — and stays silent when nothing collides - The other-window warning now names the document and says whether it is open in the app or in the browser — and stays silent when nothing collides
- Fix: in the "open in another window" dialog the list of other documents squeezed its buttons until the names ran into each other — picking one is a dropdown now, one line tall however many documents you have - Fix: in the "open in another window" dialog the list of other documents squeezed its buttons until the names ran into each other — picking one is a dropdown now, one line tall however many documents you have
- The two-window warning says "whichever change is saved last wins" now instead of "the last keystroke" — folding a branch in the diagram writes to the text too, so it joins the same race - The two-window warning says "whichever change is saved last wins" now instead of "the last keystroke" — folding a branch in the diagram writes to the text too, so it joins the same race
- "View only" really is view only now: while the document is held by another window, the diagram's fold marks stay put too — folding writes to the text and used to slip past the read-only text area; the window holding the document folds as before
- An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built - An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built
## 2026-08-28 ## 2026-08-28
+42
View File
@@ -9040,6 +9040,48 @@ Sauber wäre, auch dort im Hintergrund zu warten (`waitInBackground`) und
die Warnung zu räumen, sobald die Sperre ankommt — dieselbe Mechanik wie die Warnung zu räumen, sobald die Sperre ankommt — dieselbe Mechanik wie
bei „nur ansehen", nur ohne den Schreibschutz. bei „nur ansehen", nur ohne den Schreibschutz.
**Nachtrag 3 — „nur ansehen" galt nur dem Textfeld; jetzt gilt es dem
Dokument (2026-09-02).** Folgerichtiger Nutzer-Einwand aus Nachtrag 2:
Wenn Falten eine Änderung ist, muss der Nur-Ansehen-Modus auch das
Diagramm stillstellen — „in dem ersten Fenster soll das natürlich weiter
funktionieren."
**Es war schlimmer als eine fehlende Sperre: `readonly` hielt gar nichts
auf.** `replaceTextUndoable` fällt auf `src.value = neu` zurück, wenn
`execCommand` scheitert (D14: „der richtige Zustand geht vor der
Rückgängig-Historie") — und eine Zuweisung an `value` schreibt auch in ein
schreibgeschütztes Feld. Nachgemessen im Nur-Ansehen-Fenster: Ein Klick
auf das Falt-Zeichen setzte `- > Ast A (M)` in Textfeld **und** Speicher.
Der Modus war also nur eine Beschriftung.
**Gesperrt wird jetzt an den Schreibstellen, nicht am Feld.** Eine Zeile
in `replaceTextUndoable` weist im Nur-Ansehen-Modus jeden programmatischen
Schreibzugriff ab — das deckt Falten, Falt-Durchschalter, „aus Taiga
übernehmen", das Laden eines früheren Stands und die ID-Kurzform in einem
Griff. Dazu ruhen die **Gesten** selbst, damit nichts still ins Leere
geht: `toggleFold` und der Durchschalter kehren früh um, die Falt-Zeichen
und der Knopf treten zurück und nehmen keinen Klick an (`body.viewonly`),
und im Knoten-Fenster entfallen die Aktionen, die in die Zeile schreiben —
Story/Task anlegen, Ticket verknüpfen, „übernehmen". **„Nach Taiga
schreiben" bleibt**: Es fasst den Plan nicht an.
**Alles, was nur die Ansicht ändert, bleibt bedienbar** — Zoom, Modus,
Pfad, der Faltzustand aus dem Text, Export, Sprünge. Der Modus sagt
„dieses Dokument nicht ändern", nicht „nichts tun".
**Nachgemessen** mit zwei Fenstern: Im Nur-Ansehen-Fenster lassen Chip,
Tastatur (←) und Durchschalter Text und Speicher unangetastet (5 Knoten
bleiben 5, `pointer-events: none`, Deckkraft 0,45); im haltenden Fenster
faltet dieselbe Geste unverändert und schreibt `> Ast A`; verlässt das
haltende Fenster das Dokument, wird das andere von selbst wieder
beschreibbar **und** faltbar (`> Ast B` geschrieben). Vorher, zur
Gegenprobe: derselbe Klick schrieb im Nur-Ansehen-Modus in den Speicher.
**Benannt, nicht behoben:** Menüwege, die in den Text schreiben (früheren
Stand laden, „Original wiederherstellen"), sind im Nur-Ansehen-Modus zwar
wirkungslos — die Sperre greift —, ihre Einträge stehen aber weiterhin da.
Sie auszugrauen ist der nächste kleine Schritt, wenn es jemanden stört.
**Offen bleibt die Handarbeit** (RFC §10): installierte PWA neben einem Tab, **Offen bleibt die Handarbeit** (RFC §10): installierte PWA neben einem Tab,
Firefox, Safari, ein Browser ohne Locks-API. Werkzeuggrenzen wie gehabt — Firefox, Safari, ein Browser ohne Locks-API. Werkzeuggrenzen wie gehabt —
die Browser-Fläche war verborgen, getippt wurde per `value` + `input` die Browser-Fläche war verborgen, getippt wurde per `value` + `input`
+4 -2
View File
@@ -963,8 +963,10 @@ Verlustfall hält jedes Fenster, solange ein nicht-`live:`-Dokument vorn
ist, eine **Sperre** (Web Locks API) auf es — atomar, und sie fällt von ist, eine **Sperre** (Web Locks API) auf es — atomar, und sie fällt von
selbst, wenn das Fenster schließt. Bekommt das zweite Fenster sie nicht, selbst, wenn das Fenster schließt. Bekommt das zweite Fenster sie nicht,
zeigt es einen Dialog mit drei Auswegen: anderes Dokument öffnen, hier nur zeigt es einen Dialog mit drei Auswegen: anderes Dokument öffnen, hier nur
ansehen (Textfeld schreibgeschützt, wird von selbst beschreibbar, sobald ansehen (**das ganze Dokument ist dann schreibgeschützt**: Textfeld wie
das andere Fenster loslässt) oder trotzdem hier bearbeiten — dann gewinnt Diagramm — auch die Faltmarken lassen sich dort nicht umklappen, denn das
schriebe in den Text; es wird von selbst wieder beschreibbar, sobald das
andere Fenster loslässt) oder trotzdem hier bearbeiten — dann gewinnt
die **zuletzt gespeicherte Änderung**, und die Warnung nennt das Dokument. die **zuletzt gespeicherte Änderung**, und die Warnung nennt das Dokument.
Änderung heißt dabei nicht nur Tippen: Auch das Falten im Diagramm schreibt Änderung heißt dabei nicht nur Tippen: Auch das Falten im Diagramm schreibt
in den Text (§9, `>`/`<`) und nimmt an diesem Wettlauf teil. Das andere in den Text (§9, `>`/`<`) und nimmt an diesem Wettlauf teil. Das andere
+29 -5
View File
@@ -133,6 +133,17 @@ let lockTargetId = null; /* id der letzten Anfrage (syncDocLock vergleicht)
let lockRelease = null; /* löst die gehaltene Sperre (Callback-Promise) */ let lockRelease = null; /* löst die gehaltene Sperre (Callback-Promise) */
let lockGen = 0; /* Generation: ungültigt ausstehende Anfragen */ let lockGen = 0; /* Generation: ungültigt ausstehende Anfragen */
let viewOnly = false; /* Ausweg 2: „hier nur ansehen" (§6.5) */ let viewOnly = false; /* Ausweg 2: „hier nur ansehen" (§6.5) */
/* Nur ansehen" gilt dem DOKUMENT, nicht nur dem Textfeld: Auch das Falten im
Diagramm schreibt in den Text (D38-Nachtrag 2), und ein `readonly` am
Textfeld hält das nicht auf `replaceTextUndoable` fällt auf `value =`
zurück, wenn `execCommand` scheitert, und das schreibt auch in ein
schreibgeschütztes Feld (nachgemessen: die Faltmarke landete im Speicher).
Die Klasse macht den Zustand für das CSS sichtbar; die Sperre selbst sitzt
an den Schreibstellen (D94-Nachtrag 3). */
function setViewOnly(on){
viewOnly = on;
document.body.classList.toggle('viewonly', on);
}
let docGoneElsewhere = false; /* aktives Dokument anderswo gelöscht (§6.5) */ let docGoneElsewhere = false; /* aktives Dokument anderswo gelöscht (§6.5) */
let lockDialogEl = null; /* Dialog über dem Editor (§6.5) */ let lockDialogEl = null; /* Dialog über dem Editor (§6.5) */
function clearTabConflict(){ function clearTabConflict(){
@@ -160,7 +171,7 @@ function acquireDocLock(id){
releaseDocLock(); releaseDocLock();
lockTargetId = id; lockTargetId = id;
docGoneElsewhere = false; docGoneElsewhere = false;
viewOnly = false; setViewOnly(false);
src.readOnly = false; /* im Zweifel beschreibbar; der Dialog setzt es zurück */ src.readOnly = false; /* im Zweifel beschreibbar; der Dialog setzt es zurück */
closeLockDialog(); closeLockDialog();
clearTabConflict(); clearTabConflict();
@@ -179,7 +190,7 @@ function acquireDocLock(id){
function lockArrived(id){ function lockArrived(id){
if(id !== activeId) return; /* inzwischen weggewechselt */ if(id !== activeId) return; /* inzwischen weggewechselt */
lockHeldId = id; lockHeldId = id;
viewOnly = false; setViewOnly(false);
src.readOnly = false; src.readOnly = false;
closeLockDialog(); closeLockDialog();
clearTabConflict(); clearTabConflict();
@@ -242,7 +253,7 @@ function enterLockDialog(id){
trotzdem.textContent = t('docLockEditAnyway'); trotzdem.textContent = t('docLockEditAnyway');
ansehen.addEventListener('click', () => { ansehen.addEventListener('click', () => {
closeLockDialog(); closeLockDialog();
viewOnly = true; setViewOnly(true);
src.readOnly = true; src.readOnly = true;
updateDocName(); updateDocName();
waitInBackground(lockTargetId); /* wird von selbst beschreibbar (§6.5) */ waitInBackground(lockTargetId); /* wird von selbst beschreibbar (§6.5) */
@@ -250,7 +261,7 @@ function enterLockDialog(id){
trotzdem.addEventListener('click', () => { trotzdem.addEventListener('click', () => {
closeLockDialog(); closeLockDialog();
src.readOnly = false; src.readOnly = false;
viewOnly = false; setViewOnly(false);
lockGen++; /* ausstehende Warteanfragen ungültig (§6.5) */ lockGen++; /* ausstehende Warteanfragen ungültig (§6.5) */
const jetzt = activeDoc(); const jetzt = activeDoc();
tabWarning = {type: 'tabConflict', name: jetzt ? jetzt.name : '', tabWarning = {type: 'tabConflict', name: jetzt ? jetzt.name : '',
@@ -1506,6 +1517,11 @@ function foldStateMatches(txt, want){
ersetzt die Auswahl geändert wird deshalb nur das wirklich abweichende ersetzt die Auswahl geändert wird deshalb nur das wirklich abweichende
Stück zwischen gemeinsamem Anfang und Ende. */ Stück zwischen gemeinsamem Anfang und Ende. */
function replaceTextUndoable(neu){ function replaceTextUndoable(neu){
/* Nur ansehen" (§6.5): kein programmatischer Schreibzugriff nicht das
Falten, nicht der Falt-Durchschalter, nicht aus Taiga übernehmen",
nicht das Laden eines früheren Stands. Der Rückfall unten schriebe sonst
am `readonly` vorbei. */
if(viewOnly) return false;
const alt = src.value; const alt = src.value;
/* Nichts zu schreiben heißt: kein `input`-Ereignis, also auch kein render(). /* Nichts zu schreiben heißt: kein `input`-Ereignis, also auch kein render().
Deshalb false der Aufrufer zeichnet dann selbst neu. Sonst bliebe das Deshalb false der Aufrufer zeichnet dann selbst neu. Sonst bliebe das
@@ -1645,6 +1661,7 @@ function refocusNode(line, before){
suppressTipFocus = false; suppressTipFocus = false;
} }
function toggleFold(el){ function toggleFold(el){
if(viewOnly) return; /* keine Faltmarken im Nur-Ansehen-Modus (§6.5) */
const line = +el.dataset.line; const line = +el.dataset.line;
const st = foldByLine.get(line); const st = foldByLine.get(line);
if(!st || !st.canFold) return; if(!st || !st.canFold) return;
@@ -2021,6 +2038,9 @@ function appendTaigaActions(el){
} }
return; return;
} }
/* Anlegen und Verknüpfen schreiben die Ref in die Zeile im
Nur-Ansehen-Modus gibt es sie deshalb gar nicht erst (§6.5). */
if(viewOnly) return;
const mk = (label, action) => { const mk = (label, action) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
@@ -2170,7 +2190,10 @@ function paintDiff(box, line, st, key, slug, ref, lineNo, act){
act.appendChild(mkBtn('→ ' + t('taigaTicketPush'), act.appendChild(mkBtn('→ ' + t('taigaTicketPush'),
() => pushStatus(key, slug, ref, ziel, st.data.version))); () => pushStatus(key, slug, ref, ziel, st.data.version)));
} }
act.appendChild(mkBtn('← ' + t('taigaTicketPull'), /* übernehmen" schreibt die Statusbox in den Text im Nur-Ansehen-Modus
entfällt der Knopf; nach Taiga schreiben" bleibt, es fasst den Plan
nicht an (§6.5). */
if(!viewOnly) act.appendChild(mkBtn('← ' + t('taigaTicketPull'),
() => pullStatus(lineNo, ticket.code))); () => pullStatus(lineNo, ticket.code)));
if(!ziel) line('tk-line', t('taigaTicketNoMap')); if(!ziel) line('tk-line', t('taigaTicketNoMap'));
} }
@@ -3294,6 +3317,7 @@ showc.addEventListener('click', () => { setDiscarded(!discardedShown()); render(
Baum und setzt sie zurück, sobald jemand von Hand umklappt (D44-Linie). */ Baum und setzt sie zurück, sobald jemand von Hand umklappt (D44-Linie). */
const foldBtn = document.getElementById('foldBtn'); const foldBtn = document.getElementById('foldBtn');
foldBtn.addEventListener('click', () => { foldBtn.addEventListener('click', () => {
if(viewOnly) return; /* er schreibt den ganzen Baum in den Text (§6.5) */
const mode = FOLD_CYCLE[foldCycleNext]; const mode = FOLD_CYCLE[foldCycleNext];
foldCycleApplied = foldCycleNext; foldCycleApplied = foldCycleNext;
foldCycleNext = (foldCycleNext + 1) % FOLD_CYCLE.length; foldCycleNext = (foldCycleNext + 1) % FOLD_CYCLE.length;
+8 -1
View File
@@ -1751,8 +1751,15 @@
} }
.lockdlg-edit{border-color:var(--warn)!important;color:var(--warn)!important} .lockdlg-edit{border-color:var(--warn)!important;color:var(--warn)!important}
@media print{ .lockdlg-overlay{ display:none; } } @media print{ .lockdlg-overlay{ display:none; } }
/* „Nur ansehen" (§6.5 Ausweg 2): das Textfeld trägt seinen Zustand. */ /* „Nur ansehen" (§6.5 Ausweg 2): das Textfeld trägt seinen Zustand — und das
Diagramm mit, denn das Falten schreibt ebenfalls in den Text (D38-Nachtrag 2).
Die Falt-Zeichen und der Falt-Durchschalter treten zurück und nehmen keinen
Klick mehr an; die Sperre selbst sitzt im JS, das hier ist ihr sichtbarer
Teil (D94-Nachtrag 3). Alles Übrige — Zoom, Modus, Pfad, Export — bleibt
bedienbar: Es ändert die Ansicht, nicht den Plan. */
#src[readonly]{background:rgba(107,122,140,.08);color:var(--muted);cursor:default} #src[readonly]{background:rgba(107,122,140,.08);color:var(--muted);cursor:default}
body.viewonly #out .fold{pointer-events:none;opacity:.45}
body.viewonly #foldBtn{pointer-events:none;opacity:.45}
/* ---------- Taiga-Anlege- und Anmeldedialog (D91) ---------- /* ---------- Taiga-Anlege- und Anmeldedialog (D91) ----------
Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */ Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */