Files
dogfather-universe/workspace/assets/js/notizen.js
T
DogFatherGitandClaude Opus 5 75ede0a8e9 Die Rueckfragen sagten nicht, was passiert -- und eine lief gar nicht ueber den Hausdialog
`pruef-nachfrage` und `pruef-call-kategorien` standen seit dem 28.09.
rot und waren als „nicht angesehen" vermerkt. Nachgemessen sind es
drei echte Befunde und eine Zeitbombe.

=====================================================================
1. FALSCHE FELDNAMEN -- die Erklaerung erschien NIE
=====================================================================
Der Hausdialog kennt `was:` und `ja:`. An vier Stellen in
`reaktion.js` stand `text:` und `knopf:`. Beides wird stillschweigend
ignoriert.

Wer „Sendung beenden?" las, bekam den Titel und zwei Knoepfe -- genau
die Frage, die `confirm()` stellt und derentwegen `nachfrage.js`
ueberhaupt gebaut wurde. Es stuerzt nichts ab, es fehlt nur; so etwas
findet keine Syntaxpruefung und kein Blick auf den Bildschirm, wenn
man den Satz nicht vermisst.

Alle vier tragen jetzt `was` UND `bleibt`. Beim Beenden steht dabei
das, was seit heute frueh dazugekommen ist: dass Titel, Video,
Vorschaubild, Beginn und das zweite Video geleert werden -- wer das
nicht weiss, traegt danach alles noch einmal ein und haelt es fuer
einen Fehler.

=====================================================================
2. `window.frageNachText` GIBT ES NICHT
=====================================================================
Nur EINE Zeile im ganzen Haus nennt es; gesetzt hat es niemand. Die
Abfrage „Wie viele Dogen waren es?" lief damit IMMER ueber den
nackten `prompt()` -- ausgerechnet die, die am haeufigsten vorkommt.

Der Hausdialog kann das laengst besser: `zahl:` ist ein Zahlenfeld
mit Obergrenze und eigener Fehlerzeile.

=====================================================================
3. DER DOPPELTE NOTNAGEL WAR TOTER CODE
=====================================================================
`window.frageNach ? await frageNach(...) : confirm(...)` -- der
Notnagel fuer Browser ohne <dialog> steckt schon IN `nachfrage.js`,
und zwar mit DEMSELBEN Text. Meiner daneben zeigte im Ernstfall
WENIGER und im Normalfall nie etwas.

Im Notizblock stand sogar der nackte `window.confirm()`, ganz ohne
Hausdialog. Er fragt „bist du sicher"; der Hausdialog sagt, was
passiert und was bleibt. Beim endgueltigen Loeschen ist das `bleibt`
der halbe Grund fuer die Rueckfrage: Der Papierkorb ist der Ort, an
dem man etwas wiederfindet.

=====================================================================
4. EINE ZEITBOMBE IN pruef-call-kategorien
=====================================================================
Sie legte eine Wiederholung „in vier Tagen" an -- im Quelltext stand
woertlich „also mit grosser Wahrscheinlichkeit dieser Monat". Heute
ist der 30.; vier Tage spaeter ist Oktober. Der Server verlangt
kuenftig UND im laufenden Monat, und am Monatsende gibt es beides
zusammen nicht.

DIE ANWENDUNG HATTE RECHT. Dieselbe Art wie bei `pruef-kalender` am
29.09. Die nahe Auspraegung wird jetzt GERECHNET (letzter Tag des
Monats, 23:59) statt gehofft -- plus ein benannter dritter Ausgang
fuer die eine Minute im Monat, in der es keinen kuenftigen Termin
mehr in diesem Monat gibt. Die drei anderen Aussagen werden auch dann
geprueft; ein Ausstieg, der gar nichts mehr misst, waere ein stiller
Aussetzer.

Gemessen: pruef-nachfrage 53/0 (war 51/2), pruef-call-kategorien 22/0
(war 21/1), pruef-notizen 79/0, pruef-reaktion 407/0, mess-reaktion
0/0, pruef-css-klassen 37/0, pruef-struktur 44/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 11:10:49 +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();
})();