memos/web/src/components/MemoView/MemoView.tsx

232 lines
7.1 KiB
TypeScript

import {
type ComponentType,
forwardRef,
memo,
Suspense,
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocation } from "react-router-dom";
import { useResolvedUser } from "@/components/MemoContent/MentionResolutionContext";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { isMemoBlurred } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { lazyWithReload } from "@/utils/lazy";
import { isSuperUser } from "@/utils/user";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
import { isMemoDetailPath, resolveMemoOrigin } from "./navigation";
import type { MemoViewHandle, MemoViewProps } from "./types";
const MemoShareImageDialog = lazyWithReload(() => import("../MemoActionMenu/MemoShareImageDialog"));
const PreviewImageDialog = lazyWithReload(() => import("../PreviewImageDialog"));
const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
const {
memo: memoData,
className,
parentPage: parentPageProp,
parentScope: parentScopeProp,
compact,
timeDisplay,
showCreator,
showVisibility,
showPinned,
showSpace,
} = props;
const cardRef = useRef<HTMLDivElement>(null);
const [showEditor, setShowEditor] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
const [cardWidth, setCardWidth] = useState(0);
const currentUser = useCurrentUser();
const { userTagsSetting } = useAuth();
const creator = useResolvedUser(memoData.creator, { enabled: Boolean(showCreator || props.shareImageDialogOpen) });
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const location = useLocation();
const { parentPage, parentScope } = resolveMemoOrigin({
explicitParentPage: parentPageProp,
explicitParentScope: parentScopeProp,
pathname: location.pathname,
search: location.search,
memoName: memoData.name,
});
// Blur content when any tag has blur_content enabled in the current user's tag settings.
const [showBlurredContent, setShowBlurredContent] = useState(false);
const blurred = isMemoBlurred(memoData, userTagsSetting);
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const editorHostRef = useRef<HTMLDivElement>(null);
const focusMountedEditor = useCallback(() => {
const codeMirrorContent = editorHostRef.current?.querySelector<HTMLElement>('.cm-content[contenteditable="true"]');
const fallbackInput = editorHostRef.current?.querySelector<HTMLElement>("textarea, input");
(codeMirrorContent ?? fallbackInput)?.focus();
}, []);
const openEditor = useCallback(() => {
if (showEditor && EditorComponent) {
focusMountedEditor();
return;
}
void loadMemoEditor()
.then(({ default: MemoEditor }) => {
setEditorComponent(() => MemoEditor);
setShowEditor(true);
})
.catch(() => undefined);
}, [EditorComponent, focusMountedEditor, showEditor]);
const closeEditor = useCallback(() => setShowEditor(false), []);
useImperativeHandle(ref, () => ({ openEditor }), [openEditor]);
const isInMemoDetailPage = isMemoDetailPath(location.pathname, memoData.name);
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
// The card width is only needed by the share-image dialog. Keep feed cards
// free of a permanent ResizeObserver and measure only while that dialog is open.
useLayoutEffect(() => {
if (!props.shareImageDialogOpen) {
return;
}
const card = cardRef.current;
if (!card) {
return;
}
const updateWidth = (nextWidth?: number) => {
const width = Math.round(nextWidth ?? card.getBoundingClientRect().width);
setCardWidth((prev) => (prev === width ? prev : width));
};
updateWidth();
if (typeof ResizeObserver === "undefined") {
const handleResize = () => updateWidth();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}
const resizeObserver = new ResizeObserver((entries) => {
updateWidth(entries[0]?.contentRect.width);
});
resizeObserver.observe(card);
return () => resizeObserver.disconnect();
}, [props.shareImageDialogOpen]);
const contextValue = useMemo(
() => ({
memo: memoData,
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,
showBlurredContent,
blurred,
openEditor,
toggleBlurVisibility,
openPreview,
}),
[
memoData,
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,
showBlurredContent,
blurred,
openEditor,
toggleBlurVisibility,
openPreview,
],
);
const article = (
<article
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader
timeDisplay={timeDisplay}
showCreator={showCreator}
showVisibility={showVisibility}
showPinned={showPinned}
showSpace={showSpace}
/>
<MemoBody compact={compact} />
{previewState.items.length > 0 && (
<Suspense fallback={null}>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
items={previewState.items}
initialIndex={previewState.index}
/>
</Suspense>
)}
{props.onShareImageDialogOpenChange && props.shareImageDialogOpen && (
<Suspense fallback={null}>
<MemoShareImageDialog open onOpenChange={props.onShareImageDialogOpenChange} />
</Suspense>
)}
</article>
);
const memoDisplay = showCommentPreview ? (
<div className="w-full mb-2">
{article}
<MemoCommentListView />
</div>
) : (
article
);
return (
<MemoViewContext.Provider value={contextValue}>
{showEditor && EditorComponent ? (
<div ref={editorHostRef} className="w-full">
<EditorComponent
autoFocus
className="mb-2"
cacheKey={`inline-memo-editor-${memoData.name}`}
memo={memoData}
parentMemoName={memoData.parent || undefined}
onConfirm={closeEditor}
onCancel={closeEditor}
/>
</div>
) : (
memoDisplay
)}
</MemoViewContext.Provider>
);
});
MemoView.displayName = "MemoView";
export default memo(MemoView);