547 lines
17 KiB
JavaScript
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 - [ ] Milch - [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(() => {});
|
|
});
|