import { api, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
/* ── Die Notiz ─────────────────────────────────────────────────────────────
*
* Ein Text, mehr nicht. **InkNote gibt es schon** — die Notiz darf keine zweite
* Notiz-App werden: kein Titel, keine Liste von Notizen, keine Ordner, keine
* Suche. Genau ein Text, den man im Vorbeigehen benutzt: Einkaufszettel, eine
* Nummer, ein Gedanke. Alles, was länger als einen Tag lebt, gehört nach
* InkNote.
*
* SOFORT-ANSICHT — warum zeilenweise?
*
* Vorher gab es zwei Ansichten: gelesen gesetzt, geschrieben roh. Wer schrieb,
* sah `- [ ] Milch` und musste sich merken, wie das gleich aussehen wird. Das
* ist die Verwirrung, die hier weg soll.
*
* Der Text ist deshalb **immer** gesetzt zu sehen; roh ist nur die eine Zeile,
* in der die Schreibmarke steht. Man tippt also in einer Aufzählung, sieht aber
* ringsum schon Überschriften, Häkchen und Punkte.
*
* Der Weg dorthin wäre auch anders gegangen — ein `contenteditable`, das
* fertiges HTML entgegennimmt und daraus wieder Markdown macht. Das ist die
* Bauform, die in jedem Editor Ärger macht: Der Browser erzeugt beim Tippen
* eigenes HTML (`
`, ``, beim Einfügen ganze Fremdkörper), und
* aus dem muss man den Quelltext *zurückraten*. Ein Fehler dabei kostet Text.
*
* Hier bleibt der Quelltext die Wahrheit und wird nie zurückgerechnet: Die
* Anzeige entsteht aus ihm, und geändert wird immer nur genau eine Zeile.
* Deshalb kann hier nichts verlorengehen, was man nicht selbst gelöscht hat.
*
* Für größere Umbauten (alles markieren, Absätze verschieben) gibt es den
* Knopf „quelltext": dann doch das ganze Feld, roh.
*/
const LEER = "Noch nichts notiert.";
const VERZUG = 800; // ms bis zum Speichern beim Tippen
let text = "";
let zeile = -1; // welche Zeile gerade roh bearbeitet wird, -1 = keine
let marke = 0; // wohin die Schreibmarke nach dem Neuzeichnen gehört
let roh = false; // ganzes Feld roh (der Umweg für größere Umbauten)
let stand = ""; // "speichert …" | "gespeichert" | Fehlertext
let uhr = null;
const zeilen = () => text.split("\n");
const setzen = (arr) => {
text = arr.join("\n");
};
widgetAnmelden({
id: "notiz",
titel: "notiz",
// Die Notiz hält ein Textfeld: Ihr Inhalt darf nicht bei jedem Neuzeichnen
// ersetzt werden, sonst verliert man beim Tippen Text und Schreibmarke.
stabil: true,
async laden() {
const d = await api("/api/note");
text = (d && d.text) || "";
},
inhalt() {
if (roh) {
return `
${fuss("fertig")}`;
}
const koerper = text.trim() || zeile >= 0
? `
${markdown(text, zeile)}
`
: `
${LEER}
`;
// Die Werkzeuge nur beim Schreiben: Sie wirken auf die Zeile, in der die
// Schreibmarke steht — ohne eine solche Zeile stünden sechs Knöpfe da, die
// nichts anfassen können. Und eine Notiz, die man nur liest, soll wie ein
// Text aussehen und nicht wie ein Formular.
return `${zeile >= 0 ? werkzeuge() : ""}${koerper}${fuss("quelltext")}`;
},
montiert(el) {
el.querySelectorAll("[data-tat]").forEach((b) => {
// Nicht auf „click": Ein Mausklick nimmt dem Textfeld vorher den Fokus,
// und mit ihm wüsste man nicht mehr, für welche Zeile der Knopf gilt.
b.addEventListener("mousedown", (e) => {
e.preventDefault();
tat(b.dataset.tat);
});
});
if (roh) {
const feld = el.querySelector(".notiz-feld");
feld.focus();
feld.setSelectionRange(feld.value.length, feld.value.length);
feld.addEventListener("input", () => {
text = feld.value;
spaeterSpeichern();
});
return;
}
const eingabe = el.querySelector(".notiz-zeile");
if (eingabe) {
eingabe.focus();
eingabe.setSelectionRange(marke, marke);
wachsen(eingabe);
eingabe.addEventListener("input", () => {
const z = zeilen();
z[zeile] = eingabe.value;
setzen(z);
wachsen(eingabe);
spaeterSpeichern();
});
eingabe.addEventListener("keydown", (e) => taste(e, eingabe));
}
// Anklicken heißt: hier weiterschreiben.
el.querySelectorAll("[data-z]").forEach((b) =>
b.addEventListener("mousedown", (e) => {
if (e.target.closest(".notiz-zeile, .notiz-hakbox")) return;
e.preventDefault();
const i = Number(b.dataset.z);
springen(i, zeilen()[i].length);
})
);
el.querySelector(".notiz-leer")?.addEventListener("mousedown", (e) => {
e.preventDefault();
springen(0, 0);
});
// Häkchen lassen sich setzen, ohne in den Text zu springen — das ist der
// häufigste Griff an einer Liste.
el.querySelectorAll(".notiz-hakbox").forEach((box) =>
box.addEventListener("change", () => {
text = hakenUmlegen(text, Number(box.dataset.hak));
neu();
jetztSpeichern();
})
);
},
});
/* ── Die kleine Werkzeugleiste ─────────────────────────────────────────────
* Sechs Knöpfe, keine Auswahlfelder. Alles, was das kleine Markdown kann, und
* nichts darüber hinaus — eine Leiste mit Knöpfen für Dinge, die die Notiz gar
* nicht kennt, wäre schlimmer als keine.
*
* Sie erscheint erst, wenn man in einer Zeile steht, und verschwindet wieder,
* sobald man fertig ist.
*/
const KNOEPFE = [
["haken", "☐", "Aufgabe"],
["punkt", "•", "Aufzählung"],
["nummer", "1.", "Nummerierte Liste"],
["ueber", "H", "Überschrift"],
["fett", "B", "Fett"],
["linie", "—", "Trennlinie"],
];
function werkzeuge() {
return (
`
`;
}
/** Was die Knöpfe tun. Alle arbeiten auf der Zeile, in der man gerade steht. */
function tat(name) {
if (name === "modus") {
roh = !roh;
zeile = -1;
jetztSpeichern();
neu();
return;
}
// Ohne Schreibmarke gibt es keine „aktuelle Zeile" — dann eine neue am Ende.
if (zeile < 0) {
const z = zeilen();
if (z[z.length - 1].trim()) z.push("");
setzen(z);
zeile = z.length - 1;
marke = 0;
}
const z = zeilen();
if (name === "linie") {
z.splice(zeile + (z[zeile].trim() ? 1 : 0), 0, "---", "");
setzen(z);
zeile += z[zeile].trim() ? 2 : 1;
marke = 0;
neu();
jetztSpeichern();
return;
}
if (name === "fett") {
const feld = document.querySelector(".notiz-zeile");
const a = feld ? feld.selectionStart : z[zeile].length;
const b = feld ? feld.selectionEnd : a;
const wort = z[zeile].slice(a, b);
z[zeile] = z[zeile].slice(0, a) + "**" + wort + "**" + z[zeile].slice(b);
marke = wort ? b + 4 : a + 2; // ohne Auswahl: zwischen die Sterne
setzen(z);
neu();
jetztSpeichern();
return;
}
const VORSATZ = { haken: "- [ ] ", punkt: "- ", nummer: "1. ", ueber: "# " };
const alt = vorsatz(z[zeile]);
const rest = z[zeile].slice(alt.length);
const neuerVorsatz = alt === VORSATZ[name] ? "" : VORSATZ[name];
z[zeile] = neuerVorsatz + rest;
marke = Math.max(0, marke - alt.length) + neuerVorsatz.length;
setzen(z);
neu();
jetztSpeichern();
}
/** Der Listen- oder Überschriften-Vorsatz einer Zeile, samt Leerzeichen. */
function vorsatz(z) {
const m = /^(\s*(?:[-*]\s+\[[ xX]\]\s+|[-*]\s+|\d+[.)]\s+|#{1,3}\s+))/.exec(z);
return m ? m[1] : "";
}
/* ── Tastatur in der einen rohen Zeile ─────────────────────────────────── */
function taste(e, feld) {
const i = zeile;
const z = zeilen();
const anfang = feld.selectionStart === 0 && feld.selectionEnd === 0;
const ende = feld.selectionStart === feld.value.length && feld.selectionEnd === feld.value.length;
if (e.key === "Enter") {
e.preventDefault();
const links = feld.value.slice(0, feld.selectionStart);
const rechts = feld.value.slice(feld.selectionEnd);
const v = vorsatz(links);
// Enter auf einer leeren Aufzählungszeile beendet die Liste, statt einen
// weiteren leeren Punkt anzuhängen.
if (v && !links.slice(v.length).trim() && !rechts) {
z[i] = "";
setzen(z);
springen(i, 0);
return;
}
z.splice(i, 1, links, weiterfuehren(v) + rechts);
setzen(z);
springen(i + 1, weiterfuehren(v).length);
return;
}
if (e.key === "Backspace" && anfang) {
const v = vorsatz(feld.value);
if (v) {
// Erst den Vorsatz weg, dann erst die Zeile: „Backspace am Zeilenanfang"
// heißt meistens „das ist doch kein Aufzählungspunkt".
e.preventDefault();
z[i] = feld.value.slice(v.length);
setzen(z);
springen(i, 0);
return;
}
if (i > 0) {
e.preventDefault();
const stelle = z[i - 1].length;
z.splice(i - 1, 2, z[i - 1] + z[i]);
setzen(z);
springen(i - 1, stelle);
}
return;
}
if (e.key === "Delete" && ende && i < z.length - 1) {
e.preventDefault();
const stelle = z[i].length;
z.splice(i, 2, z[i] + z[i + 1]);
setzen(z);
springen(i, stelle);
return;
}
if (e.key === "ArrowUp" && anfang && i > 0) {
e.preventDefault();
springen(i - 1, z[i - 1].length);
return;
}
if (e.key === "ArrowDown" && ende && i < z.length - 1) {
e.preventDefault();
springen(i + 1, 0);
return;
}
if (e.key === "Escape") {
e.preventDefault();
beenden();
}
}
/** Was nach Enter am Anfang der neuen Zeile steht. */
function weiterfuehren(v) {
if (!v || v.trim().startsWith("#")) return "";
const haken = /^(\s*[-*]\s+)\[[ xX]\]\s+$/.exec(v);
if (haken) return haken[1] + "[ ] "; // eine neue Aufgabe ist offen
const nummer = /^(\s*)(\d+)([.)]\s+)$/.exec(v);
if (nummer) return nummer[1] + (Number(nummer[2]) + 1) + nummer[3];
return v;
}
function springen(i, stelle) {
zeile = i;
marke = stelle;
neu();
}
function beenden() {
zeile = -1;
neu();
jetztSpeichern();
}
// Ein Klick irgendwo anders hin heißt: fertig mit dieser Zeile. Nicht über
// „blur" — den löst auch jeder Knopf in der Werkzeugleiste aus.
//
// In der **Erfassungsphase**, und das ist hier keine Feinheit: Ein Klick in die
// Notiz zeichnet sie neu, und danach hängt das angeklickte Element nicht mehr
// im Dokument. `closest()` liefe dann ins Leere und dieser Zuhörer hielte einen
// Klick auf die Notiz für einen Klick nach draußen — er würde also genau das
// beenden, was der Klick gerade geöffnet hat.
document.addEventListener(
"mousedown",
(e) => {
if (zeile < 0 || e.target.closest('[data-widget="notiz"]')) return;
beenden();
},
true
);
/** Neu zeichnen — der Rahmen lässt stabile Widgets sonst in Ruhe. */
function neu() {
document.querySelectorAll('[data-widget="notiz"]').forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
/** Ein Zeilenfeld ist so hoch wie sein Text — es soll aussehen wie die Zeile,
* die es ersetzt, nicht wie ein Formular. */
function wachsen(feld) {
feld.style.height = "auto";
feld.style.height = feld.scrollHeight + "px";
}
/* ── Speichern ─────────────────────────────────────────────────────────── */
function spaeterSpeichern() {
stand = "";
clearTimeout(uhr);
uhr = setTimeout(jetztSpeichern, VERZUG);
}
/**
* Speichert, wenn sich etwas geändert hat.
*
* Zwei offene Geräte überschreiben einander; der letzte Stand gewinnt. Für eine
* Notiz ist das vertretbar — man sollte es nur wissen und nicht später
* „Datenverlust" nennen.
*/
let gespeichert = null;
async function jetztSpeichern() {
clearTimeout(uhr);
if (text === gespeichert) return;
const wollte = text;
standSetzen("speichert …");
try {
await api("/api/note", { method: "PUT", body: JSON.stringify({ text: wollte }) });
gespeichert = wollte;
standSetzen("gespeichert");
} catch (e) {
standSetzen(e.message || "nicht gespeichert");
}
}
function standSetzen(s) {
stand = s;
document.querySelectorAll('[data-widget="notiz"] .notiz-stand').forEach((el) => {
el.textContent = s;
});
}
/* ── Das kleine Markdown ───────────────────────────────────────────────────
*
* Absichtlich von Hand und absichtlich klein: Überschriften, Häkchen, Striche,
* Aufzählungen, fett, kursiv, Code. Keine Bibliothek — HoneyOS hat keinen
* Bauschritt, und für sechs Regeln lohnt kein Nachladen.
*
* **Zuerst wird escapt, dann gesetzt.** Der Text ist zwar der eigene, aber eine
* Notiz, die `