refactor: memo editor (#4730)

pull/4533/merge
Johnny 2 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;
}
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);
return {
...prevState,
itemHeights: newItemHeights,
distribution,
columnHeights,
};
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, newItemHeights, prefixHeight);
setDistribution(newDistribution);
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;
}
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 (!containerRef.current) return;
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,7 +205,15 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
insertText += " |";
}
editorActions.insertText("\n" + 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);
}
}
};
@ -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,131 +120,126 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
<relative-time datetime={memo.displayTime?.toISOString()} format={relativeTimeFormat}></relative-time>
);
return (
return showEditor ? (
<MemoEditor
autoFocus
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,
)}
ref={memoContainerRef}
>
{showEditor ? (
<MemoEditor
autoFocus
className="border-none !p-0 -mb-2"
cacheKey={`inline-memo-editor-${memo.name}`}
memoName={memo.name}
onConfirm={onEditorConfirm}
onCancel={() => setShowEditor(false)}
/>
) : (
<>
<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 ? (
<div className="w-full flex flex-row justify-start items-center">
<Link className="w-auto hover:opacity-80" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
</Link>
<div className="w-full flex flex-col justify-center items-start">
<Link
className="w-full block leading-tight hover:opacity-80 truncate text-gray-600 dark:text-gray-400"
to={`/u/${encodeURIComponent(creator.username)}`}
viewTransition
>
{creator.nickname || creator.username}
</Link>
<div
className="w-auto -mt-0.5 text-xs leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick={handleGotoMemoDetailPage}
>
{displayTime}
</div>
</div>
</div>
) : (
<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 ? (
<div className="w-full flex flex-row justify-start items-center">
<Link className="w-auto hover:opacity-80" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
</Link>
<div className="w-full flex flex-col justify-center items-start">
<Link
className="w-full block leading-tight hover:opacity-80 truncate text-gray-600 dark:text-gray-400"
to={`/u/${encodeURIComponent(creator.username)}`}
viewTransition
>
{creator.nickname || creator.username}
</Link>
<div
className="w-full text-sm leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
className="w-auto -mt-0.5 text-xs leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick={handleGotoMemoDetailPage}
>
{displayTime}
</div>
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
<div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2">
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip title={t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} placement="top">
<span className="flex justify-center items-center hover:opacity-70">
<VisibilityIcon visibility={memo.visibility} />
</span>
</Tooltip>
)}
{currentUser && !isArchived && <ReactionSelector className="border-none w-auto h-auto" memo={memo} />}
</div>
{!isInMemoDetailPage && (workspaceMemoRelatedSetting.enableComment || commentAmount > 0) && (
<Link
className={cn(
"flex flex-row justify-start items-center hover:opacity-70",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/${memo.name}#comments`}
viewTransition
state={{
from: parentPage,
}}
>
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" />
{commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>}
</Link>
)}
{props.showPinned && memo.pinned && (
<Tooltip title={t("common.unpin")} placement="top">
<span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-amber-500" onClick={onPinIconClick} />
</span>
</Tooltip>
)}
{nsfw && showNSFWContent && (
<span className="cursor-pointer">
<EyeOffIcon className="w-4 h-auto text-amber-500" onClick={() => setShowNSFWContent(false)} />
</span>
)}
<MemoActionMenu className="-ml-1" memo={memo} readonly={readonly} onEdit={() => setShowEditor(true)} />
</div>
</div>
<div
className={cn(
"w-full flex flex-col justify-start items-start gap-2",
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
) : (
<div
className="w-full text-sm leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick={handleGotoMemoDetailPage}
>
{displayTime}
</div>
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
<div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2">
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip title={t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} placement="top">
<span className="flex justify-center items-center hover:opacity-70">
<VisibilityIcon visibility={memo.visibility} />
</span>
</Tooltip>
)}
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
nodes={memo.nodes}
readonly={readonly}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
parentPage={parentPage}
/>
{memo.location && <MemoLocationView location={memo.location} />}
<MemoResourceListView resources={memo.resources} />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
{currentUser && !isArchived && <ReactionSelector className="border-none w-auto h-auto" memo={memo} />}
</div>
{nsfw && !showNSFWContent && (
<>
<div className="absolute inset-0 bg-transparent" />
<button
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-zinc-800"
onClick={() => setShowNSFWContent(true)}
>
{t("memo.click-to-show-nsfw-content")}
</button>
</>
{!isInMemoDetailPage && (workspaceMemoRelatedSetting.enableComment || commentAmount > 0) && (
<Link
className={cn(
"flex flex-row justify-start items-center hover:opacity-70",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/${memo.name}#comments`}
viewTransition
state={{
from: parentPage,
}}
>
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" />
{commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>}
</Link>
)}
{props.showPinned && memo.pinned && (
<Tooltip title={t("common.unpin")} placement="top">
<span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-amber-500" onClick={onPinIconClick} />
</span>
</Tooltip>
)}
{nsfw && showNSFWContent && (
<span className="cursor-pointer">
<EyeOffIcon className="w-4 h-auto text-amber-500" onClick={() => setShowNSFWContent(false)} />
</span>
)}
<MemoActionMenu className="-ml-1" memo={memo} readonly={readonly} onEdit={() => setShowEditor(true)} />
</div>
</div>
<div
className={cn(
"w-full flex flex-col justify-start items-start gap-2",
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
)}
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
nodes={memo.nodes}
readonly={readonly}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
parentPage={parentPage}
/>
{memo.location && <MemoLocationView location={memo.location} />}
<MemoResourceListView resources={memo.resources} />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
</div>
{nsfw && !showNSFWContent && (
<>
<div className="absolute inset-0 bg-transparent" />
<button
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-zinc-800"
onClick={() => setShowNSFWContent(true)}
>
{t("memo.click-to-show-nsfw-content")}
</button>
</>
)}
</div>

@ -26,108 +26,115 @@ 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 }));
const response = await memoStore.fetchMemos({
parent: props.owner || "",
state: props.state || State.NORMAL,
direction: props.direction || Direction.DESC,
filter: props.filter || "",
oldFilter: props.oldFilter || "",
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
pageToken: nextPageToken,
});
setState(() => ({
isRequesting: false,
nextPageToken: response?.nextPageToken || "",
}));
// 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,
direction: props.direction || Direction.DESC,
filter: props.filter || "",
oldFilter: props.oldFilter || "",
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
pageToken,
});
setNextPageToken(response?.nextPageToken || "");
} finally {
setIsRequesting(false);
}
};
// 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
};
// Check if content fills the viewport and fetch more if needed
// 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,
);
const windowHeight = window.innerHeight;
const isScrollable = documentHeight > windowHeight + 100; // 100px buffer
// 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(() => {
// 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;
if (shouldFetch) {
await fetchMoreMemos(nextPageToken);
// 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;

Loading…
Cancel
Save