Lay the notes screen out like a notes app

The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-21 18:22:47 +02:00
parent 5db098b67f
commit 48c6cf39d5
9 changed files with 478 additions and 190 deletions

View File

@@ -637,7 +637,7 @@ function snippetAround(text: string, word: string): string {
const at = lines.findIndex((line) => fold(line).includes(word));
const preview = lines
.slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2)
.map(plainLine)
.map(plainText)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
@@ -647,24 +647,40 @@ function snippetAround(text: string, word: string): string {
/**
* One line of Markdown as the words it holds.
*
* A snippet is read, not parsed: `| 1NF | atomare Werte |` says more as
* "1NF · atomare Werte", and a row of `**` in a preview is noise.
* Wherever a note is *shown* rather than edited — a search snippet, a row in
* the app's note list — this is what it goes through. A line is read there,
* not parsed: `| 1NF | atomare Werte |` says more as "1NF · atomare Werte",
* and `_Fazit_` says exactly as much as "Fazit" while looking like a mistake.
*/
function plainLine(line: string): string {
export function plainText(line: string): string {
let value = line.trim();
if (/^\|.*\|$/.test(value)) {
// A table row, including the `|---|---|` rule, which says nothing at all.
if (/^\|[\s:|-]*\|$/.test(value)) return '';
value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · ');
}
return value
.replace(/^#{1,6}\s+/, '')
.replace(/^>\s?/, '')
.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, '')
.replace(/^\d{1,9}[.)]\s+/, '')
.replace(/(\*\*|__|~~)/g, '')
.replace(/`+/g, '')
.trim();
// One leading marker, not all of them in turn: a heading reading
// `## 1. Deutsch` keeps its lesson number, which the list rule would
// otherwise take for a bullet and eat.
for (const marker of [/^#{1,6}\s+/, /^>\s?/, /^[-*+]\s+(\[[ xX]\]\s+)?/, /^\d{1,9}[.)]\s+/]) {
if (marker.test(value)) {
value = value.replace(marker, '');
break;
}
}
return (
value
.replace(/(\*\*|__|~~)/g, '')
// Single markers only where they are emphasis, so snake_case survives.
.replace(/(?<![\w*])\*([^*]+)\*(?![\w*])/g, '$1')
.replace(/(?<![\w_])_([^_]+)_(?![\w_])/g, '$1')
.replace(/`+/g, '')
// A link reads as its label; the target is not for a preview.
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\\([\\`*_[\]#>~|+.()-])/g, '$1')
.trim()
);
}
// --- helpers -------------------------------------------------------------