Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c60bdaf9 | ||
|
|
5e670119db |
+2
-3
@@ -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:
|
||||
|
||||
@@ -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, ¶ms)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = ?)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2129,3 +2129,85 @@ test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ pa
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -40,6 +42,13 @@
|
||||
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' },
|
||||
@@ -96,6 +105,62 @@
|
||||
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. */
|
||||
@@ -135,6 +200,31 @@
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -231,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>
|
||||
@@ -345,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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -160,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 ?? []);
|
||||
|
||||
@@ -229,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}
|
||||
|
||||
Reference in New Issue
Block a user