Files
dogfather-universe/workspace/assets/js/notizen.js
T
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00

922 lines
35 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
DER NOTIZBLOCK (26.09.2026)
=====================================================================
Filipe: „ich will dass du das auch wie ein notizblock erstellst.
ich will dass es übelst geil und einzigartig ist."
---------------------------------------------------------------------
DREI ENTSCHEIDUNGEN, AUS DENEN DER REST FOLGT
1. ES GIBT KEINEN SPEICHERN-KNOPF. Wer einen Block aufschlägt und
lostippt, drückt hinterher nicht auf „sichern"; er klappt ihn zu.
Gesichert wird 800 ms nach dem letzten Tastendruck. Geht das
nicht (Funkloch, Server weg), bleibt der Text im Browser liegen
und wird nachgereicht — und der Fuß sagt ehrlich, dass er noch
nicht drüben ist.
2. DAS BLATT IST EIN SCHREIBFELD, KEIN CONTENTEDITABLE. Die
Auszeichnung (Haken, Überschriften, Striche) zeichnet eine
Deckschicht darüber. Warum, steht im Kopf von notizen.css — kurz:
Ein `contenteditable` bricht beim Einfügen aus fremden
Programmen, beim Rückgängigmachen und an jeder Handytastatur,
die über eine Zwischenzeile schreibt.
3. DIE TASTATUR FÜHRT DIE LISTE FORT, NICHT EINE LEISTE. Eine
Werkzeugleiste über einem Notizblock ist eine Leiste zu viel:
Man schreibt `- ` und drückt Enter, und die nächste Zeile fängt
wieder mit `- ` an. Genauso bei `[ ] `. Das ist alles, was es an
„Formatierung" gibt, und es fühlt sich wie Papier an, weil man
nie den Stift wechselt.
---------------------------------------------------------------------
WAS HIER NICHT PASSIERT
Es gibt keine Rolle, die mehr sieht. Kein „Leitung"-Zweig, kein
Umschalter, keine fremde Liste. Wer das hier liest und eine
Sichtbarkeitsregel sucht: Es gibt keine, und das ist der Sinn der
Sache (siehe notiz-tabellen.js).
===================================================================== */
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (was, klasse, text) => {
const e = document.createElement(was);
if (klasse) e.className = klasse;
if (text !== undefined) e.textContent = text;
return e;
};
/* ---------- Zustand ---------------------------------------------- */
let notizen = [];
let farben = [];
let aktuell = null; // die offene Notiz (Objekt aus `notizen`)
let suchwort = '';
let papierkorbZahl = 0;
let textMax = 20000;
let korbTage = 30;
const feld = $('feld');
const deck = $('deck');
/* ---------- Fetch-Hülle ------------------------------------------- */
async function hole(pfad, einstellungen) {
const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen });
if (a.status === 401) { location.href = 'index.html'; throw new Error('weg'); }
return a;
}
async function fehlerText(a, ersatz) {
try {
const j = await a.json();
return typeof j?.fehler === 'string' && j.fehler ? j.fehler : ersatz;
} catch { return ersatz; }
}
function melde(text) {
const f = $('fehler');
f.textContent = text || '';
f.hidden = !text;
}
/* „vor 3 Minuten" statt eines Zeitstempels: Bei einer Notiz ist die
Frage „wann war ich zuletzt dran?", nicht „welcher Tag war das?".
Das genaue Datum bleibt als Titel am Element. */
function herZeit(iso) {
if (!iso) return '';
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms)) return '';
const min = Math.floor(ms / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const std = Math.floor(min / 60);
if (std < 24) return `vor ${std} Std.`;
const tage = Math.floor(std / 24);
if (tage === 1) return 'gestern';
if (tage < 30) return `vor ${tage} Tagen`;
return new Date(iso).toLocaleDateString('de-DE',
{ day: '2-digit', month: '2-digit', year: 'numeric' });
}
const genau = (iso) => (iso
? new Date(iso).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'short' })
: '');
/* =====================================================================
DIE DECKSCHICHT
Sie zeichnet denselben Text noch einmal — mit Haken, Überschriften
und Strichen. Die eiserne Regel dabei steht im Kopf von
notizen.css: KEINE Auszeichnung darf die Breite eines Zeichens
ändern. Deshalb gibt es hier kein `<b>`, kein `<h1>` und keine
Zeichen, die hinzukommen oder wegfallen — jedes Zeichen des Textes
landet genau einmal in der Ausgabe.
GEBAUT WIRD MIT createElement UND textContent, nicht mit einer
Zeichenkette und innerHTML. Ein Notizblock ist der Ort, an dem
jemand irgendwann `<img onerror=...>` hineinschreibt — nicht aus
Bosheit, sondern weil er sich etwas notiert. Mit textContent ist
das ein Text und bleibt einer.
===================================================================== */
/** Wandelt eine Zeile in Knoten um und hängt sie an `ziel`. */
function zeileZeichnen(ziel, zeile, treffer) {
/* Die Zeile wird von links nach rechts abgearbeitet; `rest` ist,
was noch nicht gezeichnet wurde. */
let rest = zeile;
let erledigt = false;
const stueck = (text, klasse, aufbau) => {
if (!text) return null;
const s = el('span', klasse, text);
if (aufbau) aufbau(s);
ziel.append(s);
return s;
};
/* 1. Der Zeilenanfang: Überschrift, Aufzählung, Zitat, Haken. */
let m;
if ((m = /^(\s*)(#{1,3}\s)/.exec(rest))) {
stueck(m[1], null);
stueck(m[2], 'n-marke');
const inhalt = rest.slice(m[0].length);
textMitLinks(ziel, inhalt, treffer, 'n-h');
return;
}
if ((m = /^(\s*)(\[[ xX]\])(\s?)/.exec(rest))) {
stueck(m[1], null);
const an = m[2][1] !== ' ';
erledigt = an;
stueck(m[2], 'n-hk', (s) => {
s.dataset.an = an ? 'ja' : 'nein';
s.setAttribute('role', 'checkbox');
s.setAttribute('aria-checked', an ? 'true' : 'false');
s.title = an ? 'Erledigt – klicken zum Aufheben' : 'Als erledigt abhaken';
});
stueck(m[3], null);
textMitLinks(ziel, rest.slice(m[0].length), treffer, erledigt ? 'n-erledigt' : null);
return;
}
if ((m = /^(\s*)([-*]\s)/.exec(rest))) {
stueck(m[1], null);
stueck(m[2], 'n-punkt');
textMitLinks(ziel, rest.slice(m[0].length), treffer, null);
return;
}
if ((m = /^(\s*)(>\s?)/.exec(rest))) {
stueck(m[1], null);
stueck(m[2], 'n-zitat');
textMitLinks(ziel, rest.slice(m[0].length), treffer, 'n-zitat');
return;
}
textMitLinks(ziel, rest, treffer, null);
}
/* Adressen werden anklickbar. Das Muster ist absichtlich eng: Es
trifft nur, was mit http(s):// anfängt. Ein „www." ohne Schema
wäre eine Vermutung, und eine Notiz ist kein Ort für Vermutungen. */
const ADRESSE = /https?:\/\/[^\s<>"')\]]+/g;
function textMitLinks(ziel, text, treffer, klasse) {
const stuecke = [];
let zuletzt = 0;
ADRESSE.lastIndex = 0;
let m;
while ((m = ADRESSE.exec(text))) {
if (m.index > zuletzt) stuecke.push({ t: text.slice(zuletzt, m.index), link: false });
stuecke.push({ t: m[0], link: true });
zuletzt = m.index + m[0].length;
}
if (zuletzt < text.length) stuecke.push({ t: text.slice(zuletzt), link: false });
if (!stuecke.length) stuecke.push({ t: '', link: false });
for (const s of stuecke) {
const klassen = [klasse, s.link ? 'n-link' : null].filter(Boolean).join(' ');
if (s.link) {
const a = el('a', klassen, s.t);
a.href = s.t;
a.target = '_blank';
/* `noopener` ist Pflicht: Ohne ihn bekommt die geöffnete Seite
über `window.opener` Zugriff auf diese hier. */
a.rel = 'noopener noreferrer';
ziel.append(a);
} else if (treffer) {
trefferZeichnen(ziel, s.t, treffer, klassen);
} else if (klassen) {
ziel.append(el('span', klassen, s.t));
} else {
ziel.append(document.createTextNode(s.t));
}
}
}
/** Hebt die Fundstellen der Suche hervor — mit Fläche, nicht mit Breite. */
function trefferZeichnen(ziel, text, wort, klassen) {
const klein = text.toLowerCase();
let von = 0, ab = 0;
while ((von = klein.indexOf(wort, ab)) !== -1) {
if (von > ab) ziel.append(el('span', klassen, text.slice(ab, von)));
const t = el('span', (klassen ? klassen + ' ' : '') + 'n-treffer',
text.slice(von, von + wort.length));
ziel.append(t);
ab = von + wort.length;
}
if (ab < text.length) ziel.append(el('span', klassen, text.slice(ab)));
}
function deckZeichnen() {
const text = feld.value;
deck.textContent = '';
const treffer = suchwort.length >= 2 ? suchwort : '';
const zeilen = text.split('\n');
zeilen.forEach((z, i) => {
zeileZeichnen(deck, z, treffer);
/* Der Zeilenumbruch gehört zum Text und muss mitgezeichnet
werden -- sonst stünde alles in einer Zeile. Beim LETZTEN
Element darf er nicht dazu: Ein Schreibfeld hat nach der
letzten Zeile keinen Umbruch, und die Deckschicht wäre eine
Zeile zu hoch. */
if (i < zeilen.length - 1) deck.append(document.createTextNode('\n'));
});
/* Ein Schreibfeld, das auf einen Umbruch endet, zeigt darunter
noch eine leere Zeile. Ein <div> tut das nicht -- deshalb eine
Leerzeile anhängen, damit beide gleich hoch bleiben. */
if (text.endsWith('\n')) deck.append(document.createTextNode(' '));
hoeheAnpassen();
titelSetzen();
hakenStandSetzen();
}
/** Das Schreibfeld wächst mit dem Text — gescrollt wird der Rahmen.
*
* So bleibt die Deckschicht ohne eigenes Scrollen deckungsgleich:
* Beide stehen im selben scrollenden Kasten. Ein eigener
* Bildlauf im Schreibfeld würde außerdem eine Bildlaufleiste
* einblenden, die Zeilen schmaler macht — und ab da bräche der
* Text in beiden Schichten an verschiedenen Stellen um. */
function hoeheAnpassen() {
feld.style.height = 'auto';
const hoch = Math.max(feld.scrollHeight, 320);
feld.style.height = hoch + 'px';
deck.style.minHeight = hoch + 'px';
}
function titelSetzen() {
const t = (feld.value.split('\n').find((z) => z.trim().length) || '')
.replace(/^\s*#{1,3}\s*/, '')
.replace(/^\s*[-*]\s+/, '')
.replace(/^\s*\[[ xX]\]\s*/, '')
.trim();
const k = $('blatt-titel');
k.textContent = t || 'Ohne Titel';
k.dataset.leer = t ? 'nein' : 'ja';
k.title = t || '';
}
/** Wie viele Haken gesetzt sind. Erscheint nur, wenn es welche gibt. */
function hakenZaehlen(text) {
const alle = String(text || '').match(/^\s*\[[ xX]\]/gm) || [];
const an = alle.filter((s) => !/\[ \]/.test(s)).length;
return { alle: alle.length, an };
}
function hakenStandSetzen() {
const s = $('haken-stand');
const z = hakenZaehlen(feld.value);
if (!z.alle) { s.hidden = true; return; }
s.hidden = false;
s.textContent = `${z.an} von ${z.alle} erledigt`;
s.dataset.fertig = z.an === z.alle ? 'ja' : 'nein';
}
/* =====================================================================
SICHERN
Gespeichert wird 800 ms nach dem letzten Tastendruck. Klappt das
nicht, bleibt der Text hier liegen und wird beim nächsten Versuch
nachgereicht — und der Fuß sagt es. Ein „konnte nicht speichern"
ohne Rettung wäre die schlechteste aller Meldungen: Sie erzählt
von einem Verlust, den man gerade nicht verhindern kann.
DER ZWISCHENSPEICHER IM BROWSER ist die zweite Sicherung, für den
Fall, dass jemand den Reiter schließt, bevor es durchgeht. Beim
nächsten Öffnen wird er angeboten — nicht heimlich eingespielt:
Wer zwischendurch auf einem anderen Gerät geschrieben hat, würde
sonst seine neuere Fassung verlieren.
===================================================================== */
const LAGER = 'dogi-notizen-offen';
let sicherUhr = null;
let laeuft = false;
let nochmal = false;
function standSetzen(stand, wort) {
const s = $('stand');
s.dataset.stand = stand;
s.textContent = wort;
}
function lagerSchreiben(id, text) {
try {
localStorage.setItem(LAGER, JSON.stringify({ id, text, am: Date.now() }));
} catch { /* Voller Speicher ist kein Grund, das Tippen zu stoppen. */ }
}
function lagerLeeren() {
try { localStorage.removeItem(LAGER); } catch { /* egal */ }
}
function lagerLesen() {
try { return JSON.parse(localStorage.getItem(LAGER) || 'null'); } catch { return null; }
}
function aendernGeplant() {
if (!aktuell) return;
lagerSchreiben(aktuell.id, feld.value);
standSetzen('sichert', 'sichert …');
clearTimeout(sicherUhr);
sicherUhr = setTimeout(sichern, 800);
}
async function sichern() {
if (!aktuell) return;
if (laeuft) { nochmal = true; return; }
laeuft = true;
const id = aktuell.id;
const text = feld.value;
try {
const a = await hole(`/workspace/api/notizblock/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!a.ok) {
standSetzen('offen', await fehlerText(a, 'noch nicht gesichert'));
return;
}
const j = await a.json();
const n = notizen.find((x) => x.id === id);
if (n) { n.text = text; n.geaendert = j.geaendert; n.titel = titelAus(text); }
lagerLeeren();
standSetzen('gesichert', 'gesichert');
wannSetzen();
stapelZeichnen();
} catch {
/* Kein Netz. Der Text liegt im Browser; beim nächsten Tippen
oder beim nächsten Öffnen geht es weiter. */
standSetzen('offen', 'noch nicht gesichert – bleibt hier, bis es geht');
} finally {
laeuft = false;
if (nochmal) { nochmal = false; setTimeout(sichern, 200); }
}
}
const titelAus = (text) => (String(text || '').split('\n').find((z) => z.trim().length) || '')
.replace(/^\s*#{1,3}\s*/, '').replace(/^\s*[-*]\s+/, '')
.replace(/^\s*\[[ xX]\]\s*/, '').trim().slice(0, 120);
function wannSetzen() {
const w = $('wann');
if (!aktuell) { w.textContent = ''; return; }
w.textContent = herZeit(aktuell.geaendert);
w.title = genau(aktuell.geaendert);
}
/* =====================================================================
DER STAPEL
===================================================================== */
function passt(n) {
if (suchwort.length < 2) return true;
return String(n.text || '').toLowerCase().includes(suchwort);
}
function stapelZeichnen() {
const liste = $('liste');
liste.textContent = '';
const sichtbar = notizen.filter(passt);
$('anzahl').textContent = suchwort.length >= 2
? `${sichtbar.length}/${notizen.length}` : String(notizen.length);
for (const n of sichtbar) {
const li = el('li');
const b = el('button', 'n-zettel');
b.type = 'button';
b.style.setProperty('--n-tinte', farbHex(n.farbe));
if (aktuell && n.id === aktuell.id) b.setAttribute('aria-current', 'true');
b.append(el('span', 'n-zettel__titel', n.titel || 'Ohne Titel'));
const vor = zweiteZeile(n.text);
if (vor) b.append(el('span', 'n-zettel__vor', vor));
const fuss = el('span', 'n-zettel__fuss');
if (n.angeheftet) {
const nadel = el('span', 'n-zettel__nadel', '📌');
nadel.title = 'Angeheftet';
fuss.append(nadel);
}
fuss.append(el('span', null, herZeit(n.geaendert)));
const h = hakenZaehlen(n.text);
if (h.alle) {
const hz = el('span', 'n-zettel__haken', `${h.an}/${h.alle}`);
hz.dataset.fertig = h.an === h.alle ? 'ja' : 'nein';
hz.title = `${h.an} von ${h.alle} erledigt`;
fuss.append(hz);
}
b.append(fuss);
b.addEventListener('click', () => oeffne(n.id));
li.append(b);
liste.append(li);
}
$('leer').hidden = notizen.length > 0;
$('korb-auf').hidden = papierkorbZahl === 0;
$('korb-zahl').textContent = String(papierkorbZahl);
}
function zweiteZeile(text) {
const zeilen = String(text || '').split('\n').filter((z) => z.trim().length);
return (zeilen[1] || '').replace(/^\s*\[[ xX]\]\s*/, '').replace(/^\s*[-*#>]+\s*/, '')
.trim().slice(0, 90);
}
const farbHex = (name) => (farben.find((f) => f.name === name) || farben[0] || {}).hex || '#9cde90';
/* =====================================================================
EIN BLATT AUFSCHLAGEN
===================================================================== */
function oeffne(id, { fokus = true } = {}) {
const n = notizen.find((x) => x.id === id);
if (!n) return;
/* Was im alten Blatt noch offen ist, geht zuerst raus. Sonst
landet der letzte Satz beim nächsten Blatt -- oder nirgends. */
if (aktuell && aktuell.id !== id) { clearTimeout(sicherUhr); sichern(); }
aktuell = n;
$('korb').hidden = true;
$('blatt').hidden = false;
$('blatt').style.setProperty('--n-tinte', farbHex(n.farbe));
feld.value = n.text || '';
feld.maxLength = textMax;
deckZeichnen();
wannSetzen();
standSetzen('gesichert', 'gesichert');
$('anheften').setAttribute('aria-pressed', n.angeheftet ? 'true' : 'false');
farbenZeichnen();
stapelZeichnen();
if (fokus) {
feld.focus();
/* Ans Ende springen, nicht an den Anfang: Wer ein Blatt
aufschlägt, schreibt weiter -- er liest es nicht von vorn. */
feld.setSelectionRange(feld.value.length, feld.value.length);
}
zurueckKnopf(true);
}
function schliessen() {
clearTimeout(sicherUhr);
if (aktuell) sichern();
aktuell = null;
$('blatt').hidden = true;
$('korb').hidden = true;
stapelZeichnen();
zurueckKnopf(false);
}
/* Der Zurück-Knopf der Kopfleiste schließt auf dem Handy das Blatt.
Auf dem Rechner wird er nicht gebraucht -- dort stehen Stapel und
Blatt nebeneinander, es gibt nichts zu schließen. */
function zurueckKnopf(an) {
const k = $('zurueck');
if (!k) return;
const schmal = window.matchMedia('(max-width: 860px)').matches;
k.hidden = !(an && schmal);
}
function farbenZeichnen() {
const kasten = $('farben');
kasten.textContent = '';
for (const f of farben) {
const b = el('button', 'n-farbe');
b.type = 'button';
b.style.setProperty('--f', f.hex);
b.title = f.wort;
b.setAttribute('aria-label', `Papierfarbe ${f.wort}`);
b.setAttribute('aria-pressed', aktuell && aktuell.farbe === f.name ? 'true' : 'false');
b.addEventListener('click', () => farbeSetzen(f.name));
kasten.append(b);
}
}
async function farbeSetzen(name) {
if (!aktuell) return;
const alt = aktuell.farbe;
aktuell.farbe = name;
$('blatt').style.setProperty('--n-tinte', farbHex(name));
farbenZeichnen();
stapelZeichnen();
try {
const a = await hole(`/workspace/api/notizblock/${aktuell.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ farbe: name }),
});
if (!a.ok) throw new Error('nein');
} catch {
/* Zurückdrehen. Eine Farbe, die nach dem Neuladen wieder
umspringt, ist schlimmer als eine, die gar nicht erst
umspringt. */
aktuell.farbe = alt;
$('blatt').style.setProperty('--n-tinte', farbHex(alt));
farbenZeichnen(); stapelZeichnen();
melde('Die Farbe ließ sich nicht sichern. Versuch es gleich noch einmal.');
}
}
/* =====================================================================
DIE TASTATUR — sie führt die Liste fort
===================================================================== */
/** Setzt Text und Cursor in einem Zug, so dass der Browser es
* rückgängig machen kann.
*
* `document.execCommand('insertText')` ist offiziell veraltet und
* trotzdem das Einzige, was den Rückgängig-Stapel des Schreibfelds
* füllt. Wer stattdessen `value` setzt, nimmt dem Benutzer Strg+Z
* weg -- in einem Notizblock die unfreundlichste aller Ersparnisse.
* Geht es nicht, wird `value` gesetzt; dann fehlt nur das
* Rückgängigmachen für diesen einen Schritt. */
function einfuegen(text) {
feld.focus();
let ging = false;
try { ging = document.execCommand('insertText', false, text); } catch { ging = false; }
if (!ging) {
const a = feld.selectionStart, b = feld.selectionEnd;
feld.value = feld.value.slice(0, a) + text + feld.value.slice(b);
feld.setSelectionRange(a + text.length, a + text.length);
}
deckZeichnen();
aendernGeplant();
}
function zeileUm(pos) {
const v = feld.value;
const von = v.lastIndexOf('\n', pos - 1) + 1;
let bis = v.indexOf('\n', pos);
if (bis === -1) bis = v.length;
return { von, bis, text: v.slice(von, bis) };
}
/** Haken auf der Zeile des Cursors umschalten — oder einen setzen. */
function hakenUmschalten() {
const z = zeileUm(feld.selectionStart);
const m = /^(\s*)\[([ xX])\]/.exec(z.text);
if (m) {
const neu = m[2] === ' ' ? 'x' : ' ';
feld.setSelectionRange(z.von + m[1].length + 1, z.von + m[1].length + 2);
einfuegen(neu);
feld.setSelectionRange(z.bis, z.bis);
return;
}
/* Keiner da: einen setzen, und zwar hinter der Einrückung. */
const e = /^(\s*)/.exec(z.text)[1];
feld.setSelectionRange(z.von + e.length, z.von + e.length);
einfuegen('[ ] ');
}
feld.addEventListener('keydown', (e) => {
/* Strg/Cmd + Enter: neues Blatt. */
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault(); neuesBlatt(); return;
}
/* Strg/Cmd + Leertaste: Haken auf dieser Zeile. */
if ((e.ctrlKey || e.metaKey) && (e.key === ' ' || e.code === 'Space')) {
e.preventDefault(); hakenUmschalten(); return;
}
/* Tabulator rückt ein, statt aus dem Feld zu springen.
MIT UMSCHALT springt er weiter -- sonst käme man mit der
Tastatur nicht mehr aus dem Blatt heraus, und das wäre eine
Falle für alle, die nicht mit der Maus arbeiten. */
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault(); einfuegen(' '); return;
}
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
/* Enter setzt die Liste fort. */
const z = zeileUm(feld.selectionStart);
const vorn = /^(\s*)(\[[ xX]\]\s|[-*]\s|>\s?)/.exec(z.text);
if (!vorn) return;
const nurZeichen = z.text.trim() === vorn[2].trim();
e.preventDefault();
if (nurZeichen) {
/* Zweimal Enter beendet die Liste -- wie in jedem Programm, das
Listen kann. Die angefangene Zeile wird dabei geleert. */
feld.setSelectionRange(z.von, z.bis);
einfuegen('\n');
return;
}
/* Ein gesetzter Haken wird NICHT mitgenommen: Die nächste Aufgabe
ist noch nicht erledigt. */
const fortsetzung = vorn[2].replace(/\[[xX]\]/, '[ ]');
einfuegen('\n' + vorn[1] + fortsetzung);
});
feld.addEventListener('input', () => { deckZeichnen(); aendernGeplant(); });
feld.addEventListener('scroll', () => { $('papier').scrollTop = feld.scrollTop; });
/* Beim Verlassen sofort sichern statt die 800 ms abzuwarten. */
feld.addEventListener('blur', () => { clearTimeout(sicherUhr); sichern(); });
/* Ein Klick auf ein Kästchen schaltet es um. Die Deckschicht lässt
sonst alles durch; nur die Haken fangen den Klick ab. */
deck.addEventListener('click', (e) => {
const hk = e.target.closest('.n-hk');
if (!hk) return;
e.preventDefault();
/* Wo im Text steht dieses Kästchen? Gezählt wird über die Knoten
davor -- das ist genauer als eine Suche nach dem n-ten „[ ]",
weil derselbe Text mehrfach vorkommen kann. */
let pos = 0;
const gehen = (knoten) => {
for (const k of knoten.childNodes) {
if (k === hk) return true;
if (k.nodeType === 3) pos += k.nodeValue.length;
else if (gehen(k)) return true;
}
return false;
};
if (!gehen(deck)) return;
const an = hk.dataset.an === 'ja';
feld.focus();
feld.setSelectionRange(pos + 1, pos + 2);
einfuegen(an ? ' ' : 'x');
feld.setSelectionRange(pos + 3, pos + 3);
});
/* =====================================================================
DIE GRIFFE
===================================================================== */
async function neuesBlatt() {
melde('');
try {
const a = await hole('/workspace/api/notizblock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: '' }),
});
if (!a.ok) { melde(await fehlerText(a, 'Das Blatt ließ sich nicht anlegen.')); return; }
const j = await a.json();
notizen.unshift(j.notiz);
oeffne(j.notiz.id);
} catch {
melde('Keine Verbindung. Sobald sie wieder da ist, geht es weiter.');
}
}
$('neu').addEventListener('click', neuesBlatt);
$('leer-neu').addEventListener('click', neuesBlatt);
$('anheften').addEventListener('click', async () => {
if (!aktuell) return;
const neu = !aktuell.angeheftet;
aktuell.angeheftet = neu;
$('anheften').setAttribute('aria-pressed', neu ? 'true' : 'false');
/* Umsortieren, damit man sieht, was der Knopf bewirkt hat. */
notizen.sort((a, b) => (Number(b.angeheftet) - Number(a.angeheftet))
|| (Date.parse(b.geaendert) - Date.parse(a.geaendert)));
stapelZeichnen();
try {
const a = await hole(`/workspace/api/notizblock/${aktuell.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angeheftet: neu }),
});
if (!a.ok) throw new Error('nein');
} catch {
aktuell.angeheftet = !neu;
$('anheften').setAttribute('aria-pressed', !neu ? 'true' : 'false');
stapelZeichnen();
melde('Das Anheften ließ sich nicht sichern.');
}
});
$('wegtun').addEventListener('click', async () => {
if (!aktuell) return;
const id = aktuell.id;
clearTimeout(sicherUhr);
await sichern();
try {
const a = await hole(`/workspace/api/notizblock/${id}`, { method: 'DELETE' });
if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; }
notizen = notizen.filter((x) => x.id !== id);
papierkorbZahl += 1;
aktuell = null;
$('blatt').hidden = true;
stapelZeichnen();
zurueckKnopf(false);
/* KEINE RÜCKFRAGE VORHER, ABER EIN WEG ZURÜCK NACHHER. Eine
Rückfrage bei jedem Wegtun ist eine Warnung, die immer kommt
-- die klickt man weg. Der Papierkorb ist die bessere
Sicherung: Er kostet nichts und hält dreißig Tage. */
melde(`Weggelegt. Liegt ${korbTage} Tage im Papierkorb.`);
setTimeout(() => melde(''), 6000);
} catch {
melde('Keine Verbindung.');
}
});
/* =====================================================================
DER PAPIERKORB
===================================================================== */
$('korb-auf').addEventListener('click', korbZeigen);
$('korb-zu').addEventListener('click', () => {
$('korb').hidden = true;
if (aktuell) $('blatt').hidden = false; else zurueckKnopf(false);
});
async function korbZeigen() {
melde('');
try {
const a = await hole('/workspace/api/notizblock/papierkorb');
if (!a.ok) { melde(await fehlerText(a, 'Der Papierkorb ließ sich nicht öffnen.')); return; }
const j = await a.json();
korbTage = j.papierkorb_tage || korbTage;
$('blatt').hidden = true;
$('korb').hidden = false;
zurueckKnopf(true);
$('korb-satz').textContent = j.notizen.length
? `Hier bleibt alles ${korbTage} Tage liegen und geht dann von selbst.`
: 'Nichts drin.';
const liste = $('korb-liste');
liste.textContent = '';
for (const n of j.notizen) {
const li = el('li');
const zeile = el('div', 'n-korb-zeile');
zeile.style.setProperty('--n-tinte', farbHex(n.farbe));
const t = el('div', 'n-korb-zeile__text');
t.append(el('span', 'n-korb-zeile__titel', n.titel || 'Ohne Titel'));
const w = el('span', 'n-korb-zeile__wann',
`weggelegt ${herZeit(n.weg_am)} · verschwindet ${herTag(n.endgueltig_am)}`);
w.title = genau(n.endgueltig_am);
t.append(w);
zeile.append(t);
const zurueck = el('button', 'knopf-still', 'Zurückholen');
zurueck.type = 'button';
zurueck.addEventListener('click', () => zurueckholen(n.id));
zeile.append(zurueck);
const weg = el('button', 'n-griff n-griff--weg');
weg.type = 'button';
weg.append(el('span', 'n-griff__text', 'Endgültig'));
weg.addEventListener('click', () => endgueltig(n.id, n.titel));
zeile.append(weg);
li.append(zeile);
liste.append(li);
}
} catch {
melde('Keine Verbindung.');
}
}
/** „in 12 Tagen" — dieselbe Idee wie herZeit, nur nach vorn. */
function herTag(iso) {
const ms = Date.parse(iso) - Date.now();
if (!Number.isFinite(ms)) return '';
const tage = Math.ceil(ms / 86400000);
if (tage <= 0) return 'demnächst';
if (tage === 1) return 'morgen';
return `in ${tage} Tagen`;
}
async function zurueckholen(id) {
try {
const a = await hole(`/workspace/api/notizblock/${id}/zurueck`, { method: 'POST' });
if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; }
const j = await a.json();
notizen.unshift(j.notiz);
notizen.sort((x, y) => (Number(y.angeheftet) - Number(x.angeheftet))
|| (Date.parse(y.geaendert) - Date.parse(x.geaendert)));
papierkorbZahl = Math.max(0, papierkorbZahl - 1);
$('korb').hidden = true;
oeffne(j.notiz.id);
} catch { melde('Keine Verbindung.'); }
}
async function endgueltig(id, titel) {
/* HIER WIRD GEFRAGT, und nur hier. Der Unterschied zum Wegtun ist
genau der, der eine Rückfrage rechtfertigt: Danach gibt es
keinen Weg zurück mehr. */
const name = titel ? `„${titel}“` : 'dieses Blatt';
/* DER HAUSDIALOG, NICHT `confirm()` (30.09.2026).
Hier stand der nackte Kasten des Browsers — ohne Notnagel, ohne
Hausgestaltung. Und er stellt die falsche Frage: `confirm()`
fragt „bist du sicher", der Hausdialog sagt, WAS passiert und
WAS bleibt. Genau dafür wurde er gebaut.
`bleibt` ist hier keine Zugabe, sondern der halbe Grund für die
Rückfrage: Der Papierkorb ist der Ort, an dem man etwas
wiederfindet. Wer endgültig löscht, gibt genau das auf. */
const ja = await window.frageNach?.({
titel: `${name} endgültig löschen?`,
was: 'Das Blatt verschwindet aus dem Papierkorb.',
bleibt: 'Alles andere im Papierkorb bleibt liegen und lässt sich '
+ 'weiter zurückholen.',
endgueltig: true,
ja: 'Endgültig löschen', nein: 'Im Papierkorb lassen',
});
if (ja === false || ja === undefined) return;
try {
const a = await hole(`/workspace/api/notizblock/${id}/endgueltig`, { method: 'DELETE' });
if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; }
papierkorbZahl = Math.max(0, papierkorbZahl - 1);
stapelZeichnen();
korbZeigen();
} catch { melde('Keine Verbindung.'); }
}
/* =====================================================================
SUCHE
===================================================================== */
$('suche').addEventListener('input', (e) => {
suchwort = String(e.target.value || '').trim().toLowerCase();
stapelZeichnen();
if (aktuell) deckZeichnen();
});
$('suche').addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
e.target.value = ''; suchwort = '';
stapelZeichnen(); if (aktuell) deckZeichnen();
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault(); $('suche').focus(); $('suche').select();
}
if (e.key === 'Escape' && document.activeElement === feld) {
feld.blur();
if (window.matchMedia('(max-width: 860px)').matches) schliessen();
}
});
$('zurueck')?.addEventListener('click', (e) => {
if ($('blatt').hidden && $('korb').hidden) return; // nicht unseres
e.preventDefault(); e.stopPropagation();
schliessen();
}, true);
window.addEventListener('resize', () => { zurueckKnopf(!!aktuell); hoeheAnpassen(); });
/* Beim Verlassen der Seite noch einmal losschicken. `keepalive`
sorgt dafür, dass die Anfrage den Seitenwechsel überlebt --
ohne das wird sie vom Browser abgebrochen, und der letzte Satz
wäre weg. */
window.addEventListener('pagehide', () => {
if (!aktuell) return;
const n = notizen.find((x) => x.id === aktuell.id);
if (n && n.text === feld.value) return;
lagerSchreiben(aktuell.id, feld.value);
try {
fetch(`/workspace/api/notizblock/${aktuell.id}`, {
method: 'PATCH', credentials: 'same-origin', keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: feld.value }),
});
} catch { /* Dann hilft der Zwischenspeicher beim nächsten Mal. */ }
});
/* =====================================================================
LOS
===================================================================== */
async function laden() {
try {
const a = await hole('/workspace/api/notizblock');
if (!a.ok) { melde(await fehlerText(a, 'Der Block ließ sich nicht laden.')); return; }
const j = await a.json();
notizen = j.notizen || [];
farben = j.farben || [];
papierkorbZahl = j.papierkorb || 0;
textMax = j.text_max || textMax;
korbTage = j.papierkorb_tage || korbTage;
stapelZeichnen();
/* Lag noch etwas im Browser? Anbieten, nicht einspielen. */
const rest = lagerLesen();
const dazu = rest && notizen.find((x) => x.id === rest.id);
if (dazu && rest.text !== dazu.text) {
melde('Auf diesem Gerät liegt noch eine ungesicherte Fassung von '
+ `„${dazu.titel || 'Ohne Titel'}“. Öffne das Blatt – du kannst sie dann übernehmen.`);
dazu.rest = rest.text;
} else if (rest) {
lagerLeeren();
}
/* Auf dem Rechner wird das oberste Blatt gleich aufgeschlagen --
ein leerer Arbeitsplatz neben einem vollen Stapel ist eine
Aufforderung zum Suchen. Auf dem Handy nicht: Dort legt sich
das Blatt über alles, und man käme nie an die Liste. */
if (notizen.length && !window.matchMedia('(max-width: 860px)').matches) {
oeffne(notizen[0].id, { fokus: false });
}
} catch {
melde('Keine Verbindung zum Server.');
}
}
laden();
})();