334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||
import { ReactMarkdownComponent } from "../../ReactMarkdown";
|
||
import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary";
|
||
import {
|
||
SightFrameThreeView,
|
||
ThreeViewHandle,
|
||
} from "./SightFrameThreeView";
|
||
import { MinusIcon, PlusIcon } from "./SightFrameThreeViewIcons";
|
||
import "./SightFramePreview.css";
|
||
|
||
interface ArticleMedia {
|
||
id: string;
|
||
media_type: number;
|
||
filename: string;
|
||
}
|
||
|
||
interface Article {
|
||
id: number;
|
||
heading: string;
|
||
body: string;
|
||
media: ArticleMedia[];
|
||
}
|
||
|
||
interface PreviewMediaData {
|
||
id: string;
|
||
media_type: number;
|
||
filename?: string;
|
||
}
|
||
|
||
interface SightFramePreviewProps {
|
||
sightName: string;
|
||
previewMedia: PreviewMediaData | null;
|
||
articles: Article[];
|
||
onArticleSelect: (index: number) => void;
|
||
previewFontSize?: number;
|
||
}
|
||
|
||
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
|
||
function renderCurrentMedia(
|
||
media: PreviewMediaData | ArticleMedia | null,
|
||
token: string,
|
||
threeViewResetKey: number,
|
||
threeViewControlRef: React.MutableRefObject<ThreeViewHandle | null>,
|
||
onZoomIn: () => void,
|
||
onZoomOut: () => void,
|
||
onThreeViewReset: () => void
|
||
) {
|
||
if (!media) return null;
|
||
|
||
const src = `${import.meta.env.VITE_KRBL_MEDIA}${media.id}/download?token=${token}`;
|
||
const className = "sfp-sight-frame-media-item";
|
||
|
||
switch (media.media_type) {
|
||
// image (1), gif (3), sprite (4)
|
||
case 1:
|
||
case 3:
|
||
case 4:
|
||
return (
|
||
<img
|
||
src={src}
|
||
alt=""
|
||
className={className}
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = "none";
|
||
}}
|
||
/>
|
||
);
|
||
|
||
// video
|
||
case 2:
|
||
return (
|
||
<video
|
||
muted
|
||
autoPlay
|
||
loop
|
||
playsInline
|
||
className={className}
|
||
onError={(e) => {
|
||
(e.target as HTMLVideoElement).style.display = "none";
|
||
}}
|
||
>
|
||
<source src={src} type="video/mp4" />
|
||
</video>
|
||
);
|
||
|
||
// panorama
|
||
case 5:
|
||
return (
|
||
<div
|
||
className={`${className} panorama-container`}
|
||
style={{ isolation: "isolate" }}
|
||
>
|
||
<ReactPhotoSphereViewer
|
||
src={src}
|
||
width={"100%"}
|
||
height={"100%"}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
// 3d
|
||
case 6:
|
||
return (
|
||
<div className={className} style={{ position: "relative" }}>
|
||
<ThreeViewErrorBoundary
|
||
resetKey={`sfp-${media.id}-${threeViewResetKey}`}
|
||
onReset={onThreeViewReset}
|
||
>
|
||
<SightFrameThreeView
|
||
key={`sfp-3d-${media.id}-${threeViewResetKey}`}
|
||
fileUrl={src}
|
||
width={"100%"}
|
||
height={"100%"}
|
||
controlRef={threeViewControlRef}
|
||
/>
|
||
</ThreeViewErrorBoundary>
|
||
<div className="sfp-three-d-controls-bar">
|
||
<button
|
||
type="button"
|
||
className="sfp-three-d-control-btn"
|
||
title="Уменьшить"
|
||
onPointerUp={onZoomOut}
|
||
>
|
||
<MinusIcon />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="sfp-three-d-control-btn"
|
||
title="Увеличить"
|
||
onPointerUp={onZoomIn}
|
||
>
|
||
<PlusIcon />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
default:
|
||
return (
|
||
<div className={className}>
|
||
<div className="sfp-sight-frame-media-placeholder">
|
||
Неподдерживаемый тип медиа
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
export function SightFramePreview({
|
||
sightName,
|
||
previewMedia,
|
||
articles,
|
||
onArticleSelect,
|
||
previewFontSize,
|
||
}: SightFramePreviewProps) {
|
||
const token = localStorage.getItem("token") ?? "";
|
||
|
||
// -1 = intro (section 0 in SightFrame)
|
||
const [selectedSection, setSelectedSection] = useState<number>(-1);
|
||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||
const threeViewControlRef = useRef<ThreeViewHandle | null>(null);
|
||
|
||
const currentArticle =
|
||
selectedSection >= 0 && selectedSection < articles.length
|
||
? articles[selectedSection]
|
||
: null;
|
||
|
||
const currentMedia: PreviewMediaData | ArticleMedia | null = currentArticle
|
||
? (currentArticle.media[0] ?? null)
|
||
: previewMedia;
|
||
|
||
const isCurrentMedia3D = currentMedia?.media_type === 6;
|
||
|
||
// Replicates processedSightName from SightFrame.jsx
|
||
const processedSightName = useMemo(() => {
|
||
if (!sightName) return sightName;
|
||
const namePattern =
|
||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||
const parts = sightName.split(namePattern);
|
||
if (parts.length > 1) {
|
||
return parts.map((part, index) => {
|
||
const initialsPattern =
|
||
/^[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+$/;
|
||
if (initialsPattern.test(part)) {
|
||
return (
|
||
<span key={index}>
|
||
<br />
|
||
{part}
|
||
</span>
|
||
);
|
||
}
|
||
return <span key={index}>{part}</span>;
|
||
});
|
||
}
|
||
return sightName;
|
||
}, [sightName]);
|
||
|
||
// Replicates titleLineHeight from SightFrame.jsx
|
||
const titleLineHeight = useMemo(() => {
|
||
if (!sightName) return "120%";
|
||
const textLength = sightName.length;
|
||
const calculatedLineHeight = Math.max(
|
||
100,
|
||
Math.min(120, 120 - (textLength / 10) * 1)
|
||
);
|
||
return `${calculatedLineHeight}%`;
|
||
}, [sightName]);
|
||
|
||
const isIntro = selectedSection === -1;
|
||
|
||
return (
|
||
<div className="sfp-sight-frame">
|
||
{/* media stack — always rendered, matches .sight-frame-media-stack */}
|
||
<div
|
||
className={`sfp-sight-frame-media-stack${isCurrentMedia3D ? " three-d-view" : ""}`}
|
||
>
|
||
{currentMedia ? (
|
||
renderCurrentMedia(
|
||
currentMedia,
|
||
token,
|
||
threeViewResetKey,
|
||
threeViewControlRef,
|
||
() => threeViewControlRef.current?.zoomIn?.(),
|
||
() => threeViewControlRef.current?.zoomOut?.(),
|
||
() => setThreeViewResetKey((k) => k + 1)
|
||
)
|
||
) : (
|
||
<div className="sfp-sight-frame-media-placeholder">
|
||
{isIntro ? "Медиа не добавлено" : "Нет медиа"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* content */}
|
||
<div className="sfp-sight-frame-content">
|
||
{/* title: intro-title (300px, 40px centered) or regular (24px with border) */}
|
||
<div
|
||
className={`sfp-sight-frame-title${isIntro ? " sfp-intro-title" : ""}`}
|
||
style={{
|
||
lineHeight: isIntro ? undefined : titleLineHeight,
|
||
...(isIntro && previewFontSize != null
|
||
? { fontSize: `${previewFontSize}px` }
|
||
: {}),
|
||
}}
|
||
>
|
||
<p
|
||
style={{
|
||
whiteSpace: "normal",
|
||
wordBreak: "break-word",
|
||
overflowWrap: "break-word",
|
||
}}
|
||
>
|
||
{isIntro ? processedSightName : sightName}
|
||
</p>
|
||
</div>
|
||
|
||
{/* text body — only for article sections */}
|
||
{!isIntro && currentArticle && (
|
||
<div className="sfp-sight-frame-text-wrapper">
|
||
<div className="sfp-sight-frame-text">
|
||
<ReactMarkdownComponent value={currentArticle.body} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* menu — matches .sight-frame-menu */}
|
||
<div className="sfp-sight-frame-menu">
|
||
{/* back arrow — shown when not on intro */}
|
||
{!isIntro && (
|
||
<button
|
||
className="sfp-back-btn"
|
||
type="button"
|
||
onClick={() => setSelectedSection(-1)}
|
||
>
|
||
<svg
|
||
width="20"
|
||
height="25"
|
||
viewBox="0 0 20 25"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
style={{ display: "block" }}
|
||
>
|
||
<defs>
|
||
<linearGradient
|
||
id="sfpGradient"
|
||
x1="0%"
|
||
y1="0%"
|
||
x2="100%"
|
||
y2="100%"
|
||
gradientUnits="userSpaceOnUse"
|
||
>
|
||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
|
||
</linearGradient>
|
||
<clipPath id="sfpClip">
|
||
<rect
|
||
width="20"
|
||
height="25"
|
||
fill="white"
|
||
transform="translate(12.5 0.5) rotate(90)"
|
||
/>
|
||
</clipPath>
|
||
</defs>
|
||
<g clipPath="url(#sfpClip)">
|
||
<path
|
||
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
|
||
fill="url(#sfpGradient)"
|
||
/>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* article nav buttons — matches .sight-frame-menu-point */}
|
||
{articles.map((article, index) => (
|
||
<button
|
||
key={article.id}
|
||
type="button"
|
||
className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`}
|
||
onClick={() => {
|
||
setSelectedSection(index);
|
||
onArticleSelect(index);
|
||
}}
|
||
>
|
||
{article.heading}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|