fix(media-gallery): harden download filename + zoom/pan from review

- mediaFilename: only skip appending an extension when the body already ends in
  a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
  gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
  dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:28:27 -04:00
co-authored by Claude Opus 4.8
parent 8a92821a48
commit f9af363dd6
+24 -6
View File
@@ -135,10 +135,17 @@ function formatBytes(bytes: number): string {
} }
// A sensible download filename: prefer the event body/filename; if it has no // A sensible download filename: prefer the event body/filename; if it has no
// extension, append one derived from the mimetype so the saved file opens. // plausible extension already, append one derived from the mimetype so the
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
// so "Screenshot 2024.01.05" still gets a real extension appended.
function hasFileExtension(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot <= 0 || dot === name.length - 1) return false;
return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
}
function mediaFilename(body: string, mimeType?: string): string { function mediaFilename(body: string, mimeType?: string): string {
const name = body.trim() || 'media'; const name = body.trim() || 'media';
if (name.includes('.')) return name; if (hasFileExtension(name)) return name;
const ext = mimeType ? mimeTypeToExt(mimeType) : ''; const ext = mimeType ? mimeTypeToExt(mimeType) : '';
return ext ? `${name}.${ext}` : name; return ext ? `${name}.${ext}` : name;
} }
@@ -254,7 +261,12 @@ function LightboxMedia({
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
display: 'block', display: 'block',
cursor, cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`, // translate is nested inside scale(), so it runs in scaled space —
// divide by zoom so a dragged pixel moves the image one screen pixel
// (1:1 with the cursor) rather than `zoom` pixels.
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
pan.translateY / zoom
}px)`,
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out', transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
willChange: 'transform', willChange: 'transform',
}} }}
@@ -313,7 +325,7 @@ function Lightbox({
(e: React.WheelEvent) => { (e: React.WheelEvent) => {
if (!isImage) return; if (!isImage) return;
if (e.deltaY < 0) zoomIn(); if (e.deltaY < 0) zoomIn();
else zoomOut(); else if (e.deltaY > 0) zoomOut();
}, },
[isImage, zoomIn, zoomOut], [isImage, zoomIn, zoomOut],
); );
@@ -372,14 +384,20 @@ function Lightbox({
{index + 1} / {items.length} {index + 1} / {items.length}
</Text> </Text>
{isImage && ( {isImage && (
<Box shrink="No" alignItems="Center" gap="100" aria-label="Zoom controls"> <Box
shrink="No"
alignItems="Center"
gap="100"
role="group"
aria-label="Zoom controls"
>
<IconButton <IconButton
variant="Surface" variant="Surface"
size="300" size="300"
radii="300" radii="300"
aria-label="Zoom out" aria-label="Zoom out"
onClick={zoomOut} onClick={zoomOut}
disabled={zoom <= 0.1} disabled={zoom <= 0.2}
> >
<Icon size="50" src={Icons.Minus} /> <Icon size="50" src={Icons.Minus} />
</IconButton> </IconButton>