Compare commits

..
3 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 f6c60bdaf9 fix(notes): #garageristeriet og #GarageRisteriet er samme projekt
Release / Build and publish image (push) Failing after 9m58s
De var det ikke. Nøglen blev gemt præcis som skrevet og slået op på samme måde,
så en note, der nævnte et projekt med lille begyndelsesbogstav, dukkede simpelthen
ikke op på det — stille, og uden noget der antydede hvorfor.

Foldet på vej ind og foldet i opslaget, så sammenligningen bliver ved med at være
en nøjagtig lighed og bruger sit indeks. Havde man i stedet sænket kolonnen i
forespørgslen, skulle hele tabellen læses.

Kun projekter. En [[notetitel]] er en titel, ikke en nøgle: to noter, der hedder
"Møde" og "møde", er to noter, og at folde dem sammen ville slå noget sammen,
nogen havde holdt adskilt. Der er en prøve for begge dele.

Migrationen samler de rækker, der allerede er skrevet. Primærnøglen er
(note_id, kind, target_id), så en note, der skrev begge stavemåder, ville kollidere
med sig selv — derfor bygges sættet forfra med INSERT OR IGNORE frem for en
opdatering, der ville fejle på parret.

Fladen viser projektets egen stavemåde og ikke den foldede nøgle. En etiket, ingen
har skrevet, og som står hverken i noten eller på projektet, ville være værre end
ingen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:48:00 +02:00
Kristian WindandClaude Opus 5 5e670119db feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik
nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som
"fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser
tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af
teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra
både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte,
den ikke kan holde.

Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren
står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det
under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens
den er åben, som enhver anden fuldførelse.

Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B,
⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i
programmet, hvor det, man skriver, bliver læst for betydning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:36:31 +02:00
Kristian WindandClaude Opus 5 3223989a9d feat(notes): første linje er titlen, resten er brødtekst
Release / Build and publish image (push) Failing after 9m57s
En ny note åbner på sin titel, og linjen efter er brødtekst — uden at man vælger
noget. Det er det, Apple Notes gør, og det er også hvad listen har brug for:
første linje bliver notens navn, så en note, der begynder i brødtekst, hedder sin
egen første sætning.

To ting undervejs. Reserven for en tom note blev aldrig brugt, fordi en tom tekst
ikke gengives som ingenting men som ét tomt afsnit — den prøves nu på teksten og
ikke på resultatet. Og Chromium svarer på retur med et bart <div>, som læses som
brødtekst men ikke er formateret som det: afstanden mellem afsnit forsvandt
stille i det øjeblik, man trykkede retur frem for at vælge Brødtekst i menuen.

