From d380b2570fa99122b33d1c2c34ae9d887938e4d6 Mon Sep 17 00:00:00 2001 From: itoshi Date: Fri, 10 Apr 2026 16:09:48 +0300 Subject: [PATCH] feat: update right widget preview --- .../RightWidgetTab/SightFramePreview.css | 267 ++++++++++++++ .../RightWidgetTab/SightFramePreview.tsx | 326 ++++++++++++++++++ .../RightWidgetTab/SightFrameThreeView.tsx | 178 ++++++++++ .../SightFrameThreeViewIcons.tsx | 55 +++ .../SightTabs/RightWidgetTab/index.tsx | 175 +--------- 5 files changed, 840 insertions(+), 161 deletions(-) create mode 100644 src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css create mode 100644 src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx create mode 100644 src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx create mode 100644 src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css new file mode 100644 index 0000000..3db37c5 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css @@ -0,0 +1,267 @@ +@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;600&display=swap"); + +.sfp-sight-frame { + display: flex; + flex-direction: column; + align-items: center; + width: 550px; + border-radius: 10px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; +} + +.sfp-sight-frame-media-stack { + margin-top: 2px; + border-radius: 10px 10px 0 0; + position: relative; + width: calc(100% - 4px); + height: 300px; + overflow: hidden; + background: #111; +} + +.sfp-sight-frame-media-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + opacity: 1; + z-index: 1; + pointer-events: auto; +} + +.sfp-sight-frame-media-item video { + width: 100%; + height: 100%; + object-fit: fill; +} + +.sfp-sight-frame-media-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.3); + font-family: "Roboto"; + font-size: 14px; +} + +.sfp-sight-frame-content { + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} + +.sfp-sight-frame-title { + display: flex; + align-items: center; + padding: 7px 16px; + width: 100%; + text-align: left; + font-family: "Roboto"; + font-size: 24px; + font-weight: 600; + line-height: 120%; + border-bottom: 1px solid rgba(255, 255, 255, 0.8); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.04) 100% + ), + rgba(179, 165, 152, 0.72); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + box-sizing: border-box; + color: white; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; +} + +.sfp-sight-frame-title p { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal !important; + margin: 0; + width: 100%; + flex: 1; + min-width: 0; + max-width: 100%; +} + +.sfp-intro-title { + height: 300px; + justify-content: center; + align-items: center; + border-bottom: none; + text-align: center; + font-size: 40px; + font-weight: 600; + line-height: 150%; +} + +.sfp-sight-frame-text-wrapper { + flex-grow: 1; + padding: 16px; + box-sizing: border-box; + max-height: calc(80vh - 354px); + min-height: 80px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar { + width: 5px; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.4); + border-radius: 1px; +} + +.sfp-sight-frame-text { + padding-right: 10px; + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sfp-sight-frame-text p, +.sfp-sight-frame-text h1, +.sfp-sight-frame-text h2, +.sfp-sight-frame-text h3, +.sfp-sight-frame-text li { + color: #fff; +} + +.sfp-sight-frame-menu { + position: relative; + padding: 7px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-around; + border-radius: 0px 0px 10px 10px; + border-top: 1px solid rgba(255, 255, 255, 0.8); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(179, 165, 152, 0.4); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); + box-sizing: border-box; + flex-shrink: 0; +} + +.sfp-sight-frame-menu-point { + background: none; + border: none; + color: #fff; + text-align: center; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + padding: 8px 12px; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.1s ease, + color 0.1s ease; +} + +.sfp-sight-frame-menu-point.active { + border-bottom: 2px solid #fff; + font-weight: 600; +} + +.sfp-back-btn { + position: absolute; + left: 10px; + margin-top: -4.5px; + z-index: 1; + padding: 4.5px 7.5px 4.5px 15px; + cursor: pointer; + background: none; + border: none; +} + +.sfp-watermark { + position: absolute; + width: 51px; + height: auto; + left: 8px; + top: 8px; + z-index: 10; +} + +.sfp-three-d-controls-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 0 30px; + background: rgba(64, 64, 64, 0.6); + z-index: 10; + pointer-events: none; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: min-content; + pointer-events: auto; + color: white; + transition: opacity 0.2s ease; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn:hover { + opacity: 0.85; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn svg { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.sfp-sight-frame-media-stack.three-d-view { + background-color: #111 !important; +} diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx new file mode 100644 index 0000000..b086323 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx @@ -0,0 +1,326 @@ +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; +} + +// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes +function renderCurrentMedia( + media: PreviewMediaData | ArticleMedia | null, + token: string, + threeViewResetKey: number, + threeViewControlRef: React.MutableRefObject, + 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 ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ); + + // video + case 2: + return ( + + ); + + // panorama + case 5: + return ( +
+ +
+ ); + + // 3d + case 6: + return ( +
+ + + +
+ + +
+
+ ); + + default: + return ( +
+
+ Неподдерживаемый тип медиа +
+
+ ); + } +} + +export function SightFramePreview({ + sightName, + previewMedia, + articles, + onArticleSelect, +}: SightFramePreviewProps) { + const token = localStorage.getItem("token") ?? ""; + + // -1 = intro (section 0 in SightFrame) + const [selectedSection, setSelectedSection] = useState(-1); + const [threeViewResetKey, setThreeViewResetKey] = useState(0); + const threeViewControlRef = useRef(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 ( + +
+ {part} +
+ ); + } + return {part}; + }); + } + 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 ( +
+ {/* media stack — always rendered, matches .sight-frame-media-stack */} +
+ {currentMedia ? ( + renderCurrentMedia( + currentMedia, + token, + threeViewResetKey, + threeViewControlRef, + () => threeViewControlRef.current?.zoomIn?.(), + () => threeViewControlRef.current?.zoomOut?.(), + () => setThreeViewResetKey((k) => k + 1) + ) + ) : ( +
+ {isIntro ? "Медиа не добавлено" : "Нет медиа"} +
+ )} +
+ + {/* content */} +
+ {/* title: intro-title (300px, 40px centered) or regular (24px with border) */} +
+

+ {isIntro ? processedSightName : sightName} +

+
+ + {/* text body — only for article sections */} + {!isIntro && currentArticle && ( +
+
+ +
+
+ )} +
+ + {/* menu — matches .sight-frame-menu */} +
+ {/* back arrow — shown when not on intro */} + {!isIntro && ( + + )} + + {/* article nav buttons — matches .sight-frame-menu-point */} + {articles.map((article, index) => ( + + ))} +
+
+ ); +} diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx new file mode 100644 index 0000000..417ed68 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx @@ -0,0 +1,178 @@ +import { Canvas, useThree } from "@react-three/fiber"; +import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; +import React, { useEffect, Suspense } from "react"; +import * as THREE from "three"; + +const BACKGROUND_COLOR = 0x111111; + +export interface ThreeViewHandle { + zoomIn: () => void; + zoomOut: () => void; +} + +interface ThreeViewProps { + fileUrl: string; + width?: string; + height?: string; + onLoad?: () => void; + onError?: (error: string) => void; + onAspectRatioCalculated?: (ratio: number) => void; + controlRef?: React.MutableRefObject; +} + +const ZOOM_FACTOR = 1.2; +const MIN_DISTANCE = 1; +const MAX_DISTANCE = 100; + +const ZoomController = ({ + controlRef, +}: { + controlRef?: React.MutableRefObject; +}) => { + const { camera, controls } = useThree(); + + useEffect(() => { + if (!controlRef || !controls) return; + const orbit = controls as unknown as { + target: THREE.Vector3; + getDistance: () => number; + setDistance?: (d: number) => void; + }; + const zoomIn = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.max(MIN_DISTANCE, dist / ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + const zoomOut = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.min(MAX_DISTANCE, dist * ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + controlRef.current = { zoomIn, zoomOut }; + return () => { + controlRef.current = null; + }; + }, [camera, controls, controlRef]); + + return null; +}; + +const AutoResize = () => { + const { gl, camera } = useThree(); + useEffect(() => { + const handleResize = () => { + const parent = gl.domElement.parentElement; + if (!parent) return; + gl.setSize(parent.clientWidth, parent.clientHeight); + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = parent.clientWidth / parent.clientHeight; + camera.updateProjectionMatrix(); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [gl, camera]); + return null; +}; + +const Model = ({ + fileUrl, + onLoad, +}: { + fileUrl: string; + onLoad?: () => void; +}) => { + const { scene } = useGLTF(fileUrl); + + useEffect(() => { + if (scene) { + scene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const m = (child as THREE.Mesh) + .material as THREE.MeshStandardMaterial; + if (m) { + m.envMapIntensity = 1.5; + m.needsUpdate = true; + } + } + }); + onLoad?.(); + } + }, [scene, onLoad]); + + return ; +}; + +export const SightFrameThreeView: React.FC = ({ + fileUrl, + width = "100%", + height = "100%", + onLoad, + onError, + controlRef, +}) => { + return ( +
+ onError?.("Canvas error")} + > + + {controlRef && } + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx new file mode 100644 index 0000000..a854ffa --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +export const MinusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const PlusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); diff --git a/src/widgets/SightTabs/RightWidgetTab/index.tsx b/src/widgets/SightTabs/RightWidgetTab/index.tsx index 234e2c7..d2b2d65 100644 --- a/src/widgets/SightTabs/RightWidgetTab/index.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/index.tsx @@ -1,7 +1,6 @@ import { Box, Button, - Paper, Typography, Menu, MenuItem, @@ -22,10 +21,9 @@ import { LanguageSwitcher, MediaArea, MediaAreaForSight, - ReactMarkdownComponent, ReactMarkdownEditor, } from "@widgets"; -import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; +import { Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; import { toast } from "react-toastify"; @@ -36,6 +34,7 @@ import { Draggable, DropResult, } from "@hello-pangea/dnd"; +import { SightFramePreview } from "./SightFramePreview"; export const RightWidgetTab = observer( ({ value, index }: { value: number; index: number }) => { @@ -213,7 +212,7 @@ export const RightWidgetTab = observer( - + @@ -455,163 +454,17 @@ export const RightWidgetTab = observer( - {type === "article" && ( - - {activeArticleIndex !== null && ( - - - {sight[language].right[activeArticleIndex].media.length > - 0 ? ( - - - - ) : ( - - - - )} - - - - {sight[language].right[activeArticleIndex].heading || - "Выберите статью"} - - - - - {sight[language].right[activeArticleIndex].body ? ( - - ) : ( - - Предпросмотр статьи появится здесь - - )} - - - {sight[language].right.length > 0 && - sight[language].right.map((article, index) => ( - - ))} - - - - )} - - )} + + { + handleSelectArticle(idx); + setType("article"); + }} + /> +