refactor(cuesheet): reserve the space an image will actually take

We were remembering the aspect ratio and reserving the full width of the
column, which over-reserves for an image smaller than the column: the row
would settle to a smaller size once the image was shown.

We now remember the size of the image and express the reservation in CSS
as min(100%, width), which is what the image itself resolves to at any
column width. Column sizes are applied as CSS variables and do not
re-render the cells, so the reservation follows a resize on its own.

Also groups the tests around the behaviour they describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
This commit is contained in:
Claude
2026-08-30 10:52:39 +00:00
parent 9efac0a60a
commit e28b06efed
4 changed files with 62 additions and 70 deletions
@@ -1,38 +1,26 @@
import { describe, expect, test } from 'vitest';
import { getRememberedAspectRatio, rememberAspectRatio } from '../imageDimensions';
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
/** stand-in for a loaded HTMLImageElement */
function makeImage(naturalWidth: number, naturalHeight: number) {
return { naturalWidth, naturalHeight } as HTMLImageElement;
}
describe('image dimensions', () => {
test('remembers the aspect ratio of a loaded image', () => {
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(null);
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
rememberAspectRatio('http://ontime.local/image.png', makeImage(1920, 1080));
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(1920 / 1080);
});
test('handles images which have not loaded', () => {
rememberAspectRatio('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedAspectRatio('http://ontime.local/broken.png')).toBe(null);
});
test('handles missing values', () => {
expect(getRememberedAspectRatio(undefined)).toBe(null);
expect(getRememberedAspectRatio('')).toBe(null);
expect(() => rememberAspectRatio('', makeImage(100, 100))).not.toThrow();
});
test('forgets the least recently used entries', () => {
for (let i = 0; i < 600; i++) {
rememberAspectRatio(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedAspectRatio('http://ontime.local/0.png')).toBe(null);
expect(getRememberedAspectRatio('http://ontime.local/599.png')).toBe(2);
});
// an image which failed to load has no size to offer
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
});
test('We keep the most recently seen images, older entries are forgotten', () => {
for (let i = 0; i < 600; i++) {
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
});
+19 -17
View File
@@ -4,44 +4,46 @@
* A re-mounted image has no dimensions until it is available,
* which makes the row change height and the table shift under the user.
*
* We remember the aspect ratio of the images we have already seen
* We remember the size of the images we have already seen
* so that we can reserve the space they will take.
* This only holds two numbers per image: we leave the image data itself to the browser cache,
* which knows better than us when memory should be released.
*/
/** how many aspect ratios we remember, this is only a few bytes per entry */
export interface ImageDimensions {
width: number;
height: number;
}
/** how many sizes we remember, this is only a few bytes per entry */
const maxSize = 500;
const aspectRatios = new Map<string, number>();
const dimensions = new Map<string, ImageDimensions>();
/**
* @returns the aspect ratio of a previously loaded image, if we have seen it before
* @returns the size of a previously loaded image, if we have seen it before
*/
export function getRememberedAspectRatio(src: string | null | undefined): number | null {
if (!src) {
return null;
}
return aspectRatios.get(src) ?? null;
export function getRememberedDimensions(src: string): ImageDimensions | null {
return dimensions.get(src) ?? null;
}
/**
* Records the aspect ratio of a loaded image
* Records the size of a loaded image
*/
export function rememberAspectRatio(src: string | null | undefined, image: HTMLImageElement) {
if (!src || image.naturalHeight === 0) {
export function rememberDimensions(src: string, image: HTMLImageElement) {
if (image.naturalHeight === 0) {
return;
}
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
aspectRatios.delete(src);
aspectRatios.set(src, image.naturalWidth / image.naturalHeight);
dimensions.delete(src);
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
while (aspectRatios.size > maxSize) {
const oldest = aspectRatios.keys().next();
while (dimensions.size > maxSize) {
const oldest = dimensions.keys().next();
if (oldest.done) {
return;
}
aspectRatios.delete(oldest.value);
dimensions.delete(oldest.value);
}
}