diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 14da1e1..39e6bb4 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2624,6 +2624,36 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin expect(trouble).toEqual([]); }); +test('[[ foreslår en note, og linket lander i noten', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + // One note to point at, then another to write the link in. + await page.getByRole('button', { name: 'Ny note' }).click(); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await ed.click(); + await page.keyboard.type('Rejseplan til Berlin'); + await ed.blur(); + await page.waitForTimeout(600); + + await page.getByRole('button', { name: 'Ny note' }).click(); + await ed.click(); + await page.keyboard.type('Se '); + await page.keyboard.type('[[Rejse'); + + // The picker offers the note by title, and choosing it closes the brackets. + const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' }); + await expect(option).toBeVisible(); + await option.click(); + await expect(ed).toContainText('[[Rejseplan til Berlin]]'); + + // And the link is real: it turns up in the note's links, not just as text. + await ed.blur(); + await expect(page.locator('.links')).toContainText('Rejseplan til Berlin'); + + 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'); diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index da68a5a..8a6b9b8 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -20,7 +20,7 @@ import { api, humanMessage } from '$lib/api.js'; import { colorVar } from '$lib/colors.js'; - let { note, onchange, onsave } = $props(); + let { note, notes = [], onchange, onsave } = $props(); let editor; // Forslagslisten, så tastaturvalget kan rulle den frem. @@ -390,9 +390,26 @@ 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] }; + + // A note link, [[, is the same idea as a project tag and gets the same + // completion — checked first because it is the longer opener. The term runs + // to the caret and is anything up to the closing bracket, which has not been + // typed yet. + const note = /\[\[([^\]\n]*)$/u.exec(before); + if (note) { + return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] }; + } + + const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before); + if (project) { + return { + kind: 'project', + node, + start: selection.anchorOffset - project[1].length, + term: project[1] + }; + } + return null; } /** @@ -436,33 +453,41 @@ // pixels for hvert bogstav, mens listen snævres ind. if (!suggestions.length) menuAt = tagPosition(partial); - // Alle der passer, ikke de seks første. - // - // Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde - // noget værre end at skjule: med tyve projekter og et blot tastet `#` viste - // den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede - // noget — kun at ens eget projekt ikke var der. - // - // Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med - // det tastede, står øverst, resten alfabetisk. Skriver man "ga", er - // GarageRisteriet den første, ikke den, der tilfældigvis lå først. - const matches = app.projects.filter( - (p) => !p.is_inbox && p.name.toLowerCase().includes(term) - ); - matches.sort((a, b) => { - const an = a.name.toLowerCase(); - const bn = b.name.toLowerCase(); - const ap = an.startsWith(term); - const bp = bn.startsWith(term); - if (ap !== bp) return ap ? -1 : 1; - return an.localeCompare(bn, 'da'); - }); - suggestions = matches; + suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term); chosen = 0; } - /** Replaces the half-typed tag with the whole name. */ - function accept(project) { + // Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder + // med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er + // GarageRisteriet den første, ikke den, der tilfældigvis lå først. + function projectMatches(term) { + return app.projects + .filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term)) + .map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color })) + .sort((a, b) => rank(a.label, b.label, term)); + } + + // The same completion for [[ as for #, over the notes the page has loaded. The + // current note is left out — a note that links to itself is a loop nobody meant + // — and a note with no title yet cannot be pointed at, so it is skipped too. + function noteMatches(term) { + return notes + .filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term)) + .map((n) => ({ id: n.id, kind: 'note', label: n.title })) + .sort((a, b) => rank(a.label, b.label, term)); + } + + function rank(a, b, term) { + const al = a.toLowerCase(); + const bl = b.toLowerCase(); + const ap = al.startsWith(term); + const bp = bl.startsWith(term); + if (ap !== bp) return ap ? -1 : 1; + return al.localeCompare(bl, 'da'); + } + + /** Replaces the half-typed tag with the whole name, and closes a note link. */ + function accept(item) { const partial = partialTag(); if (!partial) return; @@ -473,9 +498,11 @@ 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 + ' '); + // A note link closes its own brackets — the opener is already in the text — + // and both end in a space, because the next thing typed is a word and not + // more of the tag, and without it the suggestions come straight back. + const inserted = item.kind === 'note' ? `${item.label}]] ` : `${item.label} `; + document.execCommand('insertText', false, inserted); suggestions = []; onchange?.(htmlToMarkdown(editor)); } @@ -796,7 +823,7 @@ style:top={menuAt ? `${menuAt.top}px` : null} style:left={menuAt ? `${menuAt.left}px` : null} > - {#each suggestions as project, i (project.id)} + {#each suggestions as item, i (item.kind + item.id)}