311 lines
8.6 KiB
TypeScript
311 lines
8.6 KiB
TypeScript
import React, { useMemo, useRef, useState } from "react";
|
||
import subtractHomeIcon from "./subtract-home.svg";
|
||
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;
|
||
selectedSection: number;
|
||
onSectionChange: (section: number) => void;
|
||
}
|
||
|
||
// 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,
|
||
selectedSection,
|
||
onSectionChange,
|
||
}: SightFramePreviewProps) {
|
||
const token = localStorage.getItem("token") ?? "";
|
||
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;
|
||
|
||
// Handle \n line breaks
|
||
if (sightName.includes("\n")) {
|
||
return sightName.split("\n").map((line, i) => (
|
||
<React.Fragment key={i}>
|
||
{i > 0 && <br />}
|
||
{line}
|
||
</React.Fragment>
|
||
));
|
||
}
|
||
|
||
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 calculatedLineHeight = Math.max(
|
||
100,
|
||
Math.min(120, 120 - (sightName.replace(/\n/g, "").length / 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={() => onSectionChange(-1)}
|
||
>
|
||
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
|
||
</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={() => {
|
||
onSectionChange(index);
|
||
onArticleSelect(index);
|
||
}}
|
||
>
|
||
{article.heading}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|