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 ( `
` + KNOEPFE.map( ([tat, zeichen, titel]) => `` ).join("") + `
` ); } function fuss(knopf) { return `
${escapeHtml(stand)}
`; } /** 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 `