stash/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx
WithoutPants cc97e96eaa
Add wall zoom functionality (#6011)
* Show zoom slider when wall view active
* Add zoom functionality to scene wall
* Add zoom functionality to image wall
* Add zoom functionality to gallery wall
* Add zoom functionality for marker wall
2025-09-09 16:45:29 +10:00

252 lines
6.5 KiB
TypeScript

import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import * as GQL from "src/core/generated-graphql";
import Gallery, {
GalleryI,
PhotoProps,
RenderImageProps,
} from "react-photo-gallery";
import { ConfigurationContext } from "src/hooks/Config";
import { objectTitle } from "src/core/files";
import { Link, useHistory } from "react-router-dom";
import { TruncatedText } from "../Shared/TruncatedText";
import TextUtils from "src/utils/text";
import cx from "classnames";
import NavUtils from "src/utils/navigation";
import { markerTitle } from "src/core/markers";
function wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) {
const newTitle = markerTitle(sceneMarker);
const seconds = TextUtils.formatTimestampRange(
sceneMarker.seconds,
sceneMarker.end_seconds ?? undefined
);
if (newTitle) {
return `${newTitle} - ${seconds}`;
} else {
return seconds;
}
}
interface IMarkerPhoto {
marker: GQL.SceneMarkerDataFragment;
link: string;
onError?: (photo: PhotoProps<IMarkerPhoto>) => void;
}
export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
props: RenderImageProps<IMarkerPhoto>
) => {
const { configuration } = useContext(ConfigurationContext);
const playSound = configuration?.interface.soundOnPreview ?? false;
const showTitle = configuration?.interface.wallShowTitle ?? false;
const [active, setActive] = useState(false);
type style = Record<string, string | number | undefined>;
var divStyle: style = {
margin: props.margin,
display: "block",
};
if (props.direction === "column") {
divStyle.position = "absolute";
divStyle.left = props.left;
divStyle.top = props.top;
}
var handleClick = function handleClick(event: React.MouseEvent) {
if (props.onClick) {
props.onClick(event, { index: props.index });
}
};
const video = props.photo.src.includes("stream");
const ImagePreview = video ? "video" : "img";
const { marker } = props.photo;
const title = wallItemTitle(marker);
const tagNames = marker.tags.map((p) => p.name);
return (
<div
className={cx("wall-item", { "show-title": showTitle })}
role="button"
style={{
...divStyle,
width: props.photo.width,
height: props.photo.height,
}}
>
<ImagePreview
loading="lazy"
loop={video}
muted={!video || !playSound || !active}
autoPlay={video}
key={props.photo.key}
src={props.photo.src}
width={props.photo.width}
height={props.photo.height}
alt={props.photo.alt}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onClick={handleClick}
onError={() => {
props.photo.onError?.(props.photo);
}}
/>
<div className="lineargradient">
<footer className="wall-item-footer">
<Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>
{title && (
<TruncatedText
text={title}
lineCount={1}
className="wall-item-title"
/>
)}
<TruncatedText text={tagNames.join(", ")} />
</Link>
</footer>
</div>
</div>
);
};
interface IMarkerWallProps {
markers: GQL.SceneMarkerDataFragment[];
zoomIndex: number;
}
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
const MarkerGallery = Gallery as unknown as GalleryI<IMarkerPhoto>;
function getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) {
if (!srcSet.length) {
return "";
}
return (
srcSet.find((src) => !invalidSrcSet.includes(src)) ??
([...srcSet].pop() as string)
);
}
interface IFile {
width: number;
height: number;
}
function getDimensions(file?: IFile) {
const defaults = { width: 1280, height: 720 };
if (!file) return defaults;
return {
width: file.width || defaults.width,
height: file.height || defaults.height,
};
}
const breakpointZoomHeights = [
{ minWidth: 576, heights: [100, 120, 240, 360] },
{ minWidth: 768, heights: [120, 160, 240, 480] },
{ minWidth: 1200, heights: [120, 160, 240, 300] },
{ minWidth: 1400, heights: [160, 240, 300, 480] },
];
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
const history = useHistory();
const margin = 3;
const direction = "row";
const [erroredImgs, setErroredImgs] = useState<string[]>([]);
const handleError = useCallback((photo: PhotoProps<IMarkerPhoto>) => {
setErroredImgs((prev) => [...prev, photo.src]);
}, []);
useEffect(() => {
setErroredImgs([]);
}, [markers]);
const photos: PhotoProps<IMarkerPhoto>[] = useMemo(() => {
return markers.map((m, index) => {
const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]);
return {
marker: m,
src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs),
link: NavUtils.makeSceneMarkerUrl(m),
width,
height,
tabIndex: index,
key: m.id,
loading: "lazy",
alt: objectTitle(m),
onError: handleError,
};
});
}, [markers, erroredImgs, handleError]);
const onClick = useCallback(
(event, { index }) => {
history.push(photos[index].link);
},
[history, photos]
);
function columns(containerWidth: number) {
let preferredSize = 300;
let columnCount = containerWidth / preferredSize;
return Math.round(columnCount);
}
function targetRowHeight(containerWidth: number) {
let zoomHeight = 280;
breakpointZoomHeights.forEach((e) => {
if (containerWidth >= e.minWidth) {
zoomHeight = e.heights[zoomIndex];
}
});
return zoomHeight;
}
const renderImage = useCallback((props: RenderImageProps<IMarkerPhoto>) => {
return <MarkerWallItem {...props} />;
}, []);
return (
<div className="marker-wall">
{photos.length ? (
<MarkerGallery
photos={photos}
renderImage={renderImage}
onClick={onClick}
margin={margin}
direction={direction}
columns={columns}
targetRowHeight={targetRowHeight}
/>
) : null}
</div>
);
};
interface IMarkerWallPanelProps {
markers: GQL.SceneMarkerDataFragment[];
zoomIndex: number;
}
export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
markers,
zoomIndex,
}) => {
return <MarkerWall markers={markers} zoomIndex={zoomIndex} />;
};