UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация shell карточки внешнего контура с внутренним peek
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "@plane/utils";
|
||||
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||
import { ExternalContoursIssueActionsHeader, type TExternalContourPeekMode } from "./issue-header";
|
||||
|
||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:external-contour-peek-width";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
contourRequest: TExternalContourRequest;
|
||||
hasDirectTargetAccess: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
embedIssue?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ExternalContoursPeekShell = observer(function ExternalContoursPeekShell(props: Props) {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
contourRequest,
|
||||
hasDirectTargetAccess,
|
||||
isSubmitting,
|
||||
embedIssue = false,
|
||||
embedRemoveCurrentNotification,
|
||||
children,
|
||||
onClose,
|
||||
} = props;
|
||||
const [peekMode, setPeekMode] = useState<TExternalContourPeekMode>("side-peek");
|
||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 720;
|
||||
|
||||
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
|
||||
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
|
||||
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
|
||||
|
||||
return Number.isFinite(parsedWidth) ? parsedWidth : fallbackWidth;
|
||||
});
|
||||
const [isResizingPeek, setIsResizingPeek] = useState(false);
|
||||
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
const initialPeekWidthRef = useRef<number>(0);
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
const { currentTab } = useProjectExternalContours();
|
||||
|
||||
const removeRoutePeekId = useCallback(() => {
|
||||
if (embedIssue) {
|
||||
embedRemoveCurrentNotification?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [embedIssue, embedRemoveCurrentNotification, onClose]);
|
||||
|
||||
const stopPeekResizing = useCallback(() => {
|
||||
setIsResizingPeek(false);
|
||||
}, []);
|
||||
|
||||
const handlePeekResize = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const minWidth = 640;
|
||||
const deltaX = event.clientX - initialMouseXRef.current;
|
||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
||||
setSidePeekWidth(nextWidth);
|
||||
},
|
||||
[isResizingPeek]
|
||||
);
|
||||
|
||||
const startPeekResizing = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
if (peekMode !== "side-peek") return;
|
||||
event.preventDefault();
|
||||
setIsResizingPeek(true);
|
||||
initialPeekWidthRef.current = sidePeekWidth;
|
||||
initialMouseXRef.current = event.clientX;
|
||||
},
|
||||
[peekMode, sidePeekWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
|
||||
|
||||
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
|
||||
|
||||
if (clampedWidth !== sidePeekWidth) {
|
||||
setSidePeekWidth(clampedWidth);
|
||||
}
|
||||
}, [sidePeekWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
document.addEventListener("mousemove", handlePeekResize);
|
||||
document.addEventListener("mouseup", stopPeekResizing);
|
||||
document.addEventListener("mouseleave", stopPeekResizing);
|
||||
window.addEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handlePeekResize);
|
||||
document.removeEventListener("mouseup", stopPeekResizing);
|
||||
document.removeEventListener("mouseleave", stopPeekResizing);
|
||||
window.removeEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [handlePeekResize, isResizingPeek, stopPeekResizing]);
|
||||
|
||||
usePeekOverviewOutsideClickDetector(
|
||||
issuePeekOverviewRef,
|
||||
() => {
|
||||
if (!embedIssue) removeRoutePeekId();
|
||||
},
|
||||
contourRequest.id,
|
||||
["main-sidebar"]
|
||||
);
|
||||
|
||||
useKeypress("Escape", () => !embedIssue && removeRoutePeekId());
|
||||
|
||||
const peekOverviewClassName = cn(
|
||||
!embedIssue
|
||||
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
||||
: "h-full w-full",
|
||||
!embedIssue && {
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
peekMode === "side-peek",
|
||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
||||
}
|
||||
);
|
||||
|
||||
const portalContainer = typeof document !== "undefined" ? document.getElementById("full-screen-portal") : null;
|
||||
|
||||
const content = (
|
||||
<div className="w-full text-body-sm-regular">
|
||||
<div
|
||||
ref={issuePeekOverviewRef}
|
||||
className={peekOverviewClassName}
|
||||
style={{
|
||||
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
|
||||
boxShadow:
|
||||
"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)",
|
||||
}}
|
||||
>
|
||||
{!embedIssue && peekMode === "side-peek" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
||||
onMouseDown={startPeekResizing}
|
||||
role="separator"
|
||||
aria-label="Resize external contour panel"
|
||||
/>
|
||||
)}
|
||||
<ExternalContoursIssueActionsHeader
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
isSubmitting={isSubmitting}
|
||||
removeRoutePeekId={removeRoutePeekId}
|
||||
peekMode={peekMode}
|
||||
setPeekMode={setPeekMode}
|
||||
currentTab={currentTab}
|
||||
embedIssue={embedIssue}
|
||||
/>
|
||||
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
|
||||
{["side-peek", "modal"].includes(peekMode) ? (
|
||||
<div className="relative flex flex-col gap-4 px-8 py-6">{children}</div>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto px-6 py-5">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <>{!embedIssue && portalContainer ? createPortal(content, portalContainer) : content}</>;
|
||||
});
|
||||
Reference in New Issue
Block a user