`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]>
922 lines
35 KiB
JavaScript
922 lines
35 KiB
JavaScript
/* =====================================================================
|
||
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();
|
||
})();
|