feat: embed Beam viewer in issue peek

This commit is contained in:
DCCONSTRUCTIONS 2026-06-05 12:46:47 +03:00
parent 15c9fb1df1
commit 867f7bc558
4 changed files with 492 additions and 221 deletions

View File

@ -24,6 +24,7 @@ import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons"; import { getFileIcon } from "@/components/icons";
import { import {
buildBeamViewerUrl, buildBeamViewerUrl,
dispatchBeamViewerOpenEvent,
fetchBeamConversionStatus, fetchBeamConversionStatus,
getBeamViewerAttachment, getBeamViewerAttachment,
isBeamModelFile, isBeamModelFile,
@ -62,7 +63,9 @@ const getUsableBeamViewerUrl = (url: string | undefined): string | undefined =>
if (!url) return undefined; if (!url) return undefined;
try { try {
const parsedUrl = new URL(url, "http://localhost"); const parsedUrl = new URL(url, "http://localhost");
return parsedUrl.searchParams.has("url") || parsedUrl.searchParams.has("src") || parsedUrl.searchParams.has("projectId") return parsedUrl.searchParams.has("url") ||
parsedUrl.searchParams.has("src") ||
parsedUrl.searchParams.has("projectId")
? url ? url
: undefined; : undefined;
} catch (_error) { } catch (_error) {
@ -135,7 +138,14 @@ const buildSyncedBeamViewerAttachment = (
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation(); const { t } = useTranslation();
// props // props
const { attachmentId, disabled, issueId, issueServiceType = EIssueServiceType.ISSUES, projectId, workspaceSlug } = props; const {
attachmentId,
disabled,
issueId,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
workspaceSlug,
} = props;
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { const {
@ -188,16 +198,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
beamViewer?.conversion?.message || beamViewer?.conversion?.message ||
"Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."; "Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.";
const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isModelViewerOpen, setIsModelViewerOpen] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false); const [isThumbnailError, setIsThumbnailError] = useState(false);
const openModelViewer = () => {
if (!modelViewerUrl) return;
dispatchBeamViewerOpenEvent({
downloadUrl: modelDownloadUrl,
fileExtension,
fileName: fullFileName,
fileSize: attachment?.attributes.size ?? 0,
issueId,
viewerUrl: modelViewerUrl,
});
};
const menuItems: TContextMenuItem[] = [ const menuItems: TContextMenuItem[] = [
...(modelViewerUrl ...(modelViewerUrl
? [ ? [
{ {
key: "view-model", key: "view-model",
action: () => { action: openModelViewer,
setIsModelViewerOpen(true);
},
title: "Посмотреть модель", title: "Посмотреть модель",
icon: Eye, icon: Eye,
}, },
@ -262,7 +280,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
isMounted = false; isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId); if (timeoutId) window.clearTimeout(timeoutId);
}; };
}, [attachmentId, attachmentService, beamViewer, fetchAttachments, fullFileName, issueId, projectId, storedModelViewerUrl, workspaceSlug]); }, [
attachmentId,
attachmentService,
beamViewer,
fetchAttachments,
fullFileName,
issueId,
projectId,
storedModelViewerUrl,
workspaceSlug,
]);
if (!attachment) return <></>; if (!attachment) return <></>;
@ -275,7 +303,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (modelViewerUrl) setIsModelViewerOpen(true); if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true); else setIsPreviewOpen(true);
}} }}
> >
@ -302,7 +330,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
{isBeamModel ? ( {isBeamModel ? (
isBeamConversionFailed ? ( isBeamConversionFailed ? (
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}> <Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
<div className="grid size-9 place-items-center text-red-400"> <div className="text-red-400 grid size-9 place-items-center">
<AlertTriangle className="size-9" /> <AlertTriangle className="size-9" />
</div> </div>
</Tooltip> </Tooltip>
@ -434,7 +462,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div> </div>
<div className="max-w-md"> <div className="max-w-md">
<div className="text-15 font-semibold text-primary"> <div className="text-15 font-semibold text-primary">
{isBeamConversionFailed ? "Ошибка дерева" : beamConversionStatusLabel ?? "Ожидает дерево"} {isBeamConversionFailed ? "Ошибка дерева" : (beamConversionStatusLabel ?? "Ожидает дерево")}
</div> </div>
<div className="mt-2 text-14 text-secondary"> <div className="mt-2 text-14 text-secondary">
{isBeamConversionFailed {isBeamConversionFailed
@ -466,55 +494,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div> </div>
</div> </div>
</ModalCore> </ModalCore>
<ModalCore
isOpen={isModelViewerOpen && !!modelViewerUrl}
handleClose={() => setIsModelViewerOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="nodedc-beam-viewer-modal h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-hidden !border-0 !bg-black !shadow-none !ring-0 !outline-none focus:!outline-none"
>
<div className="relative h-full w-full bg-black">
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div aria-hidden="true" />
<div className="min-w-0 max-w-[min(60vw,36rem)] text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
<div className="truncate text-13 font-semibold">{fullFileName}</div>
<div className="mt-0.5 text-11 text-white/70">{convertBytesToSize(attachment.attributes.size)}</div>
</div>
<div className="pointer-events-auto flex items-center justify-end gap-2">
{modelDownloadUrl && (
<a
href={modelDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
onClick={(e) => e.stopPropagation()}
>
<Download className="size-5" />
</a>
)}
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onClick={() => setIsModelViewerOpen(false)}
>
<X className="size-5" />
</button>
</div>
</div>
{modelViewerUrl && (
<iframe
key={modelViewerUrl}
src={modelViewerUrl}
title={`Beam Viewer: ${fullFileName}`}
tabIndex={-1}
className="h-full w-full border-0 outline-none ring-0 focus:outline-none focus:ring-0"
allow="fullscreen"
/>
)}
</div>
</ModalCore>
</> </>
); );
}); });

View File

@ -9,16 +9,19 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Download, Maximize2, Minimize2, X } from "lucide-react";
// plane imports // plane imports
import type { EditorRefApi } from "@plane/editor"; import type { EditorRefApi } from "@plane/editor";
import type { TNameDescriptionLoader } from "@plane/types"; import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
import { cn } from "@plane/utils"; import { cn, convertBytesToSize } from "@plane/utils";
import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpers/beam-viewer";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import useKeypress from "@/hooks/use-keypress"; import useKeypress from "@/hooks/use-keypress";
@ -89,6 +92,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false); const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false); const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => { const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
if (typeof window === "undefined") return 720; if (typeof window === "undefined") return 720;
@ -104,6 +109,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
const initialPeekWidthRef = useRef<number>(0); const initialPeekWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0); const initialMouseXRef = useRef<number>(0);
const livePeekWidthRef = useRef<number>(sidePeekWidth);
const resizeAnimationFrameRef = useRef<number | null>(null);
// store hooks // store hooks
const { const {
setPeekIssue, setPeekIssue,
@ -129,30 +136,69 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const isAnyLocalModalOpen = const isAnyLocalModalOpen =
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen; isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
const shouldUseBeamPeekShell = !embedIssue && peekMode === "side-peek" && !!beamPeekViewer;
const getMaxSidePeekWidth = useCallback(() => {
if (typeof window === "undefined") return 720;
const minWidth = 640;
if (shouldUseBeamPeekShell && !isBeamPeekFullscreen) {
return Math.max(minWidth, Math.floor((window.innerWidth - 24) / 2));
}
return Math.max(720, window.innerWidth - 48);
}, [isBeamPeekFullscreen, shouldUseBeamPeekShell]);
const stopPeekResizing = useCallback(() => { const stopPeekResizing = useCallback(() => {
if (resizeAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(resizeAnimationFrameRef.current);
resizeAnimationFrameRef.current = null;
}
if (shouldUseBeamPeekShell) {
issuePeekOverviewRef.current?.style.setProperty("--nodedc-beam-peek-width", `${livePeekWidthRef.current}px`);
setSidePeekWidth(livePeekWidthRef.current);
}
setIsResizingPeek(false); setIsResizingPeek(false);
}, []); }, [shouldUseBeamPeekShell]);
const handlePeekResize = useCallback( const handlePeekResize = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
if (!isResizingPeek) return; if (!isResizingPeek) return;
const maxWidth = Math.max(720, window.innerWidth - 48); const maxWidth = getMaxSidePeekWidth();
const minWidth = 640; const minWidth = 640;
const deltaX = event.clientX - initialMouseXRef.current; const deltaX = event.clientX - initialMouseXRef.current;
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth); const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
if (shouldUseBeamPeekShell) {
livePeekWidthRef.current = nextWidth;
if (resizeAnimationFrameRef.current === null) {
resizeAnimationFrameRef.current = window.requestAnimationFrame(() => {
issuePeekOverviewRef.current?.style.setProperty(
"--nodedc-beam-peek-width",
`${livePeekWidthRef.current}px`
);
resizeAnimationFrameRef.current = null;
});
}
return;
}
setSidePeekWidth(nextWidth); setSidePeekWidth(nextWidth);
}, },
[isResizingPeek] [getMaxSidePeekWidth, isResizingPeek, shouldUseBeamPeekShell]
); );
const startPeekResizing = useCallback( const startPeekResizing = useCallback(
(event: ReactMouseEvent) => { (event: ReactMouseEvent) => {
if (!shouldAllowPeekResize || peekMode !== "side-peek") return; if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
setIsResizingPeek(true); setIsResizingPeek(true);
initialPeekWidthRef.current = sidePeekWidth; initialPeekWidthRef.current = sidePeekWidth;
livePeekWidthRef.current = sidePeekWidth;
initialMouseXRef.current = event.clientX; initialMouseXRef.current = event.clientX;
}, },
[peekMode, shouldAllowPeekResize, sidePeekWidth] [peekMode, shouldAllowPeekResize, sidePeekWidth]
@ -160,18 +206,18 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
useEffect(() => { useEffect(() => {
const handleWindowResize = () => { const handleWindowResize = () => {
const maxWidth = Math.max(720, window.innerWidth - 48); const maxWidth = getMaxSidePeekWidth();
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth)); setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
}; };
window.addEventListener("resize", handleWindowResize); window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize); return () => window.removeEventListener("resize", handleWindowResize);
}, []); }, [getMaxSidePeekWidth]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const maxWidth = Math.max(720, window.innerWidth - 48); const maxWidth = getMaxSidePeekWidth();
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth); const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth)); window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
@ -179,8 +225,39 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
if (clampedWidth !== sidePeekWidth) { if (clampedWidth !== sidePeekWidth) {
setSidePeekWidth(clampedWidth); setSidePeekWidth(clampedWidth);
} }
}, [getMaxSidePeekWidth, sidePeekWidth]);
useEffect(() => {
livePeekWidthRef.current = sidePeekWidth;
}, [sidePeekWidth]); }, [sidePeekWidth]);
useEffect(
() => () => {
if (resizeAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(resizeAnimationFrameRef.current);
}
},
[]
);
useEffect(() => {
setBeamPeekViewer(null);
setIsBeamPeekFullscreen(false);
}, [issueId]);
useEffect(() => {
const handleOpenBeamViewer = (event: Event) => {
const detail = (event as CustomEvent<TBeamViewerOpenEventDetail>).detail;
if (!detail?.viewerUrl || detail.issueId !== issueId) return;
setPeekMode("side-peek");
setBeamPeekViewer(detail);
setIsBeamPeekFullscreen(false);
};
window.addEventListener(BEAM_VIEWER_OPEN_EVENT, handleOpenBeamViewer);
return () => window.removeEventListener(BEAM_VIEWER_OPEN_EVENT, handleOpenBeamViewer);
}, [issueId]);
useEffect(() => { useEffect(() => {
if (!isResizingPeek) return; if (!isResizingPeek) return;
@ -219,6 +296,11 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal"); const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
const dropdownElement = document.activeElement?.tagName === "INPUT"; const dropdownElement = document.activeElement?.tagName === "INPUT";
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (beamPeekViewer) {
setBeamPeekViewer(null);
setIsBeamPeekFullscreen(false);
return;
}
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) { if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
removeRoutePeekId(); removeRoutePeekId();
const issueElement = document.getElementById(`issue-${issueId}`); const issueElement = document.getElementById(`issue-${issueId}`);
@ -238,13 +320,17 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
shouldRenderPeekSurface shouldRenderPeekSurface
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300" ? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full", : "h-full w-full",
!embedIssue && "absolute z-[80]", !embedIssue && !shouldUseBeamPeekShell && "absolute z-[80]",
!embedIssue && { !embedIssue &&
"top-[5.35rem] right-3 bottom-[5.85rem] w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]": !shouldUseBeamPeekShell && {
"nodedc-peek-side-bound top-[5.35rem] right-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:max-w-[calc(100vw-1.5rem)] md:min-w-[640px]":
peekMode === "side-peek", peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
}, },
!embedIssue &&
shouldUseBeamPeekShell &&
"nodedc-beam-peek-issue-card relative h-full rounded-[28px] border md:min-w-[640px]",
shouldUseInteractiveEmbeddedLayout && { shouldUseInteractiveEmbeddedLayout && {
"relative ml-auto h-full rounded-[28px]": peekMode === "side-peek", "relative ml-auto h-full rounded-[28px]": peekMode === "side-peek",
"relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]": "relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]":
@ -257,19 +343,23 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement; const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
const content = ( const issuePanelStyle = {
<div className="w-full text-body-sm-regular"> width: shouldUseBeamPeekShell
{issueId && ( ? "100%"
<div : shouldAllowPeekResize && peekMode === "side-peek"
ref={issuePeekOverviewRef} ? `${sidePeekWidth}px`
className={peekOverviewIssueClassName} : undefined,
style={{
width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
boxShadow: boxShadow:
shouldRenderPeekSurface shouldRenderPeekSurface && !shouldUseBeamPeekShell
? "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)" ? "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)"
: undefined, : undefined,
}} };
const issuePanel = issueId ? (
<div
ref={shouldUseBeamPeekShell ? undefined : issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={issuePanelStyle}
> >
{shouldAllowPeekResize && peekMode === "side-peek" && ( {shouldAllowPeekResize && peekMode === "side-peek" && (
<div <div
@ -420,6 +510,78 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
</> </>
)} )}
</div> </div>
) : null;
const content = (
<div className="w-full text-body-sm-regular">
{shouldUseBeamPeekShell && beamPeekViewer ? (
<div
ref={issuePeekOverviewRef}
className="nodedc-beam-peek-shell"
data-fullscreen={isBeamPeekFullscreen ? "true" : "false"}
data-resizing={isResizingPeek ? "true" : "false"}
style={
{
"--nodedc-beam-peek-width": `${sidePeekWidth}px`,
} as CSSProperties
}
>
<section className="nodedc-beam-peek-viewer-card" aria-label="Beam Viewer">
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div aria-hidden="true" />
<div className="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
<div className="truncate text-13 font-semibold">{beamPeekViewer.fileName}</div>
<div className="mt-0.5 text-11 text-white/70">{convertBytesToSize(beamPeekViewer.fileSize)}</div>
</div>
<div className="pointer-events-auto flex items-center justify-end gap-2">
{beamPeekViewer.downloadUrl && (
<a
href={beamPeekViewer.downloadUrl}
download={beamPeekViewer.fileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
onClick={(event) => event.stopPropagation()}
title="Скачать оригинал"
>
<Download className="size-5" />
</a>
)}
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onClick={() => setIsBeamPeekFullscreen((current) => !current)}
title={isBeamPeekFullscreen ? "Вернуть карточку" : "Во весь экран"}
>
{isBeamPeekFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</button>
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onClick={() => {
setBeamPeekViewer(null);
setIsBeamPeekFullscreen(false);
}}
title="Закрыть"
>
<X className="size-5" />
</button>
</div>
</div>
<iframe
key={beamPeekViewer.viewerUrl}
src={beamPeekViewer.viewerUrl}
title={`Beam Viewer: ${beamPeekViewer.fileName}`}
tabIndex={-1}
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
allow="fullscreen"
/>
{isResizingPeek && <div className="nodedc-beam-peek-resize-shield" aria-hidden="true" />}
</section>
<div className="nodedc-beam-peek-issue-slot">{issuePanel}</div>
</div>
) : (
issuePanel
)} )}
</div> </div>
); );

View File

@ -41,6 +41,21 @@ export type TBeamConversionStatus = {
updatedAt?: string; updatedAt?: string;
}; };
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
export type TBeamViewerOpenEventDetail = {
downloadUrl?: string;
fileExtension?: string;
fileName: string;
fileSize: number;
issueId: string;
viewerUrl: string;
};
export const dispatchBeamViewerOpenEvent = (detail: TBeamViewerOpenEventDetail) => {
window.dispatchEvent(new CustomEvent<TBeamViewerOpenEventDetail>(BEAM_VIEWER_OPEN_EVENT, { detail }));
};
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080"; const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = { const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
@ -73,7 +88,10 @@ const getFileExtension = (name: string): string => {
}; };
const getUploadGroupId = (parts: Array<string | undefined>): string => { const getUploadGroupId = (parts: Array<string | undefined>): string => {
const value = parts.filter(Boolean).join("_").replace(/[^a-zA-Z0-9_-]/g, "_"); const value = parts
.filter(Boolean)
.join("_")
.replace(/[^a-zA-Z0-9_-]/g, "_");
return value || "tasker"; return value || "tasker";
}; };
@ -142,9 +160,7 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
return beamViewer; return beamViewer;
}; };
export const fetchBeamConversionStatus = async ( export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
beamViewer: TBeamViewerAttachment
): Promise<TBeamConversionStatus> => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`); const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src)); statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
@ -181,7 +197,10 @@ export const uploadBeamModelFile = (
const apiBaseUrl = getBeamApiBaseUrl(); const apiBaseUrl = getBeamApiBaseUrl();
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`); const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
uploadUrl.searchParams.set("filename", file.name); uploadUrl.searchParams.set("filename", file.name);
uploadUrl.searchParams.set("projectId", getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])); uploadUrl.searchParams.set(
"projectId",
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString()); xhr.open("POST", uploadUrl.toString());

View File

@ -51,6 +51,7 @@
--nodedc-bottom-dock-visual-overlap: 0.625rem; --nodedc-bottom-dock-visual-overlap: 0.625rem;
--nodedc-expanded-footer-height: 4.95rem; --nodedc-expanded-footer-height: 4.95rem;
--nodedc-active-footer-offset: var(--nodedc-bottom-dock-offset, var(--nodedc-bottom-dock-height, 2.75rem)); --nodedc-active-footer-offset: var(--nodedc-bottom-dock-offset, var(--nodedc-bottom-dock-height, 2.75rem));
--nodedc-peek-bottom-offset: var(--nodedc-expanded-footer-height, 4.95rem);
--nodedc-list-property-chip-height: 2.12rem; --nodedc-list-property-chip-height: 2.12rem;
--nodedc-list-property-avatar-size: 1.9rem; --nodedc-list-property-avatar-size: 1.9rem;
--nodedc-list-property-icon-size: var(--nodedc-list-property-chip-height); --nodedc-list-property-icon-size: var(--nodedc-list-property-chip-height);
@ -345,31 +346,141 @@
0 8px 22px rgba(0, 0, 0, 0.24); 0 8px 22px rgba(0, 0, 0, 0.24);
} }
.nodedc-beam-viewer-modal { .nodedc-beam-peek-shell {
background: #000 !important; --nodedc-beam-peek-width: 720px;
border: 0 !important; position: fixed;
outline: none !important; top: 5.35rem;
box-shadow: none !important; left: 0.75rem;
-webkit-backdrop-filter: none !important; right: 0.75rem;
backdrop-filter: none !important; bottom: var(--nodedc-peek-bottom-offset);
z-index: 80;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--nodedc-beam-peek-width);
grid-template-rows: minmax(0, 1fr);
align-items: stretch;
column-gap: 0.75rem;
overflow: visible;
transition:
column-gap 260ms ease,
grid-template-columns 260ms ease,
opacity 220ms ease;
} }
.nodedc-beam-viewer-modal::before, .nodedc-beam-peek-shell[data-resizing="true"] {
.nodedc-beam-viewer-modal::after { transition: none;
}
.nodedc-peek-side-bound {
position: fixed !important;
bottom: var(--nodedc-peek-bottom-offset) !important;
}
.nodedc-beam-peek-shell[data-fullscreen="true"] {
grid-template-columns: minmax(0, 1fr) 0;
column-gap: 0;
}
.nodedc-beam-peek-viewer-card {
position: relative;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
transition:
border-color 260ms ease,
border-radius 260ms ease;
animation: nodedcBeamPeekSlideIn 220ms ease-out;
}
.nodedc-beam-peek-shell[data-resizing="true"] .nodedc-beam-peek-viewer-card,
.nodedc-beam-peek-shell[data-resizing="true"] .nodedc-beam-peek-issue-card {
transition: none;
}
.nodedc-beam-peek-shell[data-fullscreen="true"] .nodedc-beam-peek-viewer-card {
border-radius: 28px;
}
.nodedc-beam-peek-issue-slot {
min-width: 0;
min-height: 0;
height: 100%;
overflow: visible;
transition:
opacity 220ms ease,
transform 260ms ease;
}
.nodedc-beam-peek-shell[data-fullscreen="true"] .nodedc-beam-peek-issue-slot {
overflow: hidden;
opacity: 0;
transform: translateX(1.5rem);
pointer-events: none;
}
.nodedc-beam-peek-issue-card {
height: 100% !important;
min-height: 0 !important;
max-height: 100% !important;
border-radius: 28px !important;
overflow: hidden !important;
box-shadow:
0px 4px 8px 0px rgba(0, 0, 0, 0.12),
0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
}
.nodedc-beam-peek-viewer-card iframe,
.nodedc-beam-peek-viewer-card iframe:focus,
.nodedc-beam-peek-viewer-card iframe:focus-visible {
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
.nodedc-beam-viewer-modal:focus, .nodedc-beam-peek-shell[data-resizing="true"] iframe {
.nodedc-beam-viewer-modal:focus-visible, pointer-events: none;
.nodedc-beam-viewer-modal:focus-within, }
.nodedc-beam-viewer-modal iframe,
.nodedc-beam-viewer-modal iframe:focus, .nodedc-beam-peek-resize-shield {
.nodedc-beam-viewer-modal iframe:focus-visible { position: absolute;
border: 0 !important; inset: -1rem;
outline: none !important; z-index: 120;
box-shadow: none !important; cursor: ew-resize;
background: transparent;
}
@keyframes nodedcBeamPeekSlideIn {
from {
transform: translateX(1.25rem);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 900px) {
.nodedc-beam-peek-shell,
.nodedc-beam-peek-shell[data-fullscreen="true"] {
grid-template-columns: minmax(0, 1fr);
column-gap: 0;
}
.nodedc-beam-peek-viewer-card,
.nodedc-beam-peek-shell[data-fullscreen="true"] .nodedc-beam-peek-viewer-card {
border-radius: 24px;
}
.nodedc-beam-peek-issue-slot {
display: none;
}
} }
.nodedc-glass-surface { .nodedc-glass-surface {