Notiz zeigt sich sofort gesetzt, Widgets nach Wunsch des Nutzers

Aus dem Zettel wird die Notiz: Der Text steht immer formatiert da, roh ist nur
die Zeile mit der Schreibmarke. Dazu eine kleine Werkzeugleiste (Aufgabe,
Aufzaehlung, Nummer, Ueberschrift, fett, Linie), Enter fuehrt Listen fort,
Backspace am Zeilenanfang nimmt erst den Vorsatz. Fuer groessere Umbauten
bleibt der Quelltext-Knopf.

Kein contenteditable: Der Quelltext bleibt die Wahrheit und wird nie aus dem
HTML zurueckgerechnet, das der Browser beim Tippen erzeugt.

Reihenfolge und An/Aus der Widgets liegen jetzt je Nutzer auf dem Server, in
einer Einstellung ("benutzer,notiz,-system,wetter,bild"). Der Schalter fuer das
Wetter auf dem Schreibtisch faellt damit weg — es ist ein Widget wie jedes
andere.

Die Einstellungen sind in Abschnitte sortiert (Darstellung, HoneyBar, Widgets,
Wetter, Programme, Konto), Beschriftung links, Schalter rechts.
This commit is contained in:
marc committed 2026-08-31 01:24:50 +02:00
1 parent c0abfbb491
commit 3807ddca24
12 files changed
+796 -210

No files matched your search

