refactor: memo editor (#4730)

pull/4533/merge
Johnny 3 months ago committed by GitHub
parent 77d3853f73
commit ea4e7a1606
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -9,22 +9,15 @@ interface Props {
listMode?: boolean; listMode?: boolean;
} }
interface LocalState {
columns: number;
itemHeights: Map<string, number>;
columnHeights: number[];
distribution: number[][];
}
interface MemoItemProps { interface MemoItemProps {
memo: Memo; memo: Memo;
renderer: (memo: Memo) => JSX.Element; renderer: (memo: Memo) => JSX.Element;
onHeightChange: (memoName: string, height: number) => void; onHeightChange: (memoName: string, height: number) => void;
} }
// Minimum width required to show more than one column
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512; const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
// Component to wrap each memo and measure its height
const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => { const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
const itemRef = useRef<HTMLDivElement>(null); const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null); const resizeObserverRef = useRef<ResizeObserver | null>(null);
@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
} }
}; };
// Initial measurement
measureHeight(); measureHeight();
// Set up ResizeObserver for dynamic content changes // Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef.current = new ResizeObserver(() => { resizeObserverRef.current = new ResizeObserver(measureHeight);
measureHeight();
});
resizeObserverRef.current.observe(itemRef.current); resizeObserverRef.current.observe(itemRef.current);
return () => { return () => {
if (resizeObserverRef.current) { resizeObserverRef.current?.disconnect();
resizeObserverRef.current.disconnect();
}
}; };
}, [memo.name, onHeightChange]); }, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo)}</div>; return <div ref={itemRef}>{renderer(memo)}</div>;
}; };
// Algorithm to distribute memos into columns based on height /**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
*/
const distributeMemosToColumns = ( const distributeMemosToColumns = (
memos: Memo[], memos: Memo[],
columns: number, columns: number,
itemHeights: Map<string, number>, itemHeights: Map<string, number>,
prefixElementHeight: number = 0, prefixElementHeight: number = 0,
): { distribution: number[][]; columnHeights: number[] } => { ): { distribution: number[][]; columnHeights: number[] } => {
// List mode: all memos in single column
if (columns === 1) { if (columns === 1) {
// List mode - all memos in single column const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return { return {
distribution: [Array.from(Array(memos.length).keys())], distribution: [Array.from({ length: memos.length }, (_, i) => i)],
columnHeights: [memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight)], columnHeights: [totalHeight],
}; };
} }
// Initialize columns and heights
const distribution: number[][] = Array.from({ length: columns }, () => []); const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0); const columnHeights: number[] = Array(columns).fill(0);
@ -82,15 +74,12 @@ const distributeMemosToColumns = (
columnHeights[0] = prefixElementHeight; columnHeights[0] = prefixElementHeight;
} }
// Distribute memos to the shortest column each time // Distribute each memo to the shortest column
memos.forEach((memo, index) => { memos.forEach((memo, index) => {
const height = itemHeights.get(memo.name) || 0; const height = itemHeights.get(memo.name) || 0;
// Find the shortest column // Find column with minimum height
const shortestColumnIndex = columnHeights.reduce( const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
0,
);
distribution[shortestColumnIndex].push(index); distribution[shortestColumnIndex].push(index);
columnHeights[shortestColumnIndex] += height; columnHeights[shortestColumnIndex] += height;
@ -100,97 +89,82 @@ const distributeMemosToColumns = (
}; };
const MasonryView = (props: Props) => { const MasonryView = (props: Props) => {
const [state, setState] = useState<LocalState>({ const [columns, setColumns] = useState(1);
columns: 1, const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
itemHeights: new Map(), const [distribution, setDistribution] = useState<number[][]>([[]]);
columnHeights: [0],
distribution: [[]],
});
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null); const prefixElementRef = useRef<HTMLDivElement>(null);
// Calculate optimal number of columns based on container width
const calculateColumns = useCallback(() => {
if (!containerRef.current || props.listMode) return 1;
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
}, [props.listMode]);
// Recalculate memo distribution when layout changes
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, itemHeights, prefixHeight);
setDistribution(newDistribution);
}, [props.memoList, columns, itemHeights]);
// Handle height changes from individual memo items // Handle height changes from individual memo items
const handleHeightChange = useCallback( const handleHeightChange = useCallback(
(memoName: string, height: number) => { (memoName: string, height: number) => {
setState((prevState) => { setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevState.itemHeights); const newItemHeights = new Map(prevHeights);
newItemHeights.set(memoName, height); newItemHeights.set(memoName, height);
// Recalculate distribution with new heights
const prefixHeight = prefixElementRef.current?.offsetHeight || 0; const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, prevState.columns, newItemHeights, prefixHeight); const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, newItemHeights, prefixHeight);
setDistribution(newDistribution);
return { return newItemHeights;
...prevState,
itemHeights: newItemHeights,
distribution,
columnHeights,
};
}); });
}, },
[props.memoList], [props.memoList, columns],
); );
// Handle window resize and column count changes // Handle window resize and calculate new column count
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (!containerRef.current) { if (!containerRef.current) return;
return;
}
const newColumns = props.listMode const newColumns = calculateColumns();
? 1 if (newColumns !== columns) {
: (() => { setColumns(newColumns);
const containerWidth = containerRef.current!.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
})();
if (newColumns !== state.columns) {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, newColumns, state.itemHeights, prefixHeight);
setState((prevState) => ({
...prevState,
columns: newColumns,
distribution,
columnHeights,
}));
} }
}; };
handleResize(); handleResize();
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, [props.listMode, state.columns, state.itemHeights, props.memoList]); }, [calculateColumns, columns]);
// Redistribute when memo list changes // Redistribute memos when columns, memo list, or heights change
useEffect(() => { useEffect(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0; redistributeMemos();
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, state.columns, state.itemHeights, prefixHeight); }, [redistributeMemos]);
setState((prevState) => ({
...prevState,
distribution,
columnHeights,
}));
}, [props.memoList, state.columns, state.itemHeights]);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn("w-full grid gap-2")} className={cn("w-full grid gap-2")}
style={{ style={{
gridTemplateColumns: `repeat(${state.columns}, 1fr)`, gridTemplateColumns: `repeat(${columns}, 1fr)`,
}} }}
> >
{Array.from({ length: state.columns }).map((_, columnIndex) => ( {Array.from({ length: columns }).map((_, columnIndex) => (
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl"> <div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
{props.prefixElement && columnIndex === 0 && ( {/* Prefix element (like memo editor) goes in first column */}
<div ref={prefixElementRef} className="mb-2"> {props.prefixElement && columnIndex === 0 && <div ref={prefixElementRef}>{props.prefixElement}</div>}
{props.prefixElement}
</div> {distribution[columnIndex]?.map((memoIndex) => {
)}
{state.distribution[columnIndex]?.map((memoIndex) => {
const memo = props.memoList[memoIndex]; const memo = props.memoList[memoIndex];
return memo ? ( return memo ? (
<MemoItem <MemoItem

@ -0,0 +1,64 @@
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
value: Visibility;
onChange: (visibility: Visibility) => void;
className?: string;
}
const VisibilitySelector = (props: Props) => {
const { value, onChange, className } = props;
const t = useTranslate();
const [open, setOpen] = useState(false);
const visibilityOptions = [
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
];
const currentOption = visibilityOptions.find((option) => option.value === value);
const handleSelect = (visibility: Visibility) => {
onChange(visibility);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={`flex items-center justify-center gap-1 px-0.5 text-xs rounded hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 transition-colors ${className || ""}`}
type="button"
>
<VisibilityIcon className="w-3 h-3" visibility={value} />
<span className="hidden sm:inline">{currentOption?.label}</span>
<ChevronDownIcon className="w-3 h-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent className="!p-1" align="end" sideOffset={2} alignOffset={-4}>
<div className="flex flex-col gap-0.5">
{visibilityOptions.map((option) => (
<button
key={option.value}
onClick={() => handleSelect(option.value)}
className={`flex items-center gap-1 px-1 py-1 text-xs text-left dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded transition-colors ${
option.value === value ? "bg-gray-50 dark:bg-zinc-800" : ""
}`}
>
<VisibilityIcon className="w-3 h-3" visibility={option.value} />
<span>{option.label}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
);
};
export default VisibilitySelector;

@ -169,11 +169,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return; return;
} }
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event.preventDefault();
const cursorPosition = editorActions.getCursorPosition(); const cursorPosition = editorActions.getCursorPosition();
const prevContent = editorActions.getContent().substring(0, cursorPosition); const prevContent = editorActions.getContent().substring(0, cursorPosition);
@ -210,8 +205,16 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
insertText += " |"; insertText += " |";
} }
if (insertText) {
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event.preventDefault();
// Insert the text at the current cursor position
editorActions.insertText("\n" + insertText); editorActions.insertText("\n" + insertText);
} }
}
}; };
return ( return (
@ -220,7 +223,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
> >
<textarea <textarea
className="w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break" className="w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break"
rows={1} rows={2}
placeholder={placeholder} placeholder={placeholder}
ref={editorRef} ref={editorRef}
onPaste={onPaste} onPaste={onPaste}

@ -1,4 +1,3 @@
import { Select, Option, Divider } from "@mui/joy";
import { Button } from "@usememos/mui"; import { Button } from "@usememos/mui";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { LoaderIcon, SendIcon } from "lucide-react"; import { LoaderIcon, SendIcon } from "lucide-react";
@ -17,14 +16,15 @@ import { memoStore, resourceStore, userStore, workspaceStore } from "@/store/v2"
import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service"; import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
import { Resource } from "@/types/proto/api/v1/resource_service"; import { Resource } from "@/types/proto/api/v1/resource_service";
import { UserSetting } from "@/types/proto/api/v1/user_service"; import { UserSetting } from "@/types/proto/api/v1/user_service";
import { cn } from "@/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo"; import { convertVisibilityFromString } from "@/utils/memo";
import VisibilityIcon from "../VisibilityIcon";
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover"; import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
import LocationSelector from "./ActionButton/LocationSelector"; import LocationSelector from "./ActionButton/LocationSelector";
import MarkdownMenu from "./ActionButton/MarkdownMenu"; import MarkdownMenu from "./ActionButton/MarkdownMenu";
import TagSelector from "./ActionButton/TagSelector"; import TagSelector from "./ActionButton/TagSelector";
import UploadResourceButton from "./ActionButton/UploadResourceButton"; import UploadResourceButton from "./ActionButton/UploadResourceButton";
import VisibilitySelector from "./ActionButton/VisibilitySelector";
import Editor, { EditorRefActions } from "./Editor"; import Editor, { EditorRefActions } from "./Editor";
import RelationListView from "./RelationListView"; import RelationListView from "./RelationListView";
import ResourceListView from "./ResourceListView"; import ResourceListView from "./ResourceListView";
@ -468,13 +468,13 @@ const MemoEditor = observer((props: Props) => {
}} }}
> >
<div <div
className={`${ className={cn(
className ?? "" "group relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-3 pb-2 rounded-lg border",
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border ${
state.isDraggingFile state.isDraggingFile
? "border-dashed border-gray-400 dark:border-primary-400 cursor-copy" ? "border-dashed border-gray-400 dark:border-primary-400 cursor-copy"
: "border-gray-200 dark:border-zinc-700 cursor-auto" : "border-gray-200 dark:border-zinc-700 cursor-auto",
}`} className,
)}
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onDrop={handleDropEvent} onDrop={handleDropEvent}
@ -500,7 +500,7 @@ const MemoEditor = observer((props: Props) => {
<Editor ref={editorRef} {...editorConfig} /> <Editor ref={editorRef} {...editorConfig} />
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} /> <ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} /> <RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<div className="relative w-full flex flex-row justify-between items-center pt-2" onFocus={(e) => e.stopPropagation()}> <div className="relative w-full flex flex-row justify-between items-center py-1" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2"> <div className="flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2">
<TagSelector editorRef={editorRef} /> <TagSelector editorRef={editorRef} />
<MarkdownMenu editorRef={editorRef} /> <MarkdownMenu editorRef={editorRef} />
@ -516,31 +516,9 @@ const MemoEditor = observer((props: Props) => {
} }
/> />
</div> </div>
</div> <div className="shrink-0 -mr-1 flex flex-row justify-end items-center">
<Divider className="!mt-2 opacity-40" />
<div className="w-full flex flex-row justify-between items-center py-3 gap-2 overflow-auto dark:border-t-zinc-500">
<div className="relative flex flex-row justify-start items-center" onFocus={(e) => e.stopPropagation()}>
<Select
variant="plain"
size="sm"
value={state.memoVisibility}
startDecorator={<VisibilityIcon visibility={state.memoVisibility} />}
onChange={(_, visibility) => {
if (visibility) {
handleMemoVisibilityChange(visibility);
}
}}
>
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC].map((item) => (
<Option key={item} value={item} className="whitespace-nowrap !text-sm">
{t(`memo.visibility.${convertVisibilityToString(item).toLowerCase()}` as any)}
</Option>
))}
</Select>
</div>
<div className="shrink-0 flex flex-row justify-end items-center gap-2">
{props.onCancel && ( {props.onCancel && (
<Button variant="plain" disabled={state.isRequesting} onClick={handleCancelBtnClick}> <Button variant="plain" className="opacity-60" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
)} )}
@ -550,6 +528,12 @@ const MemoEditor = observer((props: Props) => {
</Button> </Button>
</div> </div>
</div> </div>
<div
className="absolute invisible group-focus-within:visible group-hover:visible right-1 top-1 opacity-60"
onFocus={(e) => e.stopPropagation()}
>
<VisibilitySelector value={state.memoVisibility} onChange={handleMemoVisibilityChange} />
</div>
</div> </div>
</MemoEditorContext.Provider> </MemoEditorContext.Provider>
); );

@ -1,7 +1,7 @@
import { Tooltip } from "@mui/joy"; import { Tooltip } from "@mui/joy";
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react"; import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useState } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
@ -47,7 +47,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
const [showEditor, setShowEditor] = useState<boolean>(false); const [showEditor, setShowEditor] = useState<boolean>(false);
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator)); const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent); const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
const memoContainerRef = useRef<HTMLDivElement>(null);
const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting; const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const commentAmount = memo.relations.filter( const commentAmount = memo.relations.filter(
@ -121,25 +120,22 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
<relative-time datetime={memo.displayTime?.toISOString()} format={relativeTimeFormat}></relative-time> <relative-time datetime={memo.displayTime?.toISOString()} format={relativeTimeFormat}></relative-time>
); );
return ( return showEditor ? (
<div
className={cn(
"group relative flex flex-col justify-start items-start w-full px-4 py-3 mb-2 gap-2 bg-white dark:bg-zinc-800 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700",
className,
)}
ref={memoContainerRef}
>
{showEditor ? (
<MemoEditor <MemoEditor
autoFocus autoFocus
className="border-none !p-0 -mb-2" className="mb-2"
cacheKey={`inline-memo-editor-${memo.name}`} cacheKey={`inline-memo-editor-${memo.name}`}
memoName={memo.name} memoName={memo.name}
onConfirm={onEditorConfirm} onConfirm={onEditorConfirm}
onCancel={() => setShowEditor(false)} onCancel={() => setShowEditor(false)}
/> />
) : ( ) : (
<> <div
className={cn(
"group relative flex flex-col justify-start items-start w-full px-4 py-3 mb-2 gap-2 bg-white dark:bg-zinc-800 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700",
className,
)}
>
<div className="w-full flex flex-row justify-between items-center gap-2"> <div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center"> <div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{props.showCreator && creator ? ( {props.showCreator && creator ? (
@ -246,8 +242,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
</button> </button>
</> </>
)} )}
</>
)}
</div> </div>
); );
}); });

