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}
+
+
+
+
e.stopPropagation()}>
+

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