From ff0447317b0e308a892475228407d6ca8d250083 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Mon, 14 Sep 2026 21:54:48 +0200 Subject: [PATCH] feat(noter): et indsat billede kan ses helt og kopieres videre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der ikke ser ud som et. Der var ingen vej fra det til billedet selv. Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link, der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i teksten — ellers står fokus på en knap, der ikke findes længere, og den næste tast går ingen steder. Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret af klikket, og det skal det: det er sådan, det slettes. To ting, der ikke er til at gætte: Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred — ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt forbi netop den kode. Og løftet gives videre til ClipboardItem frem for at blive ventet af først. Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning imellem gør klikket til fortid. Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det skal lægge sig over. Co-Authored-By: Claude Opus 5 (1M context) --- docs/notes.md | 10 ++ web/e2e/smoke.spec.js | 60 +++++++++ web/src/lib/components/NoteEditor.svelte | 155 +++++++++++++++++++++++ web/src/lib/locales/da.js | 4 + web/src/lib/locales/en.js | 4 + 5 files changed, 233 insertions(+) diff --git a/docs/notes.md b/docs/notes.md index a58a60b..89fe064 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline it is a document that can run script, and a note is not a place to run somebody else's. +**Click one to see it whole.** In the note it is scaled down to the width of the +column — a photo off a phone is four thousand pixels across — so what you are +looking at is a thumbnail that does not look like one. The full-size view has a +button that copies the picture to the clipboard, and a link that opens it in its +own tab. Escape closes it and puts the cursor back where it was. + +Copying converts to PNG on the way out when it has to. Clipboards take PNG and +little else, and a photo off a phone is a JPEG — without the conversion the copy +either fails or succeeds with something nothing will paste. + ### Code blocks A fenced block is coloured for the language it names, and guesses when it does not. diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 93412b6..b26785a 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4689,3 +4689,63 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) = expect(trouble).toEqual([]); }); +/** + * Et indsat billede kan ses i fuld størrelse og kopieres videre. + * + * I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon + * er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der + * ikke ser ud som et. Der var ingen vej fra det til billedet selv. + * + * Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med + * en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe + * hele vedhæftningen over og dermed netop den kode, det her handler om. + */ +test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => { + const trouble = watchForTrouble(page); + // Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven + // ville måle en browserindstilling frem for programmet. + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await page.goto('/noter'); + await page.getByRole('button', { name: 'Ny note' }).click(); + const body = page.getByLabel('Notens tekst'); + await body.fill('Billednote'); + + // En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG, + // så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG. + // Med en PNG her ville prøven køre forbi netop den kode, den skal vogte. + await body.evaluate((el) => { + const jpeg = + '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' + + 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' + + 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=='; + const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0)); + const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' }); + const data = new DataTransfer(); + data.items.add(file); + el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })); + }); + + const image = page.locator('[aria-label="Notens tekst"] img'); + await expect(image).toBeVisible(); + + // Klikket åbner det, og ruden viser det samme billede. + await image.click(); + const shot = page.getByRole('dialog', { name: 'Billede' }); + await expect(shot).toBeVisible(); + await expect(shot.locator('img')).toBeVisible(); + + // Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap: + // man indsætter noget andet og opdager det et helt andet sted. + await shot.getByRole('button', { name: 'Kopiér billedet' }).click(); + await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible(); + await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0); + + // Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der + // ikke findes mere, og den næste tast går ingen steder. + await page.keyboard.press('Escape'); + await expect(shot).toHaveCount(0); + await expect(body).toBeFocused(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index 965e7fa..e024c20 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -640,9 +640,69 @@ onopennote?.(link.getAttribute('data-note') ?? link.textContent); return; } + // Et billede i en note er et billede, man har indsat for at kunne se det. I + // arket er det skaleret ned til spaltens bredde — et foto fra en telefon er + // fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, + // der ikke ser ud som et. + const image = event.target.closest?.('img'); + if (image) { + event.preventDefault(); + showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' }; + copied = false; + copyError = ''; + return; + } colourCode(); } + // --- billedet i fuld størrelse ------------------------------------------------ + + let showing = $state(null); + let copied = $state(false); + let copyError = $state(''); + + function closeImage() { + showing = null; + // Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke + // findes længere, og den næste tast går ingen steder. + editor?.focus(); + } + + /** + * Billedet som PNG, uanset hvad det kom som. + * + * Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon + * er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers + * lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte. + */ + async function asPNG(src) { + const blob = await (await fetch(src)).blob(); + if (blob.type === 'image/png') return blob; + + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d').drawImage(bitmap, 0, 0); + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + } + + async function copyImage() { + copyError = ''; + try { + // Løftet gives videre frem for at blive ventet af først. Safari kræver, at + // clipboard.write kaldes i selve klikket, og en hentning imellem gør + // klikket til fortid — et ClipboardItem må gerne indeholde et løfte. + await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]); + copied = true; + setTimeout(() => (copied = false), 2000); + } catch (e) { + // Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre + // end ingen knap: man indsætter noget andet og opdager det et andet sted. + copyError = t('notes.copyFailed'); + } + } + /** Replaces the half-typed tag with the whole thing. */ function accept(item) { const partial = partialTag(); @@ -1068,7 +1128,102 @@

+ +{#if showing} + + +{/if} + + { + if (showing && e.key === 'Escape') { + e.stopPropagation(); + closeImage(); + } + }} +/> +