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
+806 -220

No files matched your search

+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 = {
+111 -62
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">
<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>`
: ""),
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="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>")
+74 -13
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,13 +233,16 @@ export function widgetsZeichnen() {
const schilder = $("deskwidgets");
if (!spalte || !schilder) return;
const dran = REGISTER.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
// leere Wetterkachel wäre schlechter als keine.
return !(zustand[w.id] === "leer" && w.leer === "verbergen");
});
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
// leere Wetterkachel wäre schlechter als keine.
return !(zustand[w.id] === "leer" && w.leer === "verbergen");
});
baue(spalte, desktop ? [] : dran, true);
baue(schilder, desktop ? dran : [], false);
-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