232 lines
7.1 KiB
TypeScript
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);
|