@ -26,24 +26,28 @@ interface Props {
pageSize?: number; pageSize?: number;
} }
interface LocalState {
isRequesting: boolean;
nextPageToken: string;
}
const PagedMemoList = observer((props: Props) => { const PagedMemoList = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const { md } = useResponsiveWidth(); const { md } = useResponsiveWidth();
const [state, setState] = useState<LocalState>({
isRequesting: true, // Initial request // Simplified state management - separate state variables for clarity
nextPageToken: "", const [isRequesting, setIsRequesting] = useState(true);
}); const [nextPageToken, setNextPageToken] = useState("");
const checkTimeoutRef = useRef<number | null>(null);
// Ref to manage auto-fetch timeout to prevent memory leaks
const autoFetchTimeoutRef = useRef<number | null>(null);
// Apply custom sorting if provided, otherwise use store memos directly
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos; const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
// Show memo editor only on the root route
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
const fetchMoreMemos = async (nextPageToken: string) => { // Fetch more memos with pagination support
setState((state) => ({ ...state, isRequesting: true })); const fetchMoreMemos = async (pageToken: string) => {
setIsRequesting(true);
try {
const response = await memoStore.fetchMemos({ const response = await memoStore.fetchMemos({
parent: props.owner || "", parent: props.owner || "",
state: props.state || State.NORMAL, state: props.state || State.NORMAL,
@ -51,83 +55,86 @@ const PagedMemoList = observer((props: Props) => {
filter: props.filter || "", filter: props.filter || "",
oldFilter: props.oldFilter || "", oldFilter: props.oldFilter || "",
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE, pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
pageToken: nextPageToken, pageToken,
}); });
setState(() => ({
isRequesting: false, setNextPageToken(response?.nextPageToken || "");
nextPageToken: response?.nextPageToken || "", } finally {
})); setIsRequesting(false);
}
}; };
// Check if content fills the viewport and fetch more if needed // Helper function to check if page has enough content to be scrollable
const isPageScrollable = () => {
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure
};
// Auto-fetch more content if page isn't scrollable and more data is available
const checkAndFetchIfNeeded = useCallback(async () => { const checkAndFetchIfNeeded = useCallback(async () => {
// Clear any pending checks // Clear any pending auto-fetch timeout
if (checkTimeoutRef.current) { if (autoFetchTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current); clearTimeout(autoFetchTimeoutRef.current);
} }
// Wait a bit for DOM to update after memo list changes // Wait for DOM to update before checking scrollability
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
// Check if page is scrollable using multiple methods for better reliability // Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
const documentHeight = Math.max( const shouldFetch = !isPageScrollable() && nextPageToken && !isRequesting && sortedMemoList.length > 0;
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight,
);
const windowHeight = window.innerHeight; if (shouldFetch) {
const isScrollable = documentHeight > windowHeight + 100; // 100px buffer await fetchMoreMemos(nextPageToken);
// If not scrollable and we have more data to fetch and not currently fetching // Schedule another check with delay to prevent rapid successive calls
if (!isScrollable && state.nextPageToken && !state.isRequesting && sortedMemoList.length > 0) { autoFetchTimeoutRef.current = window.setTimeout(() => {
await fetchMoreMemos(state.nextPageToken);
// Schedule another check after a delay to prevent rapid successive calls
checkTimeoutRef.current = window.setTimeout(() => {
checkAndFetchIfNeeded(); checkAndFetchIfNeeded();
}, 500); }, 500);
} }
}, [state.nextPageToken, state.isRequesting, sortedMemoList.length]); }, [nextPageToken, isRequesting, sortedMemoList.length]);
// Refresh the entire memo list from the beginning
const refreshList = async () => { const refreshList = async () => {
memoStore.state.updateStateId(); memoStore.state.updateStateId();
setState((state) => ({ ...state, nextPageToken: "" })); setNextPageToken("");
await fetchMoreMemos(""); await fetchMoreMemos("");
}; };
// Initial load and reload when props change
useEffect(() => { useEffect(() => {
refreshList(); refreshList();
}, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]); }, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]);
// Check if we need to fetch more data when content changes. // Auto-fetch more content when list changes and page isn't full
useEffect(() => { useEffect(() => {
if (!state.isRequesting && sortedMemoList.length > 0) { if (!isRequesting && sortedMemoList.length > 0) {
checkAndFetchIfNeeded(); checkAndFetchIfNeeded();
} }
}, [sortedMemoList.length, state.isRequesting, state.nextPageToken, checkAndFetchIfNeeded]); }, [sortedMemoList.length, isRequesting, nextPageToken, checkAndFetchIfNeeded]);
// Cleanup timeout on unmount. // Cleanup timeout on component unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (checkTimeoutRef.current) { if (autoFetchTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current); clearTimeout(autoFetchTimeoutRef.current);
} }
}; };
}, []); }, []);
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => { useEffect(() => {
if (!state.nextPageToken) return; if (!nextPageToken) return;
const handleScroll = () => { const handleScroll = () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300; const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
if (nearBottom && !state.isRequesting) { if (nearBottom && !isRequesting) {
fetchMoreMemos(state.nextPageToken); fetchMoreMemos(nextPageToken);
} }
}; };
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [state.nextPageToken, state.isRequesting]); }, [nextPageToken, isRequesting]);
const children = ( const children = (
<div className="flex flex-col justify-start items-start w-full max-w-full"> <div className="flex flex-col justify-start items-start w-full max-w-full">
@ -137,14 +144,18 @@ const PagedMemoList = observer((props: Props) => {
prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined} prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
listMode={viewStore.state.layout === "LIST"} listMode={viewStore.state.layout === "LIST"}
/> />
{state.isRequesting && (
{/* Loading indicator */}
{isRequesting && (
<div className="w-full flex flex-row justify-center items-center my-4"> <div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-zinc-500" /> <LoaderIcon className="animate-spin text-zinc-500" />
</div> </div>
)} )}
{!state.isRequesting && (
{/* Empty state or back-to-top button */}
{!isRequesting && (
<> <>
{!state.nextPageToken && sortedMemoList.length === 0 ? ( {!nextPageToken && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic"> <div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty /> <Empty />
<p className="mt-2 text-gray-600 dark:text-gray-400">{t("message.no-data")}</p> <p className="mt-2 text-gray-600 dark:text-gray-400">{t("message.no-data")}</p>
@ -159,7 +170,6 @@ const PagedMemoList = observer((props: Props) => {
</div> </div>
); );
// In case of md screen, we don't need pull to refresh.
if (md) { if (md) {
return children; return children;
} }
@ -186,25 +196,16 @@ const PagedMemoList = observer((props: Props) => {
const BackToTop = () => { const BackToTop = () => {
const t = useTranslate(); const t = useTranslate();
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
const shouldBeVisible = window.scrollY > 400; const shouldShow = window.scrollY > 400;
if (shouldBeVisible !== isVisible) { setIsVisible(shouldShow);
if (shouldBeVisible) {
setShouldRender(true);
setIsVisible(true);
} else {
setShouldRender(false);
setIsVisible(false);
}
}
}; };
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [isVisible]); }, []);
const scrollToTop = () => { const scrollToTop = () => {
window.scrollTo({ window.scrollTo({
@ -213,7 +214,8 @@ const BackToTop = () => {
}); });
}; };
if (!shouldRender) { // Don't render if not visible
if (!isVisible) {
return null; return null;
} }

@ -4,10 +4,11 @@ import { cn } from "@/utils";
interface Props { interface Props {
visibility: Visibility; visibility: Visibility;
className?: string;
} }
const VisibilityIcon = (props: Props) => { const VisibilityIcon = (props: Props) => {
const { visibility } = props; const { className, visibility } = props;
let VIcon = null; let VIcon = null;
if (visibility === Visibility.PRIVATE) { if (visibility === Visibility.PRIVATE) {
@ -21,7 +22,7 @@ const VisibilityIcon = (props: Props) => {
return null; return null;
} }
return <VIcon className={cn("w-4 h-auto text-gray-500 dark:text-gray-400")} />; return <VIcon className={cn("w-4 h-auto text-gray-500 dark:text-gray-400", className)} />;
}; };
export default VisibilityIcon; export default VisibilityIcon;

Loading…
Cancel
Save