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 @@