Listen viste desuden den rå Markdown i forhåndsvisningen — "# Møde med Anders" og
"**fed**" — hvilket er præcis det, editoren blev bygget for at holde op med at
vise. Den viser nu sætningen frem for notationen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:23:42 +02:00
11 changed files with 538 additions and 34 deletions
+2 -3
View File
@@ -3308,7 +3308,7 @@ components:
properties:
id: { type: string }
project_id: { type: string }
title: { type: string, description: "Taken from the first line when not given." }
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
body: { type: string, description: Markdown }
pinned: { type: boolean }
created_at: { type: string, format: date-time }
@@ -3326,8 +3326,7 @@ components:
type: object
properties:
project_id: { type: string, description: "Empty files it under no project." }
title: { type: string }
body: { type: string }
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Mailbox:
+2 -10
View File
@@ -530,8 +530,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
"pinned": mcp.Bool("Keep it at the top of the list."),
}),
@@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
"removed.",
InputSchema: mcp.Schema(map[string]any{
"note_id": mcp.Str("Which note."),
"body": mcp.Str("The whole new text, as Markdown."),
"title": mcp.Str("A new title."),
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
"pinned": mcp.Bool("Pin it or unpin it."),
}),
}, s.mcpUpdateNote)
@@ -604,7 +602,6 @@ func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) b
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Body string `json:"body"`
Title string `json:"title"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
@@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw
n := &store.Note{
CreatedBy: userID,
Title: params.Title,
Body: params.Body,
Pinned: params.Pinned,
}
@@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
var params struct {
NoteID string `json:"note_id"`
Body *string `json:"body"`
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
@@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
if params.Body != nil {
n.Body = *params.Body
}
if params.Title != nil {
n.Title = *params.Title
}
if params.Pinned != nil {
n.Pinned = *params.Pinned
}
+2 -7
View File
@@ -97,9 +97,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, n)
}
// No title. It is the note's first line and is derived on every save — a field
// here would be a promise this server cannot keep.
type noteBody struct {
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
@@ -119,9 +120,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
@@ -164,9 +162,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
if body.Title != nil {
n.Title = *body.Title
}
if body.Body != nil {
n.Body = *body.Body
}
@@ -0,0 +1,29 @@
-- #garageristeriet and #GarageRisteriet are the same project.
--
-- They were not, because the key was stored exactly as typed and looked up the
-- same way — so a note that named a project in lower case simply did not appear
-- on it, silently and with nothing to suggest why.
--
-- Folded on the way in from now on; this brings the rows already written into
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
-- and "møde" are two notes.
--
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
CREATE TABLE note_links_folded (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
target_id TEXT NOT NULL,
PRIMARY KEY (note_id, kind, target_id)
);
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
SELECT note_id, kind,
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
FROM note_links;
DROP TABLE note_links;
ALTER TABLE note_links_folded RENAME TO note_links;
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
+17 -6
View File
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
}
n.UpdatedAt = now
// A note with no title takes its first line, the way Apple Notes does. A list
// of untitled notes is a list nobody can read.
if strings.TrimSpace(n.Title) == "" {
n.Title = firstLine(n.Body)
}
// The title is always the first line, the way Apple Notes does it. Not "when it
// is empty": derived once and then left alone, it goes stale the moment somebody
// rewrites the opening of a note, and the list ends up calling a note by a name
// that is nowhere in it. The column is a cache of the body, not a field beside
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
add("task", strings.ToLower(m[1]))
}
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
add("project", m[1])
// Folded, because #garageristeriet and #GarageRisteriet are the same project
// to everybody except a database. The key is stored folded and looked up
// folded, so the comparison stays an exact match and keeps using its index —
// a comparison that lowered the column instead would read the whole table.
//
// The project's real spelling is not lost: it is on the project, which is
// what the interface shows. This column is a key, not a label.
add("project", strings.ToLower(m[1]))
}
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
add("note", strings.TrimSpace(m[1]))
@@ -209,6 +217,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
// The reason note_links exists. Without it this would mean reading every note's
// prose, and a panel that slow is one nobody leaves open.
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
if kind == "project" {
targetID = strings.ToLower(targetID)
}
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
+92 -4
View File
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
if len(projects) != 2 {
t.Fatalf("read %v out of the note, want the two projects", n.Links)
}
if projects[0] != "Firma" || projects[1] != "Regnskab" {
// Folded: the key is a key, not a label. #firma and #Firma are the same project
// to everybody except a database, and the project's own spelling is on the
// project, which is what the interface shows.
if projects[0] != "firma" || projects[1] != "regnskab" {
t.Errorf("got %v", projects)
}
}
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// The title is the first line now, so that is where it goes.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Title: "Grønt regnskab",
Body: "Målt på årsbasis for Århus.",
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
}); err != nil {
t.Fatal(err)
}
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
{"the same tag twice is one link", "#Firma og #Firma igen",
[]NoteLink{{"project", "Firma"}}},
[]NoteLink{{"project", "firma"}}},
{"two spellings are one tag", "#Firma og #firma",
[]NoteLink{{"project", "firma"}}},
{"a heading is not a tag", "# Overskrift", nil},
} {
t.Run(c.name, func(t *testing.T) {
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
t.Errorf("after deleting one, the count is %d, want 1", n)
}
}
// The title follows the first line, always — not only the first time.
//
// Derived once and then left alone, it goes stale the moment somebody rewrites
// the opening, and the list ends up calling a note by a name that is nowhere in
// it. That is exactly what happened: a note headed "Ny note" was listed as
// "fdgfgfgh", which was what its first line had been some edits ago.
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Første udgave" {
t.Fatalf("title started as %q", n.Title)
}
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("after rewriting the first line the title is %q", n.Title)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back.Title != "Ny note" {
t.Errorf("the stored title is %q", back.Title)
}
// And a title cannot be set beside the text, because then the two can disagree.
n.Title = "noget andet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("a title set by hand survived as %q", n.Title)
}
}
// A note that names a project in the other case must still turn up on it.
//
// It did not, and nothing said why: the key was stored exactly as typed and
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
// different things that looked identical to a person.
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Bønnerne kommer fredag #garageristeriet",
}); err != nil {
t.Fatal(err)
}
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
found, err := db.NotesLinking(ctx, "project", spelling)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
}
}
// A note title is a title, not a key: two notes called "Møde" and "møde" are
// two notes, and folding them together would merge things somebody kept apart.
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
t.Fatal(err)
}
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
t.Error("a note title was folded")
}
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
t.Error("a note title did not match itself")
}
}
+122
View File
@@ -2089,3 +2089,125 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Straight in, without choosing a style. The first line is the title because
// that is what the list calls the note by.
await page.keyboard.type('Titelprøven');
await expect(ed.locator('h1')).toHaveText('Titelprøven');
await page.keyboard.press('Enter');
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// And the line after is body, not a second title. A note where every line is a
// heading is a note with no heading at all.
await expect(ed.locator('h1')).toHaveCount(1);
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// A third line stays body too.
await page.keyboard.press('Enter');
await page.keyboard.type('Og han ringer selv.');
await expect(ed.locator('h1')).toHaveCount(1);
// It survives the trip through Markdown: the title is a heading again on the
// way back, and the body is not.
await ed.blur();
await page.reload();
await page.getByRole('button', { name: /Titelprøven/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Titelprøven');
await expect(back.locator('h1')).toHaveCount(1);
await expect(back).toContainText('ringer selv');
expect(trouble).toEqual([]);
});
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Første udkast');
await page.keyboard.press('Enter');
// Half a tag is enough. The name is long and nobody should have to spell it.
await page.keyboard.type('Handler om #Forslags');
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
await expect(option).toBeVisible();
// Return takes the highlighted one — the list owns the key while it is open.
await page.keyboard.press('Enter');
await expect(ed).toContainText('#Forslagsprojektet');
await expect(option).toBeHidden();
// And the syntax is spelled out under the field, where it is being typed.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
// The title is the first line. It used to be derived once and then left alone,
// so rewriting the opening left the list calling the note by an old name.
await page.keyboard.press('Enter');
await ed.blur();
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
// Rewrite the first line; the list has to follow.
await ed.click();
await page.keyboard.press('ControlOrMeta+Home');
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.type('Andet udkast');
await ed.blur();
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
expect(trouble).toEqual([]);
});
test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Written in lower case, the way somebody types when they are not thinking
// about it. It used to mean the note simply never appeared on the project, with
// nothing anywhere to say why.
await page.keyboard.type('Aftale om levering');
await page.keyboard.press('Enter');
await page.keyboard.type('Handler om #storebogstaver');
await page.keyboard.press('Escape');
await ed.blur();
// The panel shows the project's own spelling, not the folded key: a label
// nobody wrote and that is nowhere in the note would be worse than none.
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
expect(trouble).toEqual([]);
});
+227 -2
View File
@@ -15,6 +15,8 @@
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
let { note, onchange, onsave } = $props();
@@ -29,11 +31,24 @@
let loadedId = $state(null);
$effect(() => {
if (!editor || !note || note.id === loadedId) return;
editor.innerHTML = markdownToHtml(note.body) || '<p><br></p>';
// An empty note opens on its title. That is what Apple Notes does, and it is
// also what the list needs: the first line becomes the name of the note, so a
// note whose first line is body text is a note called by its first sentence.
//
// Tested on the text and not on the rendered result: an empty body renders as
// one empty paragraph, not as nothing, so a falsy check here never fired.
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
loadedId = note.id;
active = {};
});
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
const STYLES = [
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
@@ -90,9 +105,134 @@
function changed() {
if (!editor) return;
onchange?.(htmlToMarkdown(editor));
readSuggestions();
}
// --- suggesting a project ------------------------------------------------------
let suggestions = $state([]);
let chosen = $state(0);
/**
* The word being typed after a #, if that is what is happening.
*
* Read from the text node the caret is in rather than from the whole document:
* a note can mention twenty projects, and only the one under the cursor is being
* written.
*/
function partialTag() {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
const before = node.textContent.slice(0, selection.anchorOffset);
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (!match) return null;
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
}
function readSuggestions() {
const partial = partialTag();
if (!partial) {
suggestions = [];
return;
}
const term = partial.term.toLowerCase();
suggestions = app.projects
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
.slice(0, 6);
chosen = 0;
}
/** Replaces the half-typed tag with the whole name. */
function accept(project) {
const partial = partialTag();
if (!partial) return;
const range = document.createRange();
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, because the next thing typed is a word and not more of
// the tag — and without it the suggestions come straight back.
document.execCommand('insertText', false, project.name + ' ');
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
/** The block the caret is standing in. */
function currentBlock() {
const selection = window.getSelection();
if (!selection || !selection.anchorNode || !editor) return null;
let node = selection.anchorNode;
while (node && node.parentNode !== editor) node = node.parentNode;
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
}
/**
* Title for the first line, body for everything after it.
*
* Run after the browser has split the block on Enter, because until then there
* is no second block to change. Pressing return at the end of a heading leaves
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
}
function onkeydown(event) {
// The suggestion list owns the arrows and return while it is open, the way
// every other completion does. Escape closes it without choosing.
if (suggestions.length) {
if (event.key === 'ArrowDown') {
event.preventDefault();
chosen = (chosen + 1) % suggestions.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
accept(suggestions[chosen]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
suggestions = [];
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
// After the split, not before it.
setTimeout(() => {
keepTitleFirst();
changed();
}, 0);
return;
}
// The shortcuts people already have in their fingers. execCommand handles
// bold and italic itself; underline and strikethrough are claimed here so
// they behave the same way.
@@ -181,11 +321,48 @@
spellcheck="true"
oninput={changed}
onblur={onsave}
onkeyup={readState}
onkeyup={() => {
readState();
readSuggestions();
}}
onmouseup={readState}
{onkeydown}
{onpaste}
></div>
{#if suggestions.length}
<!-- Anchored to the editor rather than to the caret. Following the caret means
measuring it, and a list that jumps a few pixels as you type is worse than
one that sits still. -->
<ul class="suggestions" role="listbox">
{#each suggestions as project, i (project.id)}
<li>
<button
class:on={i === chosen}
role="option"
aria-selected={i === chosen}
onmousedown={(e) => {
e.preventDefault();
accept(project);
}}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
#{project.name}
</button>
</li>
{/each}
</ul>
{/if}
<!-- What the text understands, said where it is being typed. The same idea as
the line under the quick-add box: a note is the other place in this program
where what you type is read for meaning. -->
<p class="legend">
{#each SYNTAX as item}
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
{/each}
</p>
</div>
<style>
@@ -295,6 +472,54 @@
color: var(--ink-muted);
}
.suggestions {
position: absolute;
z-index: 30;
list-style: none;
margin: 0;
padding: var(--s1);
min-width: 200px;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
}
.suggestions button {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
text-align: left;
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
.suggestions button.on {
background: var(--surface);
color: var(--ink);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--s1) var(--s3);
padding-top: var(--s2);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.legend kbd {
font-family: var(--mono, ui-monospace, monospace);
color: var(--ink-muted);
}
.wrap {
position: relative;
}
.page {
flex: 1;
min-height: 50vh;
+2
View File
@@ -36,6 +36,8 @@ export const da = {
'notes.styleNumbered': 'Nummereret liste',
'notes.favourite': 'Gør til favorit',
'notes.unfavourite': 'Fjern som favorit',
'notes.syntaxProject': 'projekt',
'notes.syntaxNote': 'en anden note',
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
+2
View File
@@ -32,6 +32,8 @@ export const en = {
'notes.styleNumbered': 'Numbered list',
'notes.favourite': 'Make a favourite',
'notes.unfavourite': 'Remove from favourites',
'notes.syntaxProject': 'project',
'notes.syntaxNote': 'another note',
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
+41 -2
View File
@@ -48,6 +48,29 @@
draft = note?.body ?? '';
}
/**
* The first line or so of a note, without its marks.
*
* The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
* which is exactly the thing the editor was built to stop showing. What is
* wanted here is the sentence, not the notation.
*/
function plain(body) {
return (body ?? '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/~~([^~]+)~~/g, '$1')
.replace(/<\/?u>/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 90);
}
async function create() {
try {
const note = await api.createNote({ body: '' });
@@ -137,6 +160,22 @@
}
}
/**
* What a link is called on screen.
*
* A project tag is stored folded, because it is a key — but showing the folded
* key would mean a note that says #garageristeriet is labelled with a spelling
* that appears nowhere, neither in the note nor on the project. The project's
* own name is the right answer, and the fold is what makes it findable.
*/
function label(link) {
if (link.kind !== 'project') return link.target_id;
const project = app.projects.find(
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
);
return '#' + (project?.name ?? link.target_id);
}
// What the note points at, for the panel under the editor.
let links = $derived(selected?.links ?? []);
@@ -171,7 +210,7 @@
<div class="rowline">
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{note.body.slice(0, 80)}</span>
<span class="preview">{plain(note.body)}</span>
</button>
<button
class="star"
@@ -206,7 +245,7 @@
{#if links.length}
<span class="links">
{#each links as link}
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
<span class="link">{label(link)}</span>
{/each}
</span>
{/if}