Files
HoneyOS/web/js/widgets/notiz.js
T
marc c0abfbb491 Zwei Widgets: Zettel mit kleinem Markdown, Bild je Nutzer
Zettel: ein Textfeld, mehr nicht — InkNote gibt es schon. Gelesen wird
gesetzt (Ueberschriften, Haken, Aufzaehlungen, fett/kursiv/code, Strich),
geschrieben wird roh. Haken lassen sich ohne Bearbeiten setzen und
aendern die Quelle. Gespeichert wird 800 ms nach dem letzten Anschlag,
sofort beim Verlassen und beim Schliessen des Tabs (keepalive statt
sendBeacon: der kann nur POST und traegt keine Kopfzeilen). Grenze 4000
Zeichen, vom Server durchgesetzt.

Bild: genau eines je Nutzer, hochgeladen oder verlinkt. Die erste Datei,
die HoneyOS ablegt — in bilder/ neben honeyos.json, benannt nach einem
Fingerabdruck der Nutzerkennung. Ein neues Bild ersetzt das alte samt
Datei, die Adresse traegt einen Versionsanteil gegen den Zwischen-
speicher. SVG kommt nicht herein (Text, kann Skripte tragen), und der
Typ wird an den Bytes erkannt, nicht an der Angabe des Browsers.

Der Rahmen kann jetzt Widgets mit eigenem DOM: 'stabil' haelt den Inhalt,
'montiert' verdrahtet ihn. Ohne das haette das Anklopfen bei den Apps
alle zwei Minuten den Text unter den Fingern ausgetauscht.

Nebenbei: Der Aufrufhelfer setzte bei jedem Schreibzugriff
Content-Type: application/json — auch fuer Bildbytes.
2026-08-31 00:44:20 +02:00

255 lines
7.9 KiB
JavaScript

import { api, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
/* ── Der Zettel ────────────────────────────────────────────────────────────
*
* Ein Textfeld, mehr nicht. **InkNote gibt es schon** — der Zettel darf keine
* zweite Notiz-App werden: kein Titel, keine Liste, 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.
*
* Zwei Ansichten: gelesen wird gesetzt, geschrieben wird roh. Der Umweg über
* einen Knopf ist Absicht — ein Feld, das immer im Bearbeitungsmodus steht,
* frisst jeden Klick, den man eigentlich dem Häkchen zugedacht hatte.
*
* Häkchen lassen sich **ohne** Bearbeiten setzen. Das ist der häufigste Griff
* an einer Liste, und dafür erst in den Text zu springen wäre albern.
*/
const LEER = "Noch nichts notiert.";
const VERZUG = 800; // ms bis zum Speichern beim Tippen
let text = "";
let bearbeiten = false;
let stand = ""; // "speichert …" | "gespeichert" | Fehlertext
let uhr = null;
widgetAnmelden({
id: "notiz",
titel: "zettel",
// Der Zettel hält ein Textfeld: Sein 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 (bearbeiten) {
return `
<textarea class="zettel-feld" spellcheck="false"
placeholder="# Einkauf&#10;- [ ] Milch&#10;- [x] Brot">${escapeHtml(text)}</textarea>
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<button class="btn zettel-fertig">fertig</button>
</div>`;
}
return `
<div class="zettel-text">${text.trim() ? markdown(text) : `<p class="w-zustand">${LEER}</p>`}</div>
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<button class="btn zettel-auf">${text.trim() ? "bearbeiten" : "schreiben"}</button>
</div>`;
},
montiert(el) {
const feld = el.querySelector(".zettel-feld");
if (feld) {
feld.focus();
// Schreibmarke ans Ende, nicht an den Anfang: Man schreibt weiter, man
// fängt selten neu an.
feld.setSelectionRange(feld.value.length, feld.value.length);
feld.addEventListener("input", () => {
text = feld.value;
spaeterSpeichern();
});
// Beim Verlassen sofort, nicht erst nach dem Verzug — sonst ginge der
// letzte Satz verloren, wenn man den Tab zumacht.
feld.addEventListener("blur", jetztSpeichern);
el.querySelector(".zettel-fertig").addEventListener("click", () => {
bearbeiten = false;
jetztSpeichern();
neu();
});
return;
}
const auf = el.querySelector(".zettel-auf");
if (auf) {
auf.addEventListener("click", () => {
bearbeiten = true;
neu();
});
}
// Häkchen im gesetzten Text — sie ändern die Quelle, nicht nur die Anzeige.
el.querySelectorAll("[data-hak]").forEach((box) =>
box.addEventListener("change", () => {
text = hakenUmlegen(text, Number(box.dataset.hak));
neu();
jetztSpeichern();
})
);
},
});
/** Neu zeichnen — der Rahmen lässt stabile Widgets sonst in Ruhe. */
function neu() {
const koerper = document.querySelectorAll('[data-widget="notiz"]');
koerper.forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
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
* einen Zettel 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"] .zettel-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 ein
* Zettel, der `<script>` ausführt, wäre eine Falle für den Tag, an dem man
* etwas hineinkopiert.
*/
export function markdown(quelle) {
const zeilen = quelle.split("\n");
let html = "";
let liste = null; // "ul" | "ol" | null
let hakenNr = -1; // fortlaufend über den ganzen Text, für data-hak
const listeZu = () => {
if (liste) html += `</${liste}>`;
liste = null;
};
const listeAuf = (art) => {
if (liste !== art) {
listeZu();
html += `<${art}>`;
liste = art;
}
};
zeilen.forEach((roh) => {
const z = roh.trimEnd();
if (!z.trim()) {
listeZu();
return;
}
const ueber = /^(#{1,3})\s+(.*)$/.exec(z);
if (ueber) {
listeZu();
const n = ueber[1].length + 2; // # → h3, ## → h4, ### → h5
html += `<h${n}>${inline(ueber[2])}</h${n}>`;
return;
}
if (/^(-{3,}|\*{3,})$/.test(z.trim())) {
listeZu();
html += "<hr />";
return;
}
const hak = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/.exec(z);
if (hak) {
hakenNr++;
listeAuf("ul");
const an = hak[1].toLowerCase() === "x";
html +=
`<li class="zettel-hak${an ? " ab" : ""}">` +
`<label><input type="checkbox" data-hak="${hakenNr}"${an ? " checked" : ""} />` +
`<span>${inline(hak[2])}</span></label></li>`;
return;
}
const punkt = /^\s*[-*]\s+(.*)$/.exec(z);
if (punkt) {
listeAuf("ul");
html += `<li>${inline(punkt[1])}</li>`;
return;
}
const zahl = /^\s*\d+[.)]\s+(.*)$/.exec(z);
if (zahl) {
listeAuf("ol");
html += `<li>${inline(zahl[1])}</li>`;
return;
}
listeZu();
html += `<p>${inline(z)}</p>`;
});
listeZu();
return html;
}
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(() => {});
});