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 `![](http://…)` 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(`${attr(alt)}`); - return `${MARK}${images.length - 1}${MARK}`; - }); + let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`${attr(alt)}`)); + + // 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();