208 lines
7.0 KiB
JavaScript
208 lines
7.0 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
/**
|
|
* LocalMediaGrid
|
|
* Lightweight replacement for react-grid-gallery inside the chat popover.
|
|
* Props:
|
|
* - images: Array<{ src, fullsize, filename?, isSelected? }>
|
|
* - onToggle(index)
|
|
*/
|
|
export function LocalMediaGrid({
|
|
images,
|
|
onToggle,
|
|
thumbSize = 100,
|
|
gap = 8,
|
|
minColumns = 3,
|
|
maxColumns = 12,
|
|
context = "default"
|
|
}) {
|
|
const containerRef = useRef(null);
|
|
const [cols, setCols] = useState(() => {
|
|
// Pre-calc initial columns to stabilize layout before images render
|
|
const count = images.length;
|
|
if (count === 0) return minColumns; // reserve minimal structure
|
|
if (count === 1 && context === "chat") return 1;
|
|
return Math.min(maxColumns, Math.max(minColumns, count));
|
|
});
|
|
const [justifyMode, setJustifyMode] = useState("start");
|
|
const [distributeExtra, setDistributeExtra] = useState(false);
|
|
const [loadedMap, setLoadedMap] = useState(() => new Map()); // filename -> boolean loaded
|
|
|
|
const handleImageLoad = useCallback((key) => {
|
|
setLoadedMap((prev) => {
|
|
if (prev.get(key)) return prev; // already loaded
|
|
const next = new Map(prev);
|
|
next.set(key, true);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Dynamically compute columns for all contexts to avoid auto-fit stretching gaps in email overlay
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const compute = () => {
|
|
// For non-chat (email / default) we rely on CSS auto-fill; only chat needs explicit column calc & distribution logic.
|
|
if (context !== "chat") {
|
|
setCols(images.length || 0); // retain count for ARIA semantics; not used for template when non-chat.
|
|
setDistributeExtra(false);
|
|
return;
|
|
}
|
|
const width = el.clientWidth;
|
|
if (!width) return;
|
|
const perCol = thumbSize + gap; // track + gap space
|
|
const fitCols = Math.max(1, Math.floor((width + gap) / perCol));
|
|
// base desired columns: up to how many images we have and how many fit
|
|
let finalCols = Math.min(images.length || 1, fitCols, maxColumns);
|
|
// enforce minimum columns to reserve layout skeleton (except when fewer images)
|
|
if (finalCols < minColumns && images.length >= minColumns) {
|
|
finalCols = Math.min(fitCols, minColumns);
|
|
}
|
|
// chat-specific clamp
|
|
if (context === "chat") {
|
|
finalCols = Math.min(finalCols, 4);
|
|
}
|
|
if (finalCols < 1) finalCols = 1;
|
|
setCols(finalCols);
|
|
setJustifyMode("start");
|
|
|
|
// Determine if there is leftover horizontal space that can't fit another column.
|
|
// Only distribute when we're at the maximum allowed columns for the context and images exceed or meet that count.
|
|
const contextMax = context === "chat" ? 4 : maxColumns;
|
|
const baseWidthNeeded = finalCols * thumbSize + (finalCols - 1) * gap;
|
|
const leftover = width - baseWidthNeeded;
|
|
const atMaxColumns = finalCols === contextMax && images.length >= finalCols;
|
|
// leftover must be positive but less than space needed for an additional column (perCol)
|
|
if (atMaxColumns && leftover > 0 && leftover < perCol) {
|
|
setDistributeExtra(true);
|
|
} else {
|
|
setDistributeExtra(false);
|
|
}
|
|
};
|
|
compute();
|
|
const ro = new ResizeObserver(() => compute());
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [images.length, thumbSize, gap, minColumns, maxColumns, context]);
|
|
|
|
const gridTemplateColumns = useMemo(() => {
|
|
if (context === "chat") {
|
|
if (distributeExtra) {
|
|
return `repeat(${cols}, minmax(${thumbSize}px, 1fr))`;
|
|
}
|
|
return `repeat(${cols}, ${thumbSize}px)`;
|
|
}
|
|
// Non-chat contexts: allow browser to auto-fill columns; fixed min (thumbSize) ensures squares; tracks expand to distribute remaining space.
|
|
return `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`;
|
|
}, [cols, thumbSize, distributeExtra, context]);
|
|
const stableWidth = undefined; // no fixed width
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e, idx) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onToggle(idx);
|
|
}
|
|
},
|
|
[onToggle]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="local-media-grid"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns,
|
|
gap,
|
|
maxHeight: 420,
|
|
overflowY: "auto",
|
|
overflowX: "hidden",
|
|
padding: 4,
|
|
justifyContent: justifyMode,
|
|
width: stableWidth
|
|
}}
|
|
ref={containerRef}
|
|
role="list"
|
|
aria-label="media thumbnails"
|
|
>
|
|
{images.map((img, idx) => (
|
|
<div
|
|
key={img.filename || idx}
|
|
role="listitem"
|
|
tabIndex={0}
|
|
aria-label={img.filename || `image ${idx + 1}`}
|
|
onClick={() => onToggle(idx)}
|
|
onKeyDown={(e) => handleKeyDown(e, idx)}
|
|
style={{
|
|
position: "relative",
|
|
border: img.isSelected ? "2px solid #1890ff" : "1px solid #ccc",
|
|
outline: "none",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
background: "#fafafa",
|
|
width: thumbSize,
|
|
height: thumbSize,
|
|
overflow: "hidden",
|
|
boxSizing: "border-box"
|
|
}}
|
|
>
|
|
{(() => {
|
|
const key = img.filename || idx;
|
|
const loaded = loadedMap.get(key) === true;
|
|
return (
|
|
<>
|
|
{!loaded && (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "#f0f0f0",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 10,
|
|
color: "#bbb"
|
|
}}
|
|
>
|
|
{/* simple skeleton; no shimmer to reduce cost */}…
|
|
</div>
|
|
)}
|
|
<img
|
|
src={img.src}
|
|
alt={img.filename || img.caption || "thumbnail"}
|
|
loading="lazy"
|
|
onLoad={() => handleImageLoad(key)}
|
|
style={{
|
|
width: thumbSize,
|
|
height: thumbSize,
|
|
objectFit: "cover",
|
|
display: "block",
|
|
borderRadius: 4,
|
|
opacity: loaded ? 1 : 0,
|
|
transition: "opacity .25s ease"
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
})()}
|
|
{img.isSelected && (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "rgba(24,144,255,0.45)",
|
|
borderRadius: 4
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LocalMediaGrid;
|