Files
HoneyOS/web/js/widgets/notiz.js
T

547 lines
17 KiB
JavaScript

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 (`<div>`, `<span style>`, 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 `
<textarea class="notiz-feld" spellcheck="false"
placeholder="# Einkauf&#10;- [ ] Milch&#10;- [x] Brot">${escapeHtml(text)}</textarea>
${fuss("fertig")}`;
}
const koerper = text.trim() || zeile >= 0
? `<div class="notiz-text">${markdown(text, zeile)}</div>`
: `<div class="notiz-text notiz-leer"><p class="w-zustand">${LEER}</p></div>`;
// 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 (
`<div class="notiz-werkzeuge">` +
KNOEPFE.map(
([tat, zeichen, titel]) =>
`<button type="button" class="notiz-wz nw-${tat}" data-tat="${tat}" title="${titel}">${zeichen}</button>`
).join("") +
`</div>`
);
}
function fuss(knopf) {
return `
<div class="notiz-fuss">
<span class="notiz-stand">${escapeHtml(stand)}</span>
<button type="button" class="btn notiz-modus" data-tat="modus">${knopf}</button>
</div>`;
}
/** 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 `<script>` ausführt, wäre eine Falle für den Tag, an dem man etwas
* hineinkopiert.
*
* `bearbeitet` ist die Zeile, die roh gezeigt wird. Sie behält ihren Platz in
* der Liste, in der sie steht — sonst spränge der Punkt beim Hineinklicken aus
* der Aufzählung heraus.
*/
export function markdown(quelle, bearbeitet = -1) {
const zln = quelle.split("\n");
let html = "";
let liste = null; // "ul" | "ol" | null
// Die Häkchen werden über den **Quelltext** gezählt, nicht über das, was man
// sieht: Die gerade bearbeitete Zeile zeigt keines, ihre Nummer muss aber
// vergeben bleiben, sonst schaltet ein Klick darunter das falsche um.
const hakNr = {};
let n = -1;
zln.forEach((z, i) => {
if (HAKEN.test(z)) hakNr[i] = ++n;
});
const listeZu = () => {
if (liste) html += `</${liste}>`;
liste = null;
};
const listeAuf = (art) => {
if (liste !== art) {
listeZu();
html += `<${art}>`;
liste = art;
}
};
zln.forEach((rohzeile, i) => {
const z = rohzeile.trimEnd();
if (i === bearbeitet) {
const feld =
`<textarea class="notiz-zeile" rows="1" spellcheck="false" ` +
`placeholder="schreiben …">${escapeHtml(rohzeile)}</textarea>`;
const art = HAKEN.test(z) || PUNKT.test(z) ? "ul" : ZAHL.test(z) ? "ol" : null;
if (art) {
listeAuf(art);
html += `<li class="notiz-rohli">${feld}</li>`;
} else {
listeZu();
html += feld;
}
return;
}
if (!z.trim()) {
listeZu();
return;
}
const ueber = /^(#{1,3})\s+(.*)$/.exec(z);
if (ueber) {
listeZu();
const g = ueber[1].length + 2; // # → h3, ## → h4, ### → h5
html += `<h${g} data-z="${i}">${inline(ueber[2])}</h${g}>`;
return;
}
if (/^(-{3,}|\*{3,})$/.test(z.trim())) {
listeZu();
html += `<hr data-z="${i}" />`;
return;
}
const hak = HAKEN.exec(z);
if (hak) {
listeAuf("ul");
const an = hak[1].toLowerCase() === "x";
html +=
`<li class="notiz-hak${an ? " ab" : ""}" data-z="${i}">` +
`<label><input class="notiz-hakbox" type="checkbox" data-hak="${hakNr[i]}"${an ? " checked" : ""} />` +
`<span>${inline(hak[2])}</span></label></li>`;
return;
}
const punkt = PUNKT.exec(z);
if (punkt) {
listeAuf("ul");
html += `<li data-z="${i}">${inline(punkt[1])}</li>`;
return;
}
const zahl = ZAHL.exec(z);
if (zahl) {
listeAuf("ol");
html += `<li data-z="${i}">${inline(zahl[1])}</li>`;
return;
}
listeZu();
html += `<p data-z="${i}">${inline(z)}</p>`;
});
listeZu();
return html;
}
const HAKEN = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
const PUNKT = /^\s*[-*]\s+(.*)$/;
const ZAHL = /^\s*\d+[.)]\s+(.*)$/;
function inline(s) {
return escapeHtml(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, "$1<i>$2</i>");
}
/** Legt das n-te Häkchen im Quelltext um — gezählt wie beim Setzen. */
export function hakenUmlegen(quelle, n) {
let i = -1;
return quelle
.split("\n")
.map((z) => {
const hak = /^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/.exec(z);
if (!hak) return z;
i++;
if (i !== n) return z;
return hak[1] + (hak[2].toLowerCase() === "x" ? " " : "x") + hak[3];
})
.join("\n");
}
// Beim Verlassen der Seite noch schnell sichern — der Verzug beim Tippen darf
// keinen Satz kosten, nur weil der Tab zugeht.
//
// `keepalive` statt sendBeacon: Der Beacon kann nur POST und trägt keine
// eigenen Kopfzeilen — unser Weg ist ein PUT mit Prüfwort. Ein Beacon wäre
// hier also nicht schneller, sondern schlicht abgewiesen worden.
window.addEventListener("pagehide", () => {
if (text === gespeichert) return;
api("/api/note", { method: "PUT", body: JSON.stringify({ text }), keepalive: true }).catch(() => {});
});