feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a real link behind the scenes — it showed in the panel under the note — but in the note itself it stayed brackets, and "it doesn't become a link" is exactly how that reads. A wiki link is a link where it is written. Now a [[note]] renders inline as the title, in the accent colour, and a click on it opens the note it points at. It is atomic — contenteditable="false" — so it moves and deletes as one thing and a click is a click, not a caret landing mid-word. And it appears the moment it is chosen: the completion drops the finished link in, brackets and all swallowed, rather than text waiting to become a link when the note is next opened. The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by the title it carries — so what is on disk is still the same Markdown, and a note opened, saved and reopened is unchanged. e2e: choosing a note writes an inline link, it survives the round-trip through Markdown and a reload, and clicking it opens the note it names. Full suite green. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b07938e351
commit
8022502015
+13
-6
@@ -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([]);
|
||||
|
||||
@@ -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, '<').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,
|
||||
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
|
||||
);
|
||||
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);
|
||||
|
||||
+33
-6
@@ -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(`<img src="${src}" alt="${attr(alt)}">`);
|
||||
return `${MARK}${images.length - 1}${MARK}`;
|
||||
});
|
||||
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${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(
|
||||
`<a class="notelink" data-note="${attr(title)}" contenteditable="false">${escapeHtml(title)}</a>`
|
||||
)
|
||||
);
|
||||
|
||||
const marked = escapeHtml(lifted)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
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
|
||||
|
||||
@@ -916,6 +916,7 @@
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
notes={notes}
|
||||
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||
onchange={(body) => {
|
||||
draft = body;
|
||||
typed();
|
||||
|
||||
Reference in New Issue
Block a user