Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres

feat(noter): et indsat billede kan ses helt og kopieres videre
This commit is contained in:
Kristian Wind
2026-09-14 22:04:38 +02:00
committed by GitHub
5 changed files with 233 additions and 0 deletions
+10
View File
@@ -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.
+60
View File
@@ -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([]);
});
+155
View File
@@ -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 @@
</p>
</div>
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
af netop det ark, det skal lægge sig over. -->
{#if showing}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="shot" onclick={(e) => e.stopPropagation()}>
<img src={showing.src} alt={showing.alt} />
<div class="tools">
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
</div>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
{/if}
<svelte:window
onkeydown={(e) => {
if (showing && e.key === 'Escape') {
e.stopPropagation();
closeImage();
}
}}
/>
<style>
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
står alene — det er hele grunden til at åbne det. */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
}
.shot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
max-width: 100%;
max-height: 100%;
}
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
passe sig ind i den. */
.shot img {
min-height: 0;
max-width: 100%;
max-height: calc(100vh - 8rem);
object-fit: contain;
border-radius: var(--radius);
}
.tools {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
.tools button,
.tools a {
padding: var(--s2) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
text-decoration: none;
}
.tools button:hover,
.tools a:hover {
border-color: var(--accent);
}
.bad {
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
}
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
.page :global(img) {
cursor: zoom-in;
}
.wrap {
display: flex;
flex-direction: column;
+4
View File
@@ -173,6 +173,10 @@ export const da = {
'notes.copyCode': 'Kopiér',
'notes.copied': 'Kopieret',
'notes.copyFailed': 'Kunne ikke kopiere',
'notes.imageTitle': 'Billede',
'notes.imageCopy': 'Kopiér billedet',
'notes.imageCopied': 'Kopieret',
'notes.imageOpen': 'Åbn i nyt faneblad',
'notes.strike': 'Gennemstreget',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
+4
View File
@@ -170,6 +170,10 @@ export const en = {
'notes.copyCode': 'Copy',
'notes.copied': 'Copied',
'notes.copyFailed': 'Could not copy',
'notes.imageTitle': 'Image',
'notes.imageCopy': 'Copy the image',
'notes.imageCopied': 'Copied',
'notes.imageOpen': 'Open in a new tab',
'notes.strike': 'Strikethrough',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',