Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 6116ed2e21 fix(notes): [[ completes a note link, the way # completes a project
Typing [[ did nothing. The toolbar hint offered "[[ another note" and the parser
turned a fully-typed [[Title]] into a link on save, but the completion that makes
it usable — the one # has for projects — was never there, so you had to know and
type a note's title exactly, brackets and all.

Now [[ opens the same picker # does, over the notes the page has loaded: the
current note is left out (a note linking to itself is a loop nobody meant), and
one without a title yet cannot be pointed at. Choosing a note closes its own
brackets — the opener is already in the text — and leaves a space, so the next
word is a word and not more of the tag. On save the link is real and shows in the
note's links, not merely as text.

The picker's two kinds are told apart where a project wears its colour: a note
suggestion wears the brackets that open it.

e2e: [[ offers a note by title, choosing it writes [[Title]] into the note, and
the link lands in the note's links. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:22:38 +02:00
3 changed files with 111 additions and 36 deletions
+30
View File
@@ -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');
+80 -36
View File
@@ -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)}
<li>
<button
class:on={i === chosen}
@@ -804,12 +831,17 @@
aria-selected={i === chosen}
onmousedown={(e) => {
e.preventDefault();
accept(project);
accept(item);
}}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
#{project.name}
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
#{item.label}
{/if}
</button>
</li>
{/each}
@@ -962,6 +994,18 @@
font-size: var(--text-sm);
}
/* A note suggestion wears the brackets that open it where a project wears its
colour, so the two kinds are told apart at a glance in the same list. */
.suggestions .dot.note {
width: auto;
height: auto;
background: none;
color: var(--ink-faint);
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.85em;
line-height: 1;
}
.suggestions button.on {
background: var(--surface);
color: var(--ink);
+1
View File
@@ -876,6 +876,7 @@
{#if selected}
<NoteEditor
note={selected}
notes={notes}
onchange={(body) => {
draft = body;
typed();