feat: embed Beam viewer in issue peek
This commit is contained in:
parent
15c9fb1df1
commit
867f7bc558
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue