diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a67da63..a6dfb59 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2838,6 +2838,142 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b expect(trouble).toEqual([]); }); +/** + * Note-links i en delt note. + * + * Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting. + * Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen + * ikke passede — og i en delt note er det første resultat næsten altid noten selv, + * fordi den nævner den titel, den linker til. Så åbnede linket den note, man + * allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt + * noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten + * findes, den er bare ikke delt med. + */ +test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({ + browser, + page +}) => { + const trouble = watchForTrouble(page); + + await page.goto('/indstillinger/brugere'); + await page.getByLabel('E-mailadresse').fill('link@example.dk'); + await page.getByRole('button', { name: 'Send invitation' }).click(); + const link = await page.locator('.link-out').textContent(); + + const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const gæst = await gæstCtx.newPage(); + await gæst.goto(link); + await gæst.getByLabel('Navn', { exact: true }).fill('Linnea'); + await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); + await gæst.getByRole('button', { name: 'Opret konto' }).click(); + await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + const noterURL = new URL('/noter', link).href; + + // Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den + // delte peger på den anden. + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('link', { name: 'Noter', exact: true }).click(); + await page.getByRole('button', { name: 'Ny note' }).click(); + await page.getByRole('textbox', { name: 'Notens tekst' }).click(); + await page.keyboard.type('Prisliste for foraaret'); + await page.getByRole('textbox', { name: 'Notens tekst' }).blur(); + + await page.getByRole('button', { name: 'Ny note' }).click(); + await page.getByRole('textbox', { name: 'Notens tekst' }).click(); + await page.keyboard.type('Aftale om levering\n'); + await page.keyboard.type('[[Prisliste for foraaret]]'); + await page.getByRole('textbox', { name: 'Notens tekst' }).blur(); + + // Gæsten har en note af sin egen, som hun kan pege på. + await gæst.goto(noterURL); + await gæst.getByRole('button', { name: 'Ny note' }).click(); + await gæst.getByRole('textbox', { name: 'Notens tekst' }).click(); + await gæst.keyboard.type('Linneas egne maal'); + await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur(); + + await page.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click(); + await page.locator('.sharewrap > button').click(); + const share = page.locator('.sharepanel'); + await expect(share).toBeVisible(); + await share.locator('.addshare select').first().selectOption({ label: 'Linnea' }); + await share.getByRole('button', { name: 'Tilføj' }).click(); + const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' }); + await expect(row).toBeVisible(); + await row.locator('select').selectOption('editor'); + + // Hun skriver sit eget link ind i den delte note. + await gæst.goto(noterURL); + await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click(); + const body = gæst.getByRole('textbox', { name: 'Notens tekst' }); + await expect(body).toContainText('Prisliste for foraaret'); + await body.click(); + await gæst.keyboard.press('Control+End'); + await gæst.keyboard.type('\n[[Linneas egne maal]]'); + await body.blur(); + await expect(gæst.locator('footer .hint')).toHaveText('Gemt'); + + await gæst.goto(noterURL); + await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click(); + const delt = gæst.getByRole('textbox', { name: 'Notens tekst' }); + + // Hendes eget link fører derhen, det siger. + await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click(); + await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText( + 'Linneas egne maal' + ); + + // Ejerens link peger på en note, hun ikke har fået. Den må ikke lande et + // tilfældigt sted — og den må sige hvorfor. + await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click(); + const igen = gæst.getByRole('textbox', { name: 'Notens tekst' }); + await expect(igen).toContainText('Prisliste for foraaret'); + await igen.locator('a.notelink', { hasText: 'Prisliste for foraaret' }).click(); + await expect(gæst.locator('.toast')).toContainText('ikke delt med dig'); + // Stadig i den delte note: linket flyttede hende ingen steder hen. + await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText( + 'Aftale om levering' + ); + + await gæstCtx.close(); + expect(trouble).toEqual([]); +}); + +/** + * `` er kode, ikke kilde. + * + * Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet, + * og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet + * program, så det er knappen, der skal give efter, ikke brugeren. + */ +test(' sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + await page.getByRole('button', { name: 'Ny note' }).click(); + const body = page.getByRole('textbox', { name: 'Notens tekst' }); + await body.click(); + await page.keyboard.type('Kodenote\n'); + await page.keyboard.type('kør kommandoen npm først'); + + // Markér ét ord og gør det til kode. + await page.keyboard.press('Shift+Home'); + await page.keyboard.press('Shift+ArrowRight'); + await page.getByRole('button', { name: 'Kode', exact: true }).click(); + await expect(body.locator('code')).toHaveCount(1); + + // Noten er stadig rig tekst — kildevisningen er ikke slået til. + await expect(page.locator('textarea.source')).toHaveCount(0); + + // Og den anden knap gør stadig det, den altid har gjort: viser filen, med + // baktikkerne i, som er beviset på at koden nåede hele vejen ned. + await body.blur(); + await expect(page.locator('footer .hint')).toHaveText('Gemt'); + await page.getByRole('button', { name: /Vis kilden/ }).click(); + await expect(page.locator('textarea.source')).toHaveValue(/`/); + + expect(trouble).toEqual([]); +}); + test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({ page }) => { diff --git a/web/kristians-link.png b/web/kristians-link.png new file mode 100644 index 0000000..15e581d Binary files /dev/null and b/web/kristians-link.png differ diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index e59cd36..e755200 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -164,6 +164,111 @@ changed(); } + /** + * Inline kode — `sådan her`. + * + * Den knap fandtes ikke, og ``, som i ethvert andet program betyder netop + * kode, sad på kildevisningen med teksten "Vis som kode" på. Så markerede man et + * ord og trykkede på den, og hele noten skiftede til Markdown. Det blev meldt + * som en fejl, og det var det også: knappen sagde ét og gjorde et andet. Nu gør + * `` det, tegnet siger, og kilden har fået sit eget mærke. + * + * Skrevet i hånden, fordi execCommand ikke har en kommando for kode. Markeringen + * læses som ren tekst — det er hele meningen med kode, at det ikke er formateret + * — og lægges tilbage inde i et , som konverteringen allerede kender + * begge veje. + */ + function codeAround() { + const selection = window.getSelection(); + let node = selection?.anchorNode; + while (node && node !== editor) { + if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'CODE') return node; + node = node.parentNode; + } + return null; + } + + /** Markerer ordet, markøren står i. Bruges når der ikke er markeret noget. */ + function selectWordAtCaret() { + const selection = window.getSelection(); + const node = selection?.anchorNode; + if (!node || node.nodeType !== Node.TEXT_NODE) return; + const text = node.textContent ?? ''; + let start = selection.anchorOffset; + let end = start; + while (start > 0 && !/\s/.test(text[start - 1])) start--; + while (end < text.length && !/\s/.test(text[end])) end++; + if (start === end) return; + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + selection.removeAllRanges(); + selection.addRange(range); + } + + function selectContents(node) { + const range = document.createRange(); + range.selectNodeContents(node); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + + function toggleCode() { + editor?.focus(); + const selection = window.getSelection(); + if (!selection?.rangeCount) return; + + // Inde i en kodeblok er alting allerede kode; en derinde kan hverken + // ses eller skrives ned, for en hegnet blok er tekst hele vejen igennem. + const block = currentBlock(); + if (block?.tagName === 'PRE') return; + + const existing = codeAround(); + if (existing) { + // Ud igen: teksten bliver stående, mærkerne forsvinder. Markeringen lægges + // om den, så et tryk mere sætter den tilbage — knappen er en kontakt. + const text = document.createTextNode(existing.textContent ?? ''); + existing.replaceWith(text); + selectContents(text); + readState(); + changed(); + return; + } + + // Uden en markering er det ordet, markøren står i. Det er det, ⌘B gør i et + // tekstprogram, og bedre end et tomt , der ikke kan ses og ikke kan + // skrives ned. + if (selection.isCollapsed) selectWordAtCaret(); + const live = window.getSelection(); + const text = live.toString(); + if (!text) return; + + // Bygget som en node frem for med `insertHTML`. + // + // Chromium renser den HTML, den får dér, gennem den samme vej som en + // indsætning: `` kom ud som et `` med skrifttypen og farven + // skrevet ind i sig, og et span er ikke noget, htmlToMarkdown kan skrive ned + // som kode. Målt — mærket forsvandt hver gang, og noten så rigtig ud, indtil + // den blev gemt. + const range = live.getRangeAt(0); + range.deleteContents(); + const code = document.createElement('code'); + code.textContent = text; + range.insertNode(code); + + // Markøren efter mærket, ikke inde i det: ellers bliver det næste ord, man + // skriver, også til kode. + const after = document.createRange(); + after.setStartAfter(code); + after.collapse(true); + live.removeAllRanges(); + live.addRange(after); + + readState(); + changed(); + } + /** * Steps out of a code block on Enter. * @@ -223,7 +328,11 @@ underline: document.queryCommandState('underline'), strike: document.queryCommandState('strikeThrough'), bullet: document.queryCommandState('insertUnorderedList'), - numbered: document.queryCommandState('insertOrderedList') + numbered: document.queryCommandState('insertOrderedList'), + // Læst af DOM'en og ikke af execCommand: der findes ingen kommando + // for kode, hvilket er hele grunden til at knappen er skrevet i + // hånden herunder. + code: !!codeAround() }; } catch { // Some browsers throw on queryCommandState with no selection. The toolbar @@ -641,6 +750,9 @@ if (key === 'u') { event.preventDefault(); apply('underline'); + } else if (key === 'e') { + event.preventDefault(); + toggleCode(); } else if (key === 'x' && event.shiftKey) { event.preventDefault(); apply('strikeThrough'); @@ -788,19 +900,50 @@ + + + anden måde at se det hele på — og med Markdown-mærket på, som er præcis + det, den viser. Et tegn, ingen forveksler med et format. --> + + {#if asSource} @@ -1132,11 +1275,24 @@ resize: none; } - .src { + /* Kodeknappen bærer sit eget tegn i monospace — `` sat med brødskriften + ligner en tastefejl frem for et mærke. */ + .codemark { font-family: var(--font-mono); font-size: var(--text-xs); } + .src { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .src svg { + width: 17px; + height: auto; + } + /* The document styles. These are the whole of "it should look like Apple Notes": a title that is a title, and body text that is comfortable to read for a page at a time. */ diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 26c1ebf..e415eef 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -23,7 +23,7 @@ export const da = { // Deling med enkeltpersoner, ved siden af projektet. 'notes.groupSharedWithMe': 'Delt med mig', 'notes.linkOpen': 'Åbn', - 'notes.linkMissing': 'Noten blev ikke fundet.', + 'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.', 'notes.sharedBy': 'Delt af {name}', 'notes.sharePeople': 'Del med personer', 'notes.sharePick': 'Vælg en person …', @@ -76,7 +76,8 @@ export const da = { 'notes.copied': 'Kopieret', 'notes.copyFailed': 'Kunne ikke kopiere', 'notes.strike': 'Gennemstreget', - 'notes.showSource': 'Vis som kode', + 'notes.code': 'Kode', + 'notes.showSource': 'Vis kilden (Markdown)', 'notes.source': 'Notens kildetekst', 'notes.styleTitle': 'Titel', 'notes.styleHeading': 'Overskrift', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 0b317d3..def0814 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -19,7 +19,7 @@ export const en = { // Sharing with individual people, alongside the project. 'notes.groupSharedWithMe': 'Shared with me', 'notes.linkOpen': 'Open', - 'notes.linkMissing': 'That note could not be found.', + 'notes.linkMissing': 'That note does not exist, or it is not shared with you.', 'notes.sharedBy': 'Shared by {name}', 'notes.sharePeople': 'Share with people', 'notes.sharePick': 'Choose someone …', @@ -72,7 +72,8 @@ export const en = { 'notes.copied': 'Copied', 'notes.copyFailed': 'Could not copy', 'notes.strike': 'Strikethrough', - 'notes.showSource': 'Show as code', + 'notes.code': 'Code', + 'notes.showSource': 'Show the source (Markdown)', 'notes.source': 'Note source', 'notes.styleTitle': 'Title', 'notes.styleHeading': 'Heading', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 90d9044..49c6153 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -665,17 +665,38 @@ * to their own pages. Until now these were text and nothing else: the note said * it pointed somewhere and there was no way to go. */ + /** + * Følger et link — og siger det, når det ikke kan følges. + * + * Et `[[link]]` peger på en note ved dens *titel*, så det skal slås op. Først i + * listen, der allerede er hentet, og ellers med en søgning, fordi listen kan + * være snævret ind af et søgeord. + * + * Kun en titel, der passer helt. Der stod `?? found[0]` her, så et link, der + * ikke kunne findes, åbnede den første note, søgningen tilfældigvis gav — og i + * en *delt* note er det næsten altid den note, man allerede står i: den nævner + * jo titlen, den linker til. Så linket så ud til ikke at gøre noget som helst, + * hvilket er præcis sådan det blev meldt. Et link, der rammer forbi, skal sige + * det, ikke ramme et andet sted. + * + * Beskeden nævner delingen, fordi det er den almindelige årsag: en note, der + * deles, deler ikke de noter, den peger på, og modtageren kan derfor se linket + * uden at kunne åbne det. Herfra kan de to tilfælde ikke skelnes — svaret er + * det samme, hvad enten noten ikke findes eller bare ikke er ens egen — og + * sætningen siger dem begge frem for at gætte på den ene. + */ async function openLink(link) { if (link.kind === 'note') { - const wanted = link.target_id.toLowerCase(); - const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted); + const wanted = link.target_id.trim().toLowerCase(); + const titled = (n) => (n.title ?? '').trim().toLowerCase(); + const here = notes.find((n) => titled(n) === wanted); if (here) { open(here); return; } try { const found = (await api.notes({ q: link.target_id })).notes ?? []; - const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0]; + const hit = found.find((n) => titled(n) === wanted); if (hit) open(hit); else app.toast(t('notes.linkMissing')); } catch (e) {