Files
ontime/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx
T
Claude f3cd541ef0 fix(cuesheet): report image problems instead of failing silently
A value which is not a link was discarded on blur without any feedback:
the text stayed in the field, so it looked like it had been saved. We now
mark the field and say what is expected. Paths served by ontime itself
(eg. /user/slide.png) are now accepted, they were rejected before.

An image which cannot be loaded showed a broken icon with no explanation.
This is what a dropbox share link does, since it serves an html page
rather than the image, so it is worth naming the problem.

We also reserve the space of an image while it is loading, using the
aspect ratio of the last time we saw it. Rows are unmounted while out of
view, so without it the row collapses and grows again on the way back,
shifting the table under the user.

Smaller items in the same cell: the lazy loading attribute only added a
gate before the request (the row is only mounted when it is already close
to the viewport), the image had no alt text, and two expressions could
never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-29 14:02:34 +00:00

118 lines
3.6 KiB
TypeScript

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';
interface EditableImageProps {
initialValue: string;
fieldLabel: string;
readOnly?: boolean;
updateValue: (newValue: string) => void;
}
export default memo(EditableImage);
/**
* An image is either hosted somewhere else
* or served by ontime itself (eg. a file placed in the user folder)
*/
export function isValidImageSource(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/');
}
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
const [isRejected, setIsRejected] = useState(false);
/** we keep track of the source itself, so that the state follows the value being shown */
const [failedSource, setFailedSource] = useState<string | null>(null);
const [loadedSource, setLoadedSource] = useState<string | null>(null);
const handleUpdate = (newValue: string) => {
const value = newValue.trim();
if (value === initialValue) {
setIsRejected(false);
return;
}
if (value !== '' && !isValidImageSource(value)) {
setIsRejected(true);
return;
}
setIsRejected(false);
updateValue(value);
};
const openInNewTab = () => {
if (initialValue) {
window.open(initialValue, '_blank', 'noopener,noreferrer');
}
};
if (!initialValue && readOnly) {
return null;
}
if (!initialValue) {
return (
<>
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
data-invalid={isRejected || undefined}
onChange={() => setIsRejected(false)}
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
/>
{isRejected && <span className={style.message}>Use a link (https://...) or a file in ontime (/user/...)</span>}
</>
);
}
/**
* 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 isLoaded = loadedSource === initialValue;
return (
<div className={style.imageCell}>
{!readOnly && (
<div className={style.overlay}>
<Button onClick={openInNewTab}>Preview</Button>
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
Delete
</Button>
</div>
)}
{failedSource === initialValue ? (
<span className={style.message}>Could not load image</span>
) : (
<img
src={initialValue}
alt={fieldLabel}
className={style.image}
onLoad={(event) => {
rememberAspectRatio(initialValue, event.currentTarget);
setLoadedSource(initialValue);
}}
onError={() => setFailedSource(initialValue)}
/** 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}
/>
)}
</div>
);
}