diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 3bfaf02..1e62b6a 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -2638,21 +2638,28 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
+ await page.keyboard.type('Mine planer');
+ await page.keyboard.press('Enter');
await page.keyboard.type('Se ');
await page.keyboard.type('[[Rejse');
- // The picker offers the note by title, and choosing it closes the brackets.
+ // The picker offers the note by title, and choosing it turns the text into a
+ // real inline link — the title, not 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]]');
+ const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
+ await expect(inline).toBeVisible();
- // And the link is real: it turns up in the note's links, not just as text.
+ // It survives the round-trip through Markdown and a reload.
await ed.blur();
- await expect(page.locator('.links')).toContainText('Rejseplan til Berlin');
+ await page.waitForTimeout(900);
+ await page.reload();
+ await page.getByRole('button', { name: /Mine planer/ }).click();
+ await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
- // And it is a link you can follow: clicking the chip opens the note it names.
- await page.locator('.links .link', { hasText: 'Rejseplan til Berlin' }).click();
+ // And it is a link you can follow: clicking it opens the note it names.
+ await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
expect(trouble).toEqual([]);
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index 8a6b9b8..e59cd36 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, notes = [], onchange, onsave } = $props();
+ let { note, notes = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -486,23 +486,54 @@
return al.localeCompare(bl, 'da');
}
- /** Replaces the half-typed tag with the whole name, and closes a note link. */
+ const escHtml = (s) => s.replace(/&/g, '&').replace(//g, '>');
+ const escAttr = (s) => escHtml(s).replace(/"/g, '"');
+
+ // A click on a note link follows it; a click anywhere else re-colours the code,
+ // as it did before. The link is contenteditable="false", so the click is a click
+ // and not a caret landing in the middle of a word.
+ function onEditorClick(event) {
+ const link = event.target.closest?.('.notelink');
+ if (link) {
+ event.preventDefault();
+ onopennote?.(link.getAttribute('data-note') ?? link.textContent);
+ return;
+ }
+ colourCode();
+ }
+
+ /** Replaces the half-typed tag with the whole thing. */
function accept(item) {
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 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);
+ if (item.kind === 'note') {
+ // Swallow the opening [[ as well and drop the finished link in its place,
+ // so it is a link the moment it is chosen — not the brackets, waiting to
+ // become one when the note is next opened. A space after, as its own text,
+ // so the caret leaves the atomic link and the next word is a word.
+ range.setStart(partial.node, partial.start - 2);
+ range.setEnd(partial.node, partial.start + partial.term.length);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.execCommand(
+ 'insertHTML',
+ false,
+ `${escHtml(item.label)}`
+ );
+ document.execCommand('insertText', false, ' ');
+ } else {
+ range.setStart(partial.node, partial.start);
+ range.setEnd(partial.node, partial.start + partial.term.length);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ // A trailing space, or the suggestions come straight back for the tag.
+ document.execCommand('insertText', false, `${item.label} `);
+ }
+
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
@@ -803,7 +834,7 @@
readState();
readSuggestions();
}}
- onclick={colourCode}
+ onclick={onEditorClick}
onmouseup={readState}
{onkeydown}
{onpaste}
@@ -1165,6 +1196,18 @@
margin: var(--s2) 0;
}
+ /* A note link reads as a link: the accent colour, underlined on hover, and a
+ pointer so it is plainly something to click rather than to edit. */
+ .page :global(a.notelink) {
+ color: var(--accent);
+ text-decoration: none;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ }
+ .page :global(a.notelink:hover) {
+ text-decoration: underline;
+ }
+
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js
index 498055a..dba6677 100644
--- a/web/src/lib/richtext.js
+++ b/web/src/lib/richtext.js
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''')
* det overlever hverken en contenteditable eller en Markdown-fil.
*/
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
+// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
+// links on one line stay two.
+const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
const MARK = "\u0000";
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
+ // Lifted out before anything escapes or marks the text, and dropped back in
+ // whole at the end — the same placeholder trick the image already used, now for
+ // two kinds of thing the surrounding markup must not touch.
+ const pieces = [];
+ const lift = (html) => {
+ pieces.push(html);
+ return `${MARK}${pieces.length - 1}${MARK}`;
+ };
+
// Kun vores egen adresse bliver til et billede. Et `` fra en
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
// have en sporingspixel. Alt andet står som den tekst, det er.
- const images = [];
- const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
- images.push(`
`);
- return `${MARK}${images.length - 1}${MARK}`;
- });
+ let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`
`));
+
+ // A [[note]] is a link you can follow, not the brackets it was typed as. Made an
+ // atomic token — contenteditable="false" — so it moves and deletes as one thing,
+ // and it carries the title it points at in data-note; the editor reads that on a
+ // click. htmlToMarkdown turns it back into [[title]] exactly.
+ lifted = lifted.replace(
+ NOTELINK,
+ (_, title) =>
+ lift(
+ `${escapeHtml(title)}`
+ )
+ );
const marked = escapeHtml(lifted)
.replace(/<u>(.+?)<\/u>/g, '$1')
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
.replace(/~~(.+?)~~/g, '$1')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
- return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
+ return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
}
/**
@@ -234,6 +254,13 @@ function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
+ // A note link goes back to the brackets it came from, by the title it carries
+ // rather than by the text it shows — the two are the same today, but the title
+ // is the thing that must survive the round trip.
+ if (node.tagName === 'A' && node.classList?.contains('notelink')) {
+ return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
+ }
+
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index b6dc2bb..6d9f41b 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -916,6 +916,7 @@
openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
typed();