diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 71aa91a..20601be 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -2089,3 +2089,43 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
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');
+
+ await page.getByRole('button', { name: 'Ny note' }).click();
+ const ed = page.getByRole('textbox', { name: 'Notens tekst' });
+ await ed.click();
+
+ // Straight in, without choosing a style. The first line is the title because
+ // that is what the list calls the note by.
+ await page.keyboard.type('Titelprøven');
+ await expect(ed.locator('h1')).toHaveText('Titelprøven');
+
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('Han vil gerne have kaffe hver uge.');
+
+ // And the line after is body, not a second title. A note where every line is a
+ // heading is a note with no heading at all.
+ await expect(ed.locator('h1')).toHaveCount(1);
+ await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
+
+ // A third line stays body too.
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('Og han ringer selv.');
+ await expect(ed.locator('h1')).toHaveCount(1);
+
+ // It survives the trip through Markdown: the title is a heading again on the
+ // way back, and the body is not.
+ await ed.blur();
+ await page.reload();
+ await page.getByRole('button', { name: /Titelprøven/ }).click();
+
+ const back = page.getByRole('textbox', { name: 'Notens tekst' });
+ await expect(back.locator('h1')).toHaveText('Titelprøven');
+ await expect(back.locator('h1')).toHaveCount(1);
+ await expect(back).toContainText('ringer selv');
+
+ expect(trouble).toEqual([]);
+});
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index 02e90e8..f082112 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -29,7 +29,13 @@
let loadedId = $state(null);
$effect(() => {
if (!editor || !note || note.id === loadedId) return;
- editor.innerHTML = markdownToHtml(note.body) || '
';
+ // An empty note opens on its title. That is what Apple Notes does, and it is
+ // also what the list needs: the first line becomes the name of the note, so a
+ // note whose first line is body text is a note called by its first sentence.
+ //
+ // Tested on the text and not on the rendered result: an empty body renders as
+ // one empty paragraph, not as nothing, so a falsy check here never fired.
+ editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '
';
loadedId = note.id;
active = {};
});
@@ -92,7 +98,51 @@
onchange?.(htmlToMarkdown(editor));
}
+ /** The block the caret is standing in. */
+ function currentBlock() {
+ const selection = window.getSelection();
+ if (!selection || !selection.anchorNode || !editor) return null;
+ let node = selection.anchorNode;
+ while (node && node.parentNode !== editor) node = node.parentNode;
+ return node?.nodeType === Node.ELEMENT_NODE ? node : null;
+ }
+
+ /**
+ * Title for the first line, body for everything after it.
+ *
+ * Run after the browser has split the block on Enter, because until then there
+ * is no second block to change. Pressing return at the end of a heading leaves
+ * the caret in another heading in most browsers, and a note where every line is
+ * a title is a note with no title at all.
+ */
+ function keepTitleFirst() {
+ const block = currentBlock();
+ if (!block) return;
+
+ // A heading that is not the first line becomes body.
+ if (block.tagName === 'H1' && block !== editor.firstElementChild) {
+ document.execCommand('formatBlock', false, 'p');
+ return;
+ }
+
+ // And the browser's own container becomes a paragraph. Chromium answers Enter
+ // with a bare
, which reads as body but is not styled as one — so the
+ // spacing between paragraphs quietly disappears the moment somebody presses
+ // return rather than choosing Brødtekst from the menu.
+ if (block.tagName === 'DIV') {
+ document.execCommand('formatBlock', false, 'p');
+ }
+ }
+
function onkeydown(event) {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ // After the split, not before it.
+ setTimeout(() => {
+ keepTitleFirst();
+ changed();
+ }, 0);
+ return;
+ }
// The shortcuts people already have in their fingers. execCommand handles
// bold and italic itself; underline and strikethrough are claimed here so
// they behave the same way.
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index bd8996a..fc1ee7a 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -48,6 +48,29 @@
draft = note?.body ?? '';
}
+ /**
+ * The first line or so of a note, without its marks.
+ *
+ * The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
+ * which is exactly the thing the editor was built to stop showing. What is
+ * wanted here is the sentence, not the notation.
+ */
+ function plain(body) {
+ return (body ?? '')
+ .replace(/^#{1,6}\s+/gm, '')
+ .replace(/^\s*[-*+]\s+/gm, '')
+ .replace(/^\s*>\s?/gm, '')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/~~([^~]+)~~/g, '$1')
+ .replace(/<\/?u>/g, '')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\*([^*]+)\*/g, '$1')
+ .replace(/\[\[([^\]]+)\]\]/g, '$1')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 90);
+ }
+
async function create() {
try {
const note = await api.createNote({ body: '' });
@@ -171,7 +194,7 @@