mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
refactor: memo editor (#4730)
This commit is contained in:
@@ -9,22 +9,15 @@ interface Props {
|
||||
listMode?: boolean;
|
||||
}
|
||||
|
||||
interface LocalState {
|
||||
columns: number;
|
||||
itemHeights: Map<string, number>;
|
||||
columnHeights: number[];
|
||||
distribution: number[][];
|
||||
}
|
||||
|
||||
interface MemoItemProps {
|
||||
memo: Memo;
|
||||
renderer: (memo: Memo) => JSX.Element;
|
||||
onHeightChange: (memoName: string, height: number) => void;
|
||||
}
|
||||
|
||||
// Minimum width required to show more than one column
|
||||
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
|
||||
|
||||
// Component to wrap each memo and measure its height
|
||||
const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
@@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial measurement
|
||||
measureHeight();
|
||||
|
||||
// Set up ResizeObserver for dynamic content changes
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
measureHeight();
|
||||
});
|
||||
|
||||
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
|
||||
resizeObserverRef.current = new ResizeObserver(measureHeight);
|
||||
resizeObserverRef.current.observe(itemRef.current);
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
resizeObserverRef.current?.disconnect();
|
||||
};
|
||||
}, [memo.name, onHeightChange]);
|
||||
|
||||
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 = (
|
||||
memos: Memo[],
|
||||
columns: number,
|
||||
itemHeights: Map<string, number>,
|
||||
prefixElementHeight: number = 0,
|
||||
): { distribution: number[][]; columnHeights: number[] } => {
|
||||
// List mode: all memos in single column
|
||||
if (columns === 1) {
|
||||
// List mode - all memos in single column
|
||||
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
|
||||
return {
|
||||
distribution: [Array.from(Array(memos.length).keys())],
|
||||
columnHeights: [memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight)],
|
||||
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
|
||||
columnHeights: [totalHeight],
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize columns and heights
|
||||
const distribution: number[][] = Array.from({ length: columns }, () => []);
|
||||
const columnHeights: number[] = Array(columns).fill(0);
|
||||
|
||||
@@ -82,15 +74,12 @@ const distributeMemosToColumns = (
|
||||
columnHeights[0] = prefixElementHeight;
|
||||
}
|
||||
|
||||
// Distribute memos to the shortest column each time
|
||||
// Distribute each memo to the shortest column
|
||||
memos.forEach((memo, index) => {
|
||||
const height = itemHeights.get(memo.name) || 0;
|
||||
|
||||
// Find the shortest column
|
||||
const shortestColumnIndex = columnHeights.reduce(
|
||||
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
|
||||
0,
|
||||
);
|
||||
// Find column with minimum height
|
||||
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
||||
|
||||
distribution[shortestColumnIndex].push(index);
|
||||
columnHeights[shortestColumnIndex] += height;
|
||||
@@ -100,97 +89,82 @@ const distributeMemosToColumns = (
|
||||
};
|
||||
|
||||
const MasonryView = (props: Props) => {
|
||||
const [state, setState] = useState<LocalState>({
|
||||
columns: 1,
|
||||
itemHeights: new Map(),
|
||||
columnHeights: [0],
|
||||
distribution: [[]],
|
||||
});
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
|
||||
const [distribution, setDistribution] = useState<number[][]>([[]]);
|
||||
|
||||
const containerRef = 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
|
||||
const handleHeightChange = useCallback(
|
||||
(memoName: string, height: number) => {
|
||||
setState((prevState) => {
|
||||
const newItemHeights = new Map(prevState.itemHeights);
|
||||
setItemHeights((prevHeights) => {
|
||||
const newItemHeights = new Map(prevHeights);
|
||||
newItemHeights.set(memoName, height);
|
||||
|
||||
// Recalculate distribution with new heights
|
||||
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 {
|
||||
...prevState,
|
||||
itemHeights: newItemHeights,
|
||||
distribution,
|
||||
columnHeights,
|
||||
};
|
||||
return newItemHeights;
|
||||
});
|
||||
},
|
||||
[props.memoList],
|
||||
[props.memoList, columns],
|
||||
);
|
||||
|
||||
// Handle window resize and column count changes
|
||||
// Handle window resize and calculate new column count
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const newColumns = props.listMode
|
||||
? 1
|
||||
: (() => {
|
||||
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,
|
||||
}));
|
||||
const newColumns = calculateColumns();
|
||||
if (newColumns !== columns) {
|
||||
setColumns(newColumns);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("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(() => {
|
||||
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
|
||||
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, state.columns, state.itemHeights, prefixHeight);
|
||||
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
distribution,
|
||||
columnHeights,
|
||||
}));
|
||||
}, [props.memoList, state.columns, state.itemHeights]);
|
||||
redistributeMemos();
|
||||
}, [redistributeMemos]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("w-full grid gap-2")}
|
||||
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">
|
||||
{props.prefixElement && columnIndex === 0 && (
|
||||
<div ref={prefixElementRef} className="mb-2">
|
||||
{props.prefixElement}
|
||||
</div>
|
||||
)}
|
||||
{state.distribution[columnIndex]?.map((memoIndex) => {
|
||||
{/* Prefix element (like memo editor) goes in first column */}
|
||||
{props.prefixElement && columnIndex === 0 && <div ref={prefixElementRef}>{props.prefixElement}</div>}
|
||||
|
||||
{distribution[columnIndex]?.map((memoIndex) => {
|
||||
const memo = props.memoList[memoIndex];
|
||||
return memo ? (
|
||||
<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) {
|
||||
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 prevContent = editorActions.getContent().substring(0, cursorPosition);
|
||||
@@ -210,8 +205,16 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -220,7 +223,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
>
|
||||
<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"
|
||||
rows={1}
|
||||
rows={2}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onPaste={onPaste}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import { Select, Option, Divider } from "@mui/joy";
|
||||
import { Button } from "@usememos/mui";
|
||||
import { isEqual } from "lodash-es";
|
||||
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 { Resource } from "@/types/proto/api/v1/resource_service";
|
||||
import { UserSetting } from "@/types/proto/api/v1/user_service";
|
||||
import { cn } from "@/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
||||
import VisibilityIcon from "../VisibilityIcon";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
|
||||
import LocationSelector from "./ActionButton/LocationSelector";
|
||||
import MarkdownMenu from "./ActionButton/MarkdownMenu";
|
||||
import TagSelector from "./ActionButton/TagSelector";
|
||||
import UploadResourceButton from "./ActionButton/UploadResourceButton";
|
||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||
import Editor, { EditorRefActions } from "./Editor";
|
||||
import RelationListView from "./RelationListView";
|
||||
import ResourceListView from "./ResourceListView";
|
||||
@@ -468,13 +468,13 @@ const MemoEditor = observer((props: Props) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
className ?? ""
|
||||
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border ${
|
||||
className={cn(
|
||||
"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",
|
||||
state.isDraggingFile
|
||||
? "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}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDropEvent}
|
||||
@@ -500,7 +500,7 @@ const MemoEditor = observer((props: Props) => {
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
|
||||
<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">
|
||||
<TagSelector editorRef={editorRef} />
|
||||
<MarkdownMenu editorRef={editorRef} />
|
||||
@@ -516,31 +516,9 @@ const MemoEditor = observer((props: Props) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div className="shrink-0 -mr-1 flex flex-row justify-end items-center">
|
||||
{props.onCancel && (
|
||||
<Button variant="plain" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||
<Button variant="plain" className="opacity-60" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -550,6 +528,12 @@ const MemoEditor = observer((props: Props) => {
|
||||
</Button>
|
||||
</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>
|
||||
</MemoEditorContext.Provider>
|
||||
);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||
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 useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
@@ -47,7 +47,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
||||
const [showEditor, setShowEditor] = useState<boolean>(false);
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
|
||||
const memoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
return showEditor ? (
|
||||
<MemoEditor
|
||||
autoFocus
|
||||
className="border-none !p-0 -mb-2"
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memo.name}`}
|
||||
memoName={memo.name}
|
||||
onConfirm={onEditorConfirm}
|
||||
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-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||
{props.showCreator && creator ? (
|
||||
@@ -246,8 +242,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@@ -26,24 +26,28 @@ interface Props {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
interface LocalState {
|
||||
isRequesting: boolean;
|
||||
nextPageToken: string;
|
||||
}
|
||||
|
||||
const PagedMemoList = observer((props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const [state, setState] = useState<LocalState>({
|
||||
isRequesting: true, // Initial request
|
||||
nextPageToken: "",
|
||||
});
|
||||
const checkTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// Simplified state management - separate state variables for clarity
|
||||
const [isRequesting, setIsRequesting] = useState(true);
|
||||
const [nextPageToken, setNextPageToken] = useState("");
|
||||
|
||||
// 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;
|
||||
|
||||
// Show memo editor only on the root route
|
||||
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
|
||||
|
||||
const fetchMoreMemos = async (nextPageToken: string) => {
|
||||
setState((state) => ({ ...state, isRequesting: true }));
|
||||
// Fetch more memos with pagination support
|
||||
const fetchMoreMemos = async (pageToken: string) => {
|
||||
setIsRequesting(true);
|
||||
|
||||
try {
|
||||
const response = await memoStore.fetchMemos({
|
||||
parent: props.owner || "",
|
||||
state: props.state || State.NORMAL,
|
||||
@@ -51,83 +55,86 @@ const PagedMemoList = observer((props: Props) => {
|
||||
filter: props.filter || "",
|
||||
oldFilter: props.oldFilter || "",
|
||||
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||
pageToken: nextPageToken,
|
||||
pageToken,
|
||||
});
|
||||
setState(() => ({
|
||||
isRequesting: false,
|
||||
nextPageToken: response?.nextPageToken || "",
|
||||
}));
|
||||
|
||||
setNextPageToken(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 () => {
|
||||
// Clear any pending checks
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current);
|
||||
// Clear any pending auto-fetch timeout
|
||||
if (autoFetchTimeoutRef.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));
|
||||
|
||||
// Check if page is scrollable using multiple methods for better reliability
|
||||
const documentHeight = Math.max(
|
||||
document.body.scrollHeight,
|
||||
document.body.offsetHeight,
|
||||
document.documentElement.clientHeight,
|
||||
document.documentElement.scrollHeight,
|
||||
document.documentElement.offsetHeight,
|
||||
);
|
||||
// Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
|
||||
const shouldFetch = !isPageScrollable() && nextPageToken && !isRequesting && sortedMemoList.length > 0;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
const isScrollable = documentHeight > windowHeight + 100; // 100px buffer
|
||||
if (shouldFetch) {
|
||||
await fetchMoreMemos(nextPageToken);
|
||||
|
||||
// If not scrollable and we have more data to fetch and not currently fetching
|
||||
if (!isScrollable && state.nextPageToken && !state.isRequesting && sortedMemoList.length > 0) {
|
||||
await fetchMoreMemos(state.nextPageToken);
|
||||
// Schedule another check after a delay to prevent rapid successive calls
|
||||
checkTimeoutRef.current = window.setTimeout(() => {
|
||||
// Schedule another check with delay to prevent rapid successive calls
|
||||
autoFetchTimeoutRef.current = window.setTimeout(() => {
|
||||
checkAndFetchIfNeeded();
|
||||
}, 500);
|
||||
}
|
||||
}, [state.nextPageToken, state.isRequesting, sortedMemoList.length]);
|
||||
}, [nextPageToken, isRequesting, sortedMemoList.length]);
|
||||
|
||||
// Refresh the entire memo list from the beginning
|
||||
const refreshList = async () => {
|
||||
memoStore.state.updateStateId();
|
||||
setState((state) => ({ ...state, nextPageToken: "" }));
|
||||
setNextPageToken("");
|
||||
await fetchMoreMemos("");
|
||||
};
|
||||
|
||||
// Initial load and reload when props change
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
}, [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(() => {
|
||||
if (!state.isRequesting && sortedMemoList.length > 0) {
|
||||
if (!isRequesting && sortedMemoList.length > 0) {
|
||||
checkAndFetchIfNeeded();
|
||||
}
|
||||
}, [sortedMemoList.length, state.isRequesting, state.nextPageToken, checkAndFetchIfNeeded]);
|
||||
}, [sortedMemoList.length, isRequesting, nextPageToken, checkAndFetchIfNeeded]);
|
||||
|
||||
// Cleanup timeout on unmount.
|
||||
// Cleanup timeout on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current);
|
||||
if (autoFetchTimeoutRef.current) {
|
||||
clearTimeout(autoFetchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Infinite scroll: fetch more when user scrolls near bottom
|
||||
useEffect(() => {
|
||||
if (!state.nextPageToken) return;
|
||||
if (!nextPageToken) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
|
||||
if (nearBottom && !state.isRequesting) {
|
||||
fetchMoreMemos(state.nextPageToken);
|
||||
if (nearBottom && !isRequesting) {
|
||||
fetchMoreMemos(nextPageToken);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [state.nextPageToken, state.isRequesting]);
|
||||
}, [nextPageToken, isRequesting]);
|
||||
|
||||
const children = (
|
||||
<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}
|
||||
listMode={viewStore.state.layout === "LIST"}
|
||||
/>
|
||||
{state.isRequesting && (
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isRequesting && (
|
||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||
<LoaderIcon className="animate-spin text-zinc-500" />
|
||||
</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">
|
||||
<Empty />
|
||||
<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>
|
||||
);
|
||||
|
||||
// In case of md screen, we don't need pull to refresh.
|
||||
if (md) {
|
||||
return children;
|
||||
}
|
||||
@@ -186,25 +196,16 @@ const PagedMemoList = observer((props: Props) => {
|
||||
const BackToTop = () => {
|
||||
const t = useTranslate();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [shouldRender, setShouldRender] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const shouldBeVisible = window.scrollY > 400;
|
||||
if (shouldBeVisible !== isVisible) {
|
||||
if (shouldBeVisible) {
|
||||
setShouldRender(true);
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setShouldRender(false);
|
||||
setIsVisible(false);
|
||||
}
|
||||
}
|
||||
const shouldShow = window.scrollY > 400;
|
||||
setIsVisible(shouldShow);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [isVisible]);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
@@ -213,7 +214,8 @@ const BackToTop = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!shouldRender) {
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@@ -4,10 +4,11 @@ import { cn } from "@/utils";
|
||||
|
||||
interface Props {
|
||||
visibility: Visibility;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VisibilityIcon = (props: Props) => {
|
||||
const { visibility } = props;
|
||||
const { className, visibility } = props;
|
||||
|
||||
let VIcon = null;
|
||||
if (visibility === Visibility.PRIVATE) {
|
||||
@@ -21,7 +22,7 @@ const VisibilityIcon = (props: Props) => {
|
||||
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;
|
||||
|
Reference in New Issue
Block a user