diff --git a/apps/client/src/common/utils/__tests__/imageDimensions.test.ts b/apps/client/src/common/utils/__tests__/imageDimensions.test.ts new file mode 100644 index 000000000..fa0e3a4d4 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/imageDimensions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; + +import { getRememberedAspectRatio, rememberAspectRatio } 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); + + rememberAspectRatio('http://ontime.local/image.png', makeImage(1920, 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); + }); +}); diff --git a/apps/client/src/common/utils/imageDimensions.ts b/apps/client/src/common/utils/imageDimensions.ts new file mode 100644 index 000000000..27519dccf --- /dev/null +++ b/apps/client/src/common/utils/imageDimensions.ts @@ -0,0 +1,47 @@ +/** + * Images in the cuesheet live inside a virtualised table: + * rows are unmounted when they leave the viewport and mounted again when they come back. + * 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 + * 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 */ +const maxSize = 500; + +const aspectRatios = new Map(); + +/** + * @returns the aspect ratio 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; +} + +/** + * Records the aspect ratio of a loaded image + */ +export function rememberAspectRatio(src: string | null | undefined, image: HTMLImageElement) { + if (!src || 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); + + while (aspectRatios.size > maxSize) { + const oldest = aspectRatios.keys().next(); + if (oldest.done) { + return; + } + aspectRatios.delete(oldest.value); + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx index a9bbc00be..c68612817 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx @@ -1,7 +1,8 @@ -import { memo } from 'react'; +import { memo, useState } from 'react'; import Button from '../../../../common/components/buttons/Button'; import Input from '../../../../common/components/input/input/Input'; +import { getRememberedAspectRatio, rememberAspectRatio } from '../../../../common/utils/imageDimensions'; import style from './EditableImage.module.scss'; @@ -14,6 +15,14 @@ interface EditableImageProps { export default memo(EditableImage); function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) { + const [isLoaded, setIsLoaded] = useState(false); + /** + * The cuesheet is virtualised: rows are unmounted once they leave the viewport. + * When the row comes back, we reserve the space the image took + * so that the table does not shift while the browser makes it available. + */ + const knownAspectRatio = getRememberedAspectRatio(initialValue); + const handleUpdate = (newValue: string) => { if (newValue === initialValue) { return; @@ -62,7 +71,17 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro )} - {Boolean(initialValue) && } + { + rememberAspectRatio(initialValue, event.currentTarget); + setIsLoaded(true); + }} + /** until the image is available, we reserve the space it took the last time we saw it */ + style={!isLoaded && knownAspectRatio !== null ? { aspectRatio: knownAspectRatio, width: '100%' } : undefined} + /> ); }