Compare commits

...
Author SHA1 Message Date
Kristian Wind 95e02b567b Merge pull request #104 from kristianwind/billedvisning-stoerre
CI / OpenAPI is valid (push) Successful in 18s
CI / End-to-end (push) Failing after 4m58s
CI / Go (push) Failing after 5m30s
CI / Docker image builds (push) Successful in 2m39s
fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
2026-10-18 23:46:13 +02:00
kristianwindandClaude Opus 5.5 93470259d2 fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
Billedvisningen havde kun max-width/max-height, så den kunne gøre et billede
mindre og aldrig større. Målt i den rigtige app: et 600×400-billede stod 600 px
i noten og 600 i visningen, og et højt telefon-skærmbillede stod 701 px bredt i
noten og 357 i visningen. På en telefon var der ingen forskel overhovedet.

Nu fylder billedet skærmen (også når det skal forstørres), og et klik på
billedet — eller knappen "Fuld størrelse" — viser det i egne pixels, aldrig
smallere end skærmen, med rulning hen til det sted, man klikkede. Knapperne
ligger fast nederst, så de kan nås, uanset hvor man har rullet hen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:37:10 +02:00
Kristian Wind 6a5228aed8 Merge pull request #103 from kristianwind/alarm-i-saetningen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m53s
CI / Docker image builds (push) Successful in 3m38s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 27m13s
feat(opgaver): alarmen kan tales ind, og kan være standard
2026-10-08 20:40:44 +02:00
4 changed files with 213 additions and 29 deletions
+78
View File
@@ -5440,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* Et billede bliver større af at blive åbnet — ikke mindre.
*
* Rapporteret som "billederne er meget små, når man klikker på dem". Ruden havde
* kun `max-width` og `max-height`, så den kunne gøre et billede mindre og aldrig
* større. Målt i den rigtige app: et lille billede (600×400) stod 600 pixels bredt
* i noten og 600 i ruden, og et højt skærmbillede fra en telefon stod 701 bredt i
* noten og 357 i ruden. At klikke for at se billedet bedre gav et dårligere billede.
*
* Målt på kasserne, med tal: "billedet er synligt" var sandt hele tiden.
*/
test('et åbnet billede fylder skærmen og kan ses i fuld størrelse', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Billednote med mål');
const paste = (w, h) =>
body.evaluate(async (el, [w, h]) => {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
g.fillStyle = '#c33';
g.fillRect(0, 0, w, h);
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
const data = new DataTransfer();
data.items.add(new File([blob], 'skud.png', { type: 'image/png' }));
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
}, [w, h]);
const viewport = page.viewportSize();
const shot = page.getByRole('dialog', { name: 'Billede' });
const big = shot.locator('img');
// Et lille billede: ruden forstørrer det, til det fylder skærmen i den ene led.
await paste(600, 400);
const small = page.locator('[aria-label="Notens tekst"] img').last();
await expect(small).toHaveJSProperty('complete', true);
const inNote = await small.boundingBox();
await small.click();
await expect(big).toHaveClass(/sized/);
const fit = await big.boundingBox();
expect(fit.width).toBeGreaterThan(inNote.width * 1.5);
expect(fit.width).toBeLessThanOrEqual(viewport.width);
expect(fit.y + fit.height).toBeLessThanOrEqual(viewport.height);
// Fuld størrelse er aldrig mindre end det, man kom fra.
await shot.getByRole('button', { name: 'Fuld størrelse' }).click();
const full = await big.boundingBox();
expect(full.width).toBeGreaterThanOrEqual(fit.width);
await shot.getByRole('button', { name: 'Tilpas skærmen' }).click();
await page.keyboard.press('Escape');
await expect(shot).toHaveCount(0);
// Et højt skærmbillede fra en telefon: i fuld størrelse er det mindst lige så
// bredt som i noten, og ruden ruller, så hele billedet kan nås.
await paste(1170, 2532);
const tall = page.locator('[aria-label="Notens tekst"] img').last();
await expect(tall).toHaveJSProperty('complete', true);
const tallInNote = await tall.boundingBox();
await tall.click();
await expect(big).toHaveClass(/sized/);
await big.click();
const tallFull = await big.boundingBox();
expect(tallFull.width).toBeGreaterThanOrEqual(tallInNote.width);
expect(tallFull.width).toBeGreaterThanOrEqual(1170);
const scroll = await shot.evaluate((d) => d.scrollHeight - d.clientHeight);
expect(scroll).toBeGreaterThan(1000);
// Et klik på billedet igen bringer det tilbage til skærmens mål.
await big.click();
const back = await big.boundingBox();
expect(back.y + back.height).toBeLessThanOrEqual(viewport.height);
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
+131 -29
View File
@@ -13,6 +13,7 @@
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { tick } from 'svelte';
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
@@ -756,6 +757,8 @@
if (image) {
event.preventDefault();
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
zoomed = false;
natural = null;
copied = false;
copyError = '';
return;
@@ -769,6 +772,49 @@
let copied = $state(false);
let copyError = $state('');
// Ruden viste billedet med `max-width`/`max-height` og intet andet, så det kunne
// blive mindre, men aldrig større. Det var mindre end i noten, det kom fra: et
// skærmbillede fra en telefon på en skærm i bredformat stod 701 pixels bredt i
// arket og 357 i ruden, og et lille billede blev slet ikke forstørret. Nu fylder
// det skærmen — også når det skal forstørres for at gøre det — og et klik på
// billedet viser det i sin egen størrelse, til at rulle rundt i.
let zoomed = $state(false);
/** @type {{ w: number, h: number } | null} */
let natural = $state(null);
/** @type {HTMLDivElement | null} */
let lightbox = $state(null);
/** @param {Event & { currentTarget: HTMLImageElement }} event */
function measured(event) {
const img = event.currentTarget;
natural = { w: img.naturalWidth, h: img.naturalHeight };
}
/**
* Skift mellem "hele skærmen" og "fuld størrelse".
*
* Fra et klik på billedet rulles der hen til det sted, man klikkede, så man
* ikke lander i øverste venstre hjørne og skal lede efter det, man ville se.
*
* @param {MouseEvent} [event]
*/
async function toggleZoom(event) {
const img = lightbox?.querySelector('.shot');
let fx = 0.5;
let fy = 0.5;
if (event?.clientX !== undefined && event.detail > 0 && img) {
const r = img.getBoundingClientRect();
fx = (event.clientX - r.left) / r.width;
fy = (event.clientY - r.top) / r.height;
}
zoomed = !zoomed;
await tick();
if (!zoomed || !img || !lightbox) return;
const r = img.getBoundingClientRect();
lightbox.scrollLeft += r.left + r.width * fx - lightbox.clientWidth / 2;
lightbox.scrollTop += r.top + r.height * fy - lightbox.clientHeight / 2;
}
function closeImage() {
showing = null;
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
@@ -1293,15 +1339,35 @@
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')}>
<div
class="lightbox"
class:zoomed
bind:this={lightbox}
onclick={closeImage}
role="dialog"
aria-modal="true"
aria-label={t('notes.imageTitle')}
style:--w={natural?.w}
style:--h={natural?.h}
>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<img
class="shot"
class:sized={natural}
src={showing.src}
alt={showing.alt}
onload={measured}
onclick={(e) => {
e.stopPropagation();
toggleZoom(e);
}}
/>
<!-- 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>
<div class="tools" onclick={(e) => e.stopPropagation()}>
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<button onclick={toggleZoom}>{zoomed ? t('notes.imageZoomOut') : t('notes.imageZoomIn')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
@@ -1318,43 +1384,77 @@
<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. */
står alene — det er hele grunden til at åbne det.
Centreret med `margin: auto` på billedet og ikke med `justify-content`: i
fuld størrelse er billedet større end ruden, og et flex-element, der er
centreret og for stort, stikker lige meget ud til begge sider — den venstre og
den øverste del kan man så ikke rulle hen til. */
.lightbox {
--pad: var(--s4);
--bar: 4rem;
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
background: rgb(0 0 0 / 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
overflow: auto;
overscroll-behavior: contain;
}
/* På en telefon bryder de fire knapper over i to rækker. */
@media (max-width: 600px) {
.lightbox {
--pad: var(--s2);
--bar: 6.5rem;
}
}
.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;
margin: auto;
flex: none;
max-width: calc(100vw - 2 * var(--pad));
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
border-radius: var(--radius);
cursor: zoom-in;
}
/* Så snart billedets egne mål kendes, fylder det skærmen: den største bredde,
der både er inden for skærmens bredde og — ved billedets form — inden for dens
højde. Det forstørrer et lille billede, hvad `max-width` alene aldrig gjorde. */
.shot.sized {
max-width: none;
max-height: none;
width: min(
calc(100vw - 2 * var(--pad)),
calc((100dvh - 2 * var(--pad) - var(--bar)) * var(--w) / var(--h))
);
height: auto;
}
/* Fuld størrelse: billedets egne pixels, og ruden ruller. Aldrig smallere end
skærmen — et lille billede ville ellers blive MINDRE af at blive forstørret, og
et højt skærmbillede fra en telefon skal kunne læses i hele skærmens bredde. */
.zoomed .shot.sized {
width: max(calc(var(--w) * 1px), calc(100vw - 2 * var(--pad)));
cursor: zoom-out;
}
/* Knapperne ligger fast nederst, så de er der, uanset hvor man har rullet hen
i et billede i fuld størrelse. */
.tools {
position: fixed;
left: 50%;
bottom: var(--s3);
transform: translateX(-50%);
display: flex;
gap: var(--s2);
align-items: center;
justify-content: center;
flex-wrap: wrap;
width: max-content;
max-width: calc(100vw - 2 * var(--s3));
}
.tools button,
@@ -1374,6 +1474,8 @@
}
.bad {
flex-basis: 100%;
text-align: center;
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
+2
View File
@@ -177,6 +177,8 @@ export const da = {
'notes.imageCopy': 'Kopiér billedet',
'notes.imageCopied': 'Kopieret',
'notes.imageOpen': 'Åbn i nyt faneblad',
'notes.imageZoomIn': 'Fuld størrelse',
'notes.imageZoomOut': 'Tilpas skærmen',
'notes.strike': 'Gennemstreget',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
+2
View File
@@ -174,6 +174,8 @@ export const en = {
'notes.imageCopy': 'Copy the image',
'notes.imageCopied': 'Copied',
'notes.imageOpen': 'Open in a new tab',
'notes.imageZoomIn': 'Full size',
'notes.imageZoomOut': 'Fit the screen',
'notes.strike': 'Strikethrough',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',