+29 -5
View File
@@ -206,8 +206,10 @@ nicht geplant"). Was bleibt, ist alles, was für sich allein steht: Wetter
### Wetter in der HoneyBar, neben der Uhr — ✅ gebaut (25.08.2026)
**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem
Schreibtisch, in der HoneyBar, beides oder keins. Die Stadt wird einmal gesucht
**Steht.** Zwei Orte, unabhängig voneinander: auf dem Schreibtisch, in der
HoneyBar, beides oder keins. Seit dem 31.08.2026 an zwei Stellen — auf dem
Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in der
Widget-Liste, für die Leiste gibt es den eigenen Schalter. Die Stadt wird einmal gesucht
und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es
zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt.
@@ -299,7 +301,7 @@ Zu prüfen ist dabei, was sonst noch inline gesetzt wird: die Lage der HoneyBar
selbst und der Innenabstand, den sie der Seite verpasst. Über die CSSOM gesetzte
Stile (`el.style.x = …`) fallen nicht unter die CSP — was im Markup steht, schon.
### Bild-Widget — Marcs Idee (29.08.2026)
### Bild-Widget — ✅ gebaut (30.08.2026)
Ein Widget, das ein Bild zeigt: hochgeladen oder als Adresse eingetragen. Genau
**eines je Nutzer**; ein neues ersetzt das alte, ohne Verwaltung und ohne Liste.
@@ -324,11 +326,19 @@ Zu klären, wenn es soweit ist:
- Passt zum Aufnehmen eigener App-Logos (1.3): dieselbe Ablage, dasselbe
Ausliefern. Zusammen bauen spart die Hälfte.
### Notizzettel-Widget — Marcs Idee (29.08.2026)
### Notiz-Widget — ✅ gebaut (30.08.2026), Sofort-Ansicht (31.08.2026)
Ein Zettel auf dem Schreibtisch: hineintippen, fertig. Serverseitig je Nutzer,
also auf jedem Gerät derselbe Text.
**Nachtrag 31.08.2026 — was daraus wurde.** Aus dem „Zettel" ist die *Notiz*
geworden, mit kleinem Markdown, einer Werkzeugleiste und **Sofort-Ansicht**: Der
Text ist immer gesetzt zu sehen, roh ist nur die Zeile, in der die Schreibmarke
steht. Der Weg über `contenteditable` wurde verworfen — man müsste aus dem HTML,
das der Browser beim Tippen erzeugt, den Quelltext zurückraten, und ein Fehler
dabei kostet Text. Die Grenze zu InkNote steht trotz Formatierung: ein Text,
keine Liste von Notizen, keine Ordner, keine Suche.
**Die Abgrenzung ist hier wichtiger als die Technik:** InkNote gibt es schon.
Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste,
keine Ordner, keine Suche, keine Formatierung. Genau *ein* Textfeld, das man
@@ -387,7 +397,7 @@ wenige Farben reduziert, hart gerastert) oder bunt bleiben? Ein Original-Logo
mit Farbverlauf neben Pixelglyphen sieht schnell nach Fremdkörper aus. Ein
Filter in der HoneyBar wäre billig zu haben und ließe sich abschalten.
### Widgets verschieben, Reihenfolge im Server — Marcs Idee (29.08.2026)
### Widgets verschieben, Reihenfolge im Server — ✅ gebaut (31.08.2026)
Widgets per Ziehen umsortieren, die Reihenfolge liegt beim Nutzer auf dem
Server — also auf jedem Gerät gleich.
@@ -412,6 +422,20 @@ Voraussetzung ist ein Widget-Verzeichnis — heute stehen Benutzer und Wetter
fest im HTML. Sinnvoll zusammen mit dem Widget-Rahmen (1.4) zu bauen, nicht
davor.
**Gebaut (31.08.2026), wie vorgeschlagen: eine Liste, beide Ansichten folgen
ihr.** Dazu ein Schalter je Widget — wer den Systemzustand nicht sehen will,
soll ihn abschalten können, ohne ihn nach unten zu schieben.
Beides steht in **einer** Einstellung: `widgets = "benutzer,notiz,-system,…"`,
das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Ein
ausgeschaltetes Widget behält seinen Platz. Damit brauchte es weder einen neuen
Endpunkt noch eine neue Tabelle — die Einstellungen liegen ohnehin je Nutzer auf
dem Server.
Ziehen ist es nicht geworden, sondern **Pfeile** — aus demselben Grund wie bei
den Apps (1.3b): Am Handy löst HTML5-Drag per Berührung nicht aus, und die Liste
ist kurz. Freie Plätze auf dem Schreibtisch bleiben offen, falls sie je fehlen.
### Widgets in der HoneyBar selbst
Bisher sind Widgets als Fenster auf dem Schreibtisch gedacht. Der Schreibtisch
+76 -6
View File
@@ -192,7 +192,7 @@ Ein Widget ist eine **Beschreibung**, kein Kasten in der `index.html`:
widgetAnmelden({
id: "wetter", titel: "wetter",
wo: ["terminal", "desktop"], // wo es erscheinen darf
sichtbar: () => state.settings.weatherDesk === "on",
sichtbar: () => true, // eigene Bedingung, optional
laden: async () => …, // false = es gibt nichts zu zeigen
leer: "verbergen", // oder ein Satz, der dann dasteht
inhalt: () => "<html>",
@@ -214,15 +214,85 @@ wegbleiben — so macht es das Wetter ohne eingestellten Ort.
Eine neue Anzeige ist damit eine Datei in `web/js/widgets/` und ein Import in
`os.js`. Weder das Raster noch die Seite werden dafür angefasst.
### Reihenfolge und An/Aus liegen beim Nutzer
Unter *Einstellungen → Widgets* steht jedes Widget mit einem Schalter und zwei
Pfeilen. Beides zusammen steht in **einer** Einstellung, und die liegt wie alle
anderen beim Konto auf dem Server — also auf jedem Gerät gleich:
```
widgets = "benutzer,notiz,-system,wetter,bild"
```
Das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Wer ein
Widget ausschaltet, soll seinen Platz behalten, wenn er es wieder einschaltet.
Die Liste ist ein Wunsch, kein Gesetz — unbekannte Namen fallen weg, neue
Widgets hängen sich hinten an und sind an. Ein Widget, das erst später
eingeschaltet wird, holt seine Daten dann nach.
Pfeile statt Ziehen: Ziehen wäre hübscher, löst am Handy per Berührung aber
nicht aus (dieselbe Entscheidung wie bei der Reihenfolge der Apps).
## Notiz und Bild
Zwei Widgets, die nichts abfragen, sondern etwas *sind*. Beides liegt **je
Nutzer auf dem Server** — die Notiz als Text in `honeyos.json`, das Bild als
Datei in `bilder/` daneben (oder als fremde Adresse, nie beides).
### Die Notiz zeigt sich sofort gesetzt
Der Text ist **immer** formatiert zu sehen; roh ist nur die eine Zeile, in der
die Schreibmarke steht. Man tippt also in einer Aufzählung und sieht ringsum
schon Überschriften, Häkchen und Punkte — statt `- [ ] Milch` zu schreiben und
sich vorzustellen, wie das gleich aussieht.
Die Werkzeugleiste (Aufgabe · Aufzählung · Nummer · Überschrift · fett · Linie)
wirkt auf genau diese Zeile. Enter führt eine Liste fort und nummeriert weiter;
Enter auf einer leeren Aufzählungszeile beendet sie. Backspace am Zeilenanfang
nimmt erst den Aufzählungspunkt, dann erst die Zeile. Häkchen lassen sich
anklicken, ohne in den Text zu springen — das ist der häufigste Griff an einer
Liste. Für größere Umbauten gibt es den Knopf **quelltext**: dann doch das ganze
Feld, roh.
**Warum nicht `contenteditable`?** Weil man aus dem, was der Browser beim Tippen
und Einfügen an HTML erzeugt, den Quelltext *zurückraten* müsste — und ein
Fehler dabei kostet Text. Hier bleibt der Quelltext die Wahrheit und wird nie
zurückgerechnet: Die Anzeige entsteht aus ihm, geändert wird immer nur eine
Zeile. Der Preis ist, dass man nicht über mehrere Zeilen hinweg markieren kann;
dafür ist der Quelltext-Knopf da.
Das kleine Markdown ist von Hand geschrieben und absichtlich klein:
Überschriften (`#`, `##`, `###`), Häkchen (`- [ ]`, `- [x]`), Aufzählungen,
nummerierte Listen, `---`, `**fett**`, `*kursiv*`, `` `code` ``. Keine
Bibliothek — HoneyOS hat keinen Bauschritt, und für sechs Regeln lohnt kein
Nachladen. Escapt wird **vor** dem Formatieren: Eine Notiz, die `<script>`
ausführt, wäre eine Falle für den Tag, an dem man etwas hineinkopiert.
**Die Notiz ist keine zweite Notiz-App.** InkNote gibt es schon: kein Titel,
keine Liste von Notizen, keine Ordner, keine Suche. Genau ein Text für das, was
nicht länger als einen Tag lebt. Gespeichert wird verzögert (und beim Verlassen
der Seite sofort); zwei offene Geräte überschreiben einander, der letzte Stand
gewinnt.
### Das Bild
Genau eines je Nutzer, hochgeladen oder verlinkt; ein neues ersetzt das alte
samt Datei. Erlaubt sind PNG, JPEG, WebP und GIF — der Typ wird aus den
**Bytes** bestimmt, nicht aus dem, was der Browser behauptet. SVG ist bewusst
nicht dabei: Es kann Skripte enthalten. Ausgeliefert wird mit einem
Versionsanteil in der Adresse, sonst bliebe beim Austausch das alte Bild im
Zwischenspeicher hängen.
## Wetter
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
HoneyBar, in beidem oder nirgends. Zwei getrennte Schalter unter
*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim
Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer
Seite, die er selten aufruft.
HoneyBar, in beidem oder nirgends. Zwei Orte, zwei Schalter an zwei Stellen:
Auf dem Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in
der Widget-Liste; für die Leiste gibt es einen eigenen Schalter unter
*Einstellungen → HoneyBar*. Wer es beim Arbeiten in einer App sehen will, will
es nicht zwangsläufig auch auf einer Seite, die er selten aufruft.
Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert;
Die Stadt wird unter *Einstellungen → Wetter* einmal gesucht und **mit ihren Koordinaten** gespeichert;
sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
statt einer. Ohne Stadt bleibt das Wetter aus.
+8 -8
View File
@@ -39,8 +39,8 @@ type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
// Der Zettel. Nicht in den Einstellungen, obwohl er dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und ein Zettel
// Die Notiz. Nicht in den Einstellungen, obwohl sie dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und eine Notiz
// ist Text, kein Schalter.
Note string `json:"note,omitempty"`
@@ -67,7 +67,7 @@ const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
// Ein Zettel, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// Eine Notiz, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// muss die Grenze durchsetzen, nicht das Formular.
maxNoteLen = 4000
)
@@ -200,19 +200,19 @@ func (s *Store) SeenUser(sub, name, picture string) error {
return s.save()
}
// Note gibt den Zettel des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Ein fremder Zettel ist kein Ersatz für den eigenen.
// Note gibt die Notiz des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Eine fremde Notiz ist kein Ersatz für die eigene.
func (s *Store) Note(sub string) string {
return s.User(sub).Note
}
// SetNote schreibt den Zettel. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für einen Zettel ist das vertretbar; alles darüber
// SetNote schreibt die Notiz. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für eine Notiz ist das vertretbar; alles darüber
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
// Kleinigkeit mehr, sondern eine Notiz-App.
func (s *Store) SetNote(sub, text string) error {
if len([]rune(text)) > maxNoteLen {
return fmt.Errorf("der Zettel fasst %d Zeichen", maxNoteLen)
return fmt.Errorf("die Notiz fasst %d Zeichen", maxNoteLen)
}
s.mu.Lock()
+3 -3
View File
@@ -52,7 +52,7 @@ func ladeGlyphen() map[string][]string {
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
const Version = "1.1"
const Version = "1.2"
type Server struct {
cfg config.Config
@@ -147,9 +147,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
// Der Zettel — nur für Angemeldete, in beide Richtungen. Er steht bewusst
// Die Notiz — nur für Angemeldete, in beide Richtungen. Sie steht bewusst
// nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Zettel gehen
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Notizen gehen
// niemanden etwas an.
mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote))
mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote))
+133 -38
View File
@@ -490,6 +490,62 @@ body {
margin-bottom: 6px;
}
/* ── Einstellungen: Abschnitte statt einer langen Spalte ───────────────────
Beschriftung links, Schalter rechts. Bei zwölf Einstellungen untereinander
sucht man sonst; mit Überschriften weiß man, wo man ist. */
.sek { margin-bottom: 18px; }
.sek + .sek { border-top: 1px solid var(--p-faint); padding-top: 14px; }
.sek-titel {
margin: 0 0 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--p);
}
.sek-hinweis { margin: 8px 0 0; color: var(--p-dim); font-size: 11px; line-height: 1.4; }
.reihe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 7px;
}
.reihe .r-label { font-size: 12.5px; color: var(--p-mid); }
.reihe .seg { justify-content: flex-end; }
@media (max-width: 460px) {
.reihe { flex-direction: column; align-items: stretch; gap: 4px; }
.reihe .seg { justify-content: flex-start; }
}
/* Die Widget-Liste: an/aus, Name, Pfeile — dieselben Pfeile wie bei den Apps. */
.wl { list-style: none; margin: 0; padding: 0; }
.wl-zeile {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--p-faint);
padding: 5px 6px;
margin-bottom: 4px;
}
.wl-zeile.wl-aus .wl-name { color: var(--p-dim); }
.wl-an {
background: var(--p);
border: 1px solid var(--p);
color: var(--bg);
font: inherit;
font-size: 11px;
text-shadow: none;
padding: 2px 8px;
cursor: pointer;
flex: none;
width: 44px;
}
.wl-an[aria-pressed="false"] { background: none; border-color: var(--p-dim); color: var(--p-dim); text-shadow: inherit; }
.wl-name { flex: 1; min-width: 0; font-size: 13px; color: var(--p-mid); }
.wl-name em { font-style: normal; color: var(--p-dim); font-size: 11px; margin-left: 6px; }
.wl .ae-move { margin-left: 0; }
.seg { display: flex; flex-wrap: wrap; gap: 6px; }
.seg button {
background: none;
@@ -785,15 +841,9 @@ body {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
}
/* Abmelden steht unten in den Einstellungen, abgesetzt vom Rest: Es ist die
/* Abmelden steht unten in den Einstellungen, im letzten Abschnitt: Es ist die
einzige Schaltfläche dort, die nicht etwas einstellt, sondern etwas beendet. */
.field-out {
border-top: 1px solid var(--p-faint);
padding-top: 12px;
margin-top: 4px;
margin-bottom: 0;
}
.btn-out { width: 100%; color: var(--p-mid); }
.btn-out { width: 100%; color: var(--p-mid); margin-top: 10px; }
.btn-out:hover { color: var(--bg); background: var(--p); }
.startmenu .sm-head {
padding: 4px 8px;
@@ -1115,16 +1165,17 @@ body {
.w-zustand { color: var(--p-dim); margin: 0; font-size: 12.5px; }
.w-fehler { color: var(--p-mid); }
/* ── Zettel ────────────────────────────────────────────────────────────────
* Gelesen wird gesetzt, geschrieben wird roh. Beide Ansichten sind gleich
* hoch, damit der Kasten beim Umschalten nicht springt. */
.zettel-text,
.zettel-feld {
/* ── Notiz ─────────────────────────────────────────────────────────────────
* Gesetzt ist der Normalfall — roh ist nur die Zeile, in der man steht (und im
* Quelltext-Modus das ganze Feld). Beide sind gleich hoch, damit der Kasten
* beim Umschalten nicht springt. */
.notiz-text,
.notiz-feld {
min-height: 112px;
max-height: 320px;
overflow-y: auto;
}
.zettel-feld {
.notiz-feld {
display: block;
width: 100%;
background: var(--bg);
@@ -1136,44 +1187,44 @@ body {
padding: 8px;
resize: vertical;
}
.zettel-feld:focus { outline: none; border-color: var(--p); }
.notiz-feld:focus { outline: none; border-color: var(--p); }
.zettel-text { font-size: 13px; line-height: 1.45; color: var(--p-mid); }
.zettel-text h3,
.zettel-text h4,
.zettel-text h5 {
.notiz-text { font-size: 13px; line-height: 1.45; color: var(--p-mid); }
.notiz-text h3,
.notiz-text h4,
.notiz-text h5 {
margin: 10px 0 4px;
color: var(--p);
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.zettel-text h3 { font-size: 13px; }
.zettel-text h4 { font-size: 12px; }
.zettel-text h5 { font-size: 11px; color: var(--p-mid); }
.zettel-text > :first-child { margin-top: 0; }
.zettel-text p { margin: 0 0 6px; }
.zettel-text ul,
.zettel-text ol { margin: 0 0 6px; padding-left: 18px; }
.zettel-text li { margin: 1px 0; }
.zettel-text code {
.notiz-text h3 { font-size: 13px; }
.notiz-text h4 { font-size: 12px; }
.notiz-text h5 { font-size: 11px; color: var(--p-mid); }
.notiz-text > :first-child { margin-top: 0; }
.notiz-text p { margin: 0 0 6px; }
.notiz-text ul,
.notiz-text ol { margin: 0 0 6px; padding-left: 18px; }
.notiz-text li { margin: 1px 0; }
.notiz-text code {
background: var(--p-faint);
padding: 0 3px;
color: var(--p);
}
.zettel-text b { color: var(--p); font-weight: 400; }
.zettel-text hr { border: 0; border-top: 1px solid var(--p-faint); margin: 8px 0; }
.notiz-text b { color: var(--p); font-weight: 400; }
.notiz-text hr { border: 0; border-top: 1px solid var(--p-faint); margin: 8px 0; }
/* Häkchen: anklickbar, ohne in den Text zu springen. Die Aufzählungspunkte
fallen weg — das Kästchen ist der Punkt. */
.zettel-text .zettel-hak { list-style: none; margin-left: -18px; }
.zettel-text .zettel-hak label {
.notiz-text .notiz-hak { list-style: none; margin-left: -18px; }
.notiz-text .notiz-hak label {
display: flex;
align-items: baseline;
gap: 7px;
cursor: pointer;
}
.zettel-text .zettel-hak input {
.notiz-text .notiz-hak input {
appearance: none;
flex: none;
width: 11px;
@@ -1184,18 +1235,62 @@ body {
position: relative;
top: 1px;
}
.zettel-text .zettel-hak input:checked { background: var(--p); border-color: var(--p); }
.zettel-text .zettel-hak.ab span { color: var(--p-dim); text-decoration: line-through; }
.notiz-text .notiz-hak input:checked { background: var(--p); border-color: var(--p); }
.notiz-text .notiz-hak.ab span { color: var(--p-dim); text-decoration: line-through; }
.notiz-text i { color: var(--p-mid); font-style: italic; }
.zettel-fuss {
/* Die Zeile, in der die Schreibmarke steht: als Feld, aber ohne wie eines
auszusehen. Sie soll an der Stelle sitzen, an der eben noch der gesetzte
Text stand — gleiche Schrift, gleiche Höhe, nur ein Streifen links zeigt,
dass hier gerade der Quelltext steht. */
.notiz-zeile {
display: block;
width: 100%;
box-sizing: border-box;
background: var(--p-faint);
border: 0;
border-left: 2px solid var(--p);
color: var(--p);
font: inherit;
font-size: 13px;
line-height: 1.45;
padding: 1px 4px 1px 6px;
margin: 1px 0;
resize: none;
overflow: hidden;
}
.notiz-zeile:focus { outline: none; }
.notiz-rohli { list-style: none; margin-left: -18px; }
.notiz-leer { cursor: text; }
/* Werkzeugleiste: klein, einreihig, immer da. Sie wirkt auf die Zeile, in der
man steht — deshalb steht sie oben und nicht neben dem Knopf zum Umschalten. */
.notiz-werkzeuge { display: flex; gap: 4px; margin-bottom: 6px; }
.notiz-wz {
background: none;
border: 1px solid var(--p-faint);
color: var(--p-mid);
font: inherit;
font-size: 12px;
line-height: 1;
width: 24px;
height: 22px;
cursor: pointer;
padding: 0;
}
.notiz-wz:hover:not(:disabled) { border-color: var(--p); color: var(--p); }
.notiz-wz:disabled { opacity: 0.35; cursor: default; }
.nw-fett { font-weight: 700; }
.w-fuss {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.zettel-stand { color: var(--p-dim); font-size: 11px; }
.zettel-fuss .btn { font-size: 12px; padding: 3px 9px; }
.w-stand { color: var(--p-dim); font-size: 11px; }
.w-fuss .btn { font-size: 12px; padding: 3px 9px; }
/* ── Bild ──────────────────────────────────────────────────────────────────
* Fester Rahmen, das Bild füllt ihn. Verzerren wäre die Alternative — und
+5 -4
View File
@@ -16,14 +16,15 @@ export const DEFAULT_SETTINGS = {
openIn: "tab", // tab | same
// Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer.
barGlyphs: "on",
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
// Wetter in der HoneyBar. Auf dem Schreibtisch entscheidet das die Widget-
// Liste — zwei Schalter für dasselbe Widget wären einer zu viel.
weatherBar: "off",
weatherDesk: "off",
weatherCity: "",
weatherLat: "",
weatherLon: "",
// Reihenfolge und An/Aus der Widgets, als eine Liste: "benutzer,-wetter,notiz".
// Leer heißt: Grundstellung (siehe widgets/rahmen.js).
widgets: "",
};
export const state = {
+107 -58
View File
@@ -4,7 +4,9 @@ import {
SS_BOOTED, state,
} from "./core.js";
import { GLYPHS, glyphSvg } from "./glyphs.js";
import { widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen } from "./widgets/rahmen.js";
import {
widgetPlan, widgetPlanSetzen, widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen,
} from "./widgets/rahmen.js";
import "./widgets/user.js";
import { statsHtml } from "./widgets/system.js";
import { loadWeather, renderWeatherBar } from "./widgets/weather.js";
@@ -551,75 +553,66 @@ function closeModal() {
function openSettings() {
const s = state.settings;
/* Ein Feld je Zeile, Beschriftung links, Schalter rechts — und die Zeilen in
* Abschnitten. Vorher stand alles untereinander in einer einzigen Spalte,
* mit Abstandshaltern zwischen den Schaltern; bei zwölf Einstellungen sieht
* das aus wie eine Liste, in der man suchen muss. */
const seg = (name, opts, cur) =>
`<div class="seg" data-seg="${name}">` +
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
`</div>`;
const zeile = (label, rechts) => `<div class="reihe"><span class="r-label">${label}</span>${rechts}</div>`;
const schalter = (name, label, anLabel = "an") =>
zeile(label, seg(name, [["on", anLabel], ["off", "aus"]], s[name]));
const sektion = (titel, inhalt) => `<section class="sek"><h4 class="sek-titel">${titel}</h4>${inhalt}</section>`;
openPanel(
"settings",
"einstellungen",
`
<div class="field">
<span class="label">Betriebsart</span>
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
</div>
<div class="field">
<span class="label">Röhre</span>
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
</div>
<div class="field">
<span class="label">Bildröhren-Effekte</span>
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
<div style="height:6px"></div>
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
<div style="height:6px"></div>
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
</div>
<div class="field">
<span class="label">HoneyBar</span>
${seg("barGlyphs", [["on", "symbole an"], ["off", "nur namen"]], s.barGlyphs)}
<p class="ae-hint" style="margin-left:0">gilt für den App-Streifen hier und in jeder App</p>
</div>
<div class="field">
<span class="label">Apps öffnen</span>
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
</div>
<div class="field">
<span class="label">Wetter</span>
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
<div style="height:6px"></div>
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
<div class="wx-city-row">
sektion(
"Darstellung",
zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
schalter("scanlines", "Scanlines") +
schalter("flicker", "Flackern") +
schalter("glow", "Glühen")
) +
sektion(
"HoneyBar",
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
schalter("weatherBar", "Wetter in der Leiste") +
`<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
) +
sektion(
"Widgets",
`<ul class="wl" id="wlListe"></ul>
<p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
) +
sektion(
"Wetter",
`<div class="wx-city-row">
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
<button class="btn" id="btnWxSearch">suchen</button>
</div>
<div id="wxResults" class="wx-results"></div>
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p>
</div>
<div class="field">
<span class="label">Programme</span>
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
</div>
<p class="login-note" style="margin-top:4px">
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
</p>` +
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus einer
// fremden App heraus nicht anklicken können soll (siehe honeybar.js), und
// von HoneyOS aus findet man es dort, wo alles andere auch steht.
(state.oidc
? `<div class="field field-out">
<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>
</div>`
: ""),
<p class="sek-hinweis" id="wxCurrent"></p>`
) +
sektion(
"Programme",
zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
) +
sektion(
"Konto",
`<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
// einer fremden App heraus nicht anklicken können soll (siehe
// honeybar.js), und von HoneyOS aus findet man es dort, wo alles
// andere auch steht.
(state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
),
(root) => {
root.querySelectorAll(".seg").forEach((segEl) => {
segEl.addEventListener("click", (e) => {
@@ -644,6 +637,8 @@ function openSettings() {
});
});
widgetListeZeichnen(root.querySelector("#wlListe"));
const zeigeOrt = () => {
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
? `eingestellt: ${state.settings.weatherCity}`
@@ -698,6 +693,60 @@ function openSettings() {
);
}
/* ── Die Widget-Liste in den Einstellungen ─────────────────────────────────
*
* Dieselbe Bedienung wie bei den Apps: Pfeile statt Ziehen. Ziehen wäre hübsch,
* aber am Handy ist es fummelig, und die Liste ist kurz.
*
* Der Rahmen kennt die Widgets, nicht diese Datei — hier wird nur gezeigt, was
* er hergibt, und zurückgegeben, was der Nutzer daraus gemacht hat.
*/
function widgetListeZeichnen(ul) {
if (!ul) return;
const plan = widgetPlan();
ul.innerHTML = plan
.map(
(p, i) => `
<li class="wl-zeile${p.an ? "" : " wl-aus"}">
<button type="button" class="wl-an" data-an="${i}" aria-pressed="${p.an}">${p.an ? "an" : "aus"}</button>
<span class="wl-name">${escapeHtml(p.titel)}${
p.wo && p.wo.length === 1 ? `<em>nur ${p.wo[0]}</em>` : ""
}</span>
<span class="ae-move">
<button type="button" class="ae-arrow" data-hoch="${i}" ${i === 0 ? "disabled" : ""}>&#9650;</button>
<button type="button" class="ae-arrow" data-runter="${i}" ${
i === plan.length - 1 ? "disabled" : ""
}>&#9660;</button>
</span>
</li>`
)
.join("");
const uebernehmen = () => {
widgetPlanSetzen(plan);
widgetListeZeichnen(ul);
};
ul.querySelectorAll("[data-an]").forEach((b) =>
b.addEventListener("click", () => {
const p = plan[Number(b.dataset.an)];
p.an = !p.an;
uebernehmen();
})
);
const tausche = (i, j) => {
[plan[i], plan[j]] = [plan[j], plan[i]];
uebernehmen();
};
ul.querySelectorAll("[data-hoch]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.hoch), Number(b.dataset.hoch) - 1))
);
ul.querySelectorAll("[data-runter]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.runter), Number(b.dataset.runter) + 1))
);
}
/* ── App-Verwaltung ────────────────────────────────────────────────────────
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
+3 -3
View File
@@ -48,8 +48,8 @@ widgetAnmelden({
</div>`
: ""
}
<div class="zettel-fuss">
<span class="zettel-stand">${escapeHtml(stand)}</span>
<div class="w-fuss">
<span class="w-stand">${escapeHtml(stand)}</span>
<span class="bildw-knoepfe">
<input class="bildw-datei" type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
<button class="btn bildw-waehlen">${quelle ? "ersetzen" : "hochladen"}</button>
@@ -122,7 +122,7 @@ async function linkSetzen(adresse) {
function melden(s) {
stand = s;
document.querySelectorAll('[data-widget="bild"] .zettel-stand').forEach((el) => {
document.querySelectorAll('[data-widget="bild"] .w-stand').forEach((el) => {
el.textContent = s;
});
}
+357 -69
View File
@@ -1,33 +1,57 @@
import { api, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
/* ── Der Zettel ────────────────────────────────────────────────────────────
/* ── Die Notiz ─────────────────────────────────────────────────────────────
*
* 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.
* 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.
*
* 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.
* SOFORT-ANSICHT — warum zeilenweise?
*
* 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.
* 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 bearbeiten = false;
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: "zettel",
// Der Zettel hält ein Textfeld: Sein Inhalt darf nicht bei jedem Neuzeichnen
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,
@@ -37,54 +61,72 @@ widgetAnmelden({
},
inhalt() {
if (bearbeiten) {
if (roh) {
return `
<textarea class="zettel-feld" spellcheck="false"
${werkzeuge()}
<textarea class="notiz-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>`;
${fuss("fertig")}`;
}
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>`;
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>`;
return `${werkzeuge()}${koerper}${fuss("quelltext")}`;
},
montiert(el) {
const feld = el.querySelector(".zettel-feld");
if (feld) {
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();
// 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();
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));
}
// Häkchen im gesetzten Text — sie ändern die Quelle, nicht nur die Anzeige.
el.querySelectorAll("[data-hak]").forEach((box) =>
// 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();
@@ -94,13 +136,229 @@ widgetAnmelden({
},
});
/* ── 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 der Zettel
* gar nicht kennt, wäre schlimmer als keine.
*/
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}" ${
roh ? "disabled" : ""
}>${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() {
const koerper = document.querySelectorAll('[data-widget="notiz"]');
koerper.forEach((el) => delete el.dataset.zustand);
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);
@@ -110,8 +368,8 @@ function spaeterSpeichern() {
/**
* 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
* 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;
@@ -131,7 +389,7 @@ async function jetztSpeichern() {
function standSetzen(s) {
stand = s;
document.querySelectorAll('[data-widget="notiz"] .zettel-stand').forEach((el) => {
document.querySelectorAll('[data-widget="notiz"] .notiz-stand').forEach((el) => {
el.textContent = s;
});
}
@@ -142,15 +400,27 @@ function standSetzen(s) {
* 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.
* **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) {
const zeilen = quelle.split("\n");
export function markdown(quelle, bearbeitet = -1) {
const zln = quelle.split("\n");
let html = "";
let liste = null; // "ul" | "ol" | null
let hakenNr = -1; // fortlaufend über den ganzen Text, für data-hak
// 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}>`;
@@ -164,8 +434,23 @@ export function markdown(quelle) {
}
};
zeilen.forEach((roh) => {
const z = roh.trimEnd();
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();
@@ -175,51 +460,54 @@ export function markdown(quelle) {
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}>`;
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 />";
html += `<hr data-z="${i}" />`;
return;
}
const hak = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/.exec(z);
const hak = HAKEN.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" : ""} />` +
`<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 = /^\s*[-*]\s+(.*)$/.exec(z);
const punkt = PUNKT.exec(z);
if (punkt) {
listeAuf("ul");
html += `<li>${inline(punkt[1])}</li>`;
html += `<li data-z="${i}">${inline(punkt[1])}</li>`;
return;
}
const zahl = /^\s*\d+[.)]\s+(.*)$/.exec(z);
const zahl = ZAHL.exec(z);
if (zahl) {
listeAuf("ol");
html += `<li>${inline(zahl[1])}</li>`;
html += `<li data-z="${i}">${inline(zahl[1])}</li>`;
return;
}
listeZu();
html += `<p>${inline(z)}</p>`;
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>")
+68 -7
View File
@@ -28,12 +28,15 @@
* als Aufklappfeld in der HoneyBar leben können — deshalb erzeugt `inhalt()`
* nur den Kern, und die Verpackung entsteht hier.
*/
import { $, state } from "../core.js";
import { $, saveSettings, state } from "../core.js";
const REGISTER = [];
/** id → "laedt" | "fertig" | "leer" | "fehler" */
const zustand = {};
const uhren = {};
/** Was schon einmal geladen wurde — ein Widget, das erst später eingeschaltet
* wird, holt seine Daten dann nach. */
const geladen = new Set();
export function widgetAnmelden(def) {
REGISTER.push(def);
@@ -55,9 +58,9 @@ export function widgetListe() {
* anderen schon durchlaufen. Der Systemzustand braucht keine Symbole und stand
* deshalb vor dem Benutzer, obwohl er darunter importiert wird.
*
* Hier ist die Reihenfolge damit eine Angabe statt einer Nebenwirkung — und
* genau die Stelle, an der später die im Server gespeicherte Reihenfolge
* einhaken kann.
* Hier ist die Reihenfolge damit eine Angabe statt einer Nebenwirkung. Sie ist
* die **Grundstellung**: Was der Nutzer selbst sortiert hat, steht in seinen
* Einstellungen und schlägt sie (siehe widgetPlan).
*/
export function widgetsOrdnen(ids) {
const platz = (w) => {
@@ -67,6 +70,53 @@ export function widgetsOrdnen(ids) {
REGISTER.sort((a, b) => platz(a) - platz(b));
}
/* ── Was der Nutzer eingestellt hat ────────────────────────────────────────
*
* Reihenfolge und An/Aus stehen in **einer** Einstellung, als Liste von IDs mit
* einem Minus vor dem, was aus ist:
*
* widgets = "benutzer,system,-wetter,notiz,bild"
*
* Ein Feld statt zwei, weil beides zusammen gehört: Wer ein Widget ausschaltet,
* soll seinen Platz behalten, wenn er es wieder einschaltet. Und weil die
* Einstellungen ohnehin schon je Nutzer auf dem Server liegen, braucht es dafür
* weder eine neue Tabelle noch einen neuen Endpunkt.
*
* Die gespeicherte Liste ist ein Wunsch, kein Gesetz: Unbekannte IDs fallen
* weg (ein Widget kann verschwinden), neue hängen sich hinten an und sind an.
*/
const AUS = "-";
/** Die Widgets in der Reihenfolge des Nutzers: [{id, titel, wo, an}]. */
export function widgetPlan() {
const aus = new Set();
const reihe = [];
String(state.settings.widgets || "")
.split(",")
.map((e) => e.trim())
.filter(Boolean)
.forEach((e) => {
const id = e.startsWith(AUS) ? e.slice(1) : e;
if (e.startsWith(AUS)) aus.add(id);
if (!reihe.includes(id) && REGISTER.some((w) => w.id === id)) reihe.push(id);
});
REGISTER.forEach((w) => reihe.includes(w.id) || reihe.push(w.id));
return reihe.map((id) => {
const w = REGISTER.find((x) => x.id === id);
return { id, titel: w.titel, wo: w.wo, an: !aus.has(id) };
});
}
/** Übernimmt einen geänderten Plan: speichern, nachladen, neu zeichnen. */
export function widgetPlanSetzen(plan) {
state.settings.widgets = plan.map((p) => (p.an ? "" : AUS) + p.id).join(",");
saveSettings();
widgetsZeichnen();
// Ein gerade erst eingeschaltetes Widget hat seine Daten noch nie geholt.
widgetsLaden();
}
/**
* Lädt ein Widget (oder alle) und zeichnet neu.
*
@@ -75,9 +125,15 @@ export function widgetsOrdnen(ids) {
* anderen Widgets mitreißen.
*/
export async function widgetsLaden(nurId) {
const dran = REGISTER.filter((w) => w.laden && (!nurId || w.id === nurId));
const an = new Set(widgetPlan().filter((p) => p.an).map((p) => p.id));
const dran = REGISTER.filter((w) => {
if (!w.laden) return false;
if (nurId) return w.id === nurId;
return an.has(w.id) && !geladen.has(w.id);
});
await Promise.all(
dran.map(async (w) => {
geladen.add(w.id);
zustand[w.id] = "laedt";
widgetsZeichnen();
try {
@@ -94,7 +150,9 @@ export async function widgetsLaden(nurId) {
export function widgetsStarten() {
REGISTER.forEach((w) => {
if (!w.takt || uhren[w.id]) return;
uhren[w.id] = setInterval(() => widgetsLaden(w.id), w.takt);
uhren[w.id] = setInterval(() => {
if (widgetPlan().find((p) => p.id === w.id)?.an) widgetsLaden(w.id);
}, w.takt);
});
}
@@ -175,7 +233,10 @@ export function widgetsZeichnen() {
const schilder = $("deskwidgets");
if (!spalte || !schilder) return;
const dran = REGISTER.filter((w) => {
const dran = widgetPlan()
.filter((p) => p.an)
.map((p) => REGISTER.find((w) => w.id === p.id))
.filter((w) => {
if (w.wo && !w.wo.includes(modus)) return false;
if (w.sichtbar && !w.sichtbar()) return false;
// „nichts da" heißt bei manchen Widgets: gar nicht erst hinstellen. Eine
-2
View File
@@ -11,8 +11,6 @@ import { widgetAnmelden, widgetsLaden } from "./rahmen.js";
widgetAnmelden({
id: "wetter",
titel: "wetter",
// Der eigene Schalter — unabhängig von dem für die HoneyBar.
sichtbar: () => state.settings.weatherDesk === "on",
leer: "verbergen",
takt: 10 * 60 * 1000, // derselbe Takt, in dem der Server seinen Speicher erneuert
+7 -7
View File
@@ -130,10 +130,10 @@ func TestHoneyBarOhneBacktickInKommentaren(t *testing.T) {
}
}
// Der Zettel ist ein Zettel, kein Dokument: Die Grenze muss der Server ziehen,
// Die Notiz ist eine Notiz, kein Dokument: Die Grenze muss der Server ziehen,
// nicht das Formular — sonst schreibt sie jeder Aufruf aus, der das Formular
// umgeht.
func TestZettelHatEineGrenze(t *testing.T) {
func TestNotizHatEineGrenze(t *testing.T) {
s := newTestServer(t, "")
h := s.routes()
@@ -146,10 +146,10 @@ func TestZettelHatEineGrenze(t *testing.T) {
}
if code := put("# Einkauf\n- [ ] Milch"); code != http.StatusNoContent {
t.Fatalf("kurzer Zettel: %d, erwartet 204", code)
t.Fatalf("kurze Notiz: %d, erwartet 204", code)
}
if code := put(strings.Repeat("x", 4001)); code != http.StatusBadRequest {
t.Errorf("zu langer Zettel: %d, erwartet 400", code)
t.Errorf("zu lange Notiz: %d, erwartet 400", code)
}
rec := httptest.NewRecorder()
@@ -157,13 +157,13 @@ func TestZettelHatEineGrenze(t *testing.T) {
var out map[string]string
json.Unmarshal(rec.Body.Bytes(), &out)
if out["text"] != "# Einkauf\n- [ ] Milch" {
t.Errorf("zurück kam %q — der zu lange Zettel darf den guten nicht überschrieben haben", out["text"])
t.Errorf("zurück kam %q — die zu lange Notiz darf die gute nicht überschrieben haben", out["text"])
}
}
// Fremde Zettel gehen niemanden etwas an: Der Schlüssel für die App-Liste darf
// Fremde Notizen gehen niemanden etwas an: Der Schlüssel für die App-Liste darf
// sie nicht öffnen, auch nicht lesend.
func TestZettelNichtUeberDenSchluessel(t *testing.T) {
func TestNotizNichtUeberDenSchluessel(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
s.cfg.AppsKey = "geheim123"