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;
|
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,7 +205,15 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
|||||||
insertText += " |";
|
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
|
<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,131 +120,126 @@ 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 ? (
|
||||||
|
<MemoEditor
|
||||||
|
autoFocus
|
||||||
|
className="mb-2"
|
||||||
|
cacheKey={`inline-memo-editor-${memo.name}`}
|
||||||
|
memoName={memo.name}
|
||||||
|
onConfirm={onEditorConfirm}
|
||||||
|
onCancel={() => setShowEditor(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={memoContainerRef}
|
|
||||||
>
|
>
|
||||||
{showEditor ? (
|
<div className="w-full flex flex-row justify-between items-center gap-2">
|
||||||
<MemoEditor
|
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||||
autoFocus
|
{props.showCreator && creator ? (
|
||||||
className="border-none !p-0 -mb-2"
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
cacheKey={`inline-memo-editor-${memo.name}`}
|
<Link className="w-auto hover:opacity-80" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
|
||||||
memoName={memo.name}
|
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
||||||
onConfirm={onEditorConfirm}
|
</Link>
|
||||||
onCancel={() => setShowEditor(false)}
|
<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)}`}
|
||||||
<div className="w-full flex flex-row justify-between items-center gap-2">
|
viewTransition
|
||||||
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
>
|
||||||
{props.showCreator && creator ? (
|
{creator.nickname || creator.username}
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
</Link>
|
||||||
<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
|
<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}
|
onClick={handleGotoMemoDetailPage}
|
||||||
>
|
>
|
||||||
{displayTime}
|
{displayTime}
|
||||||
</div>
|
</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>
|
</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>
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="w-full text-sm leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
|
||||||
"w-full flex flex-col justify-start items-start gap-2",
|
onClick={handleGotoMemoDetailPage}
|
||||||
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
|
>
|
||||||
)}
|
{displayTime}
|
||||||
>
|
</div>
|
||||||
<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>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
@@ -26,108 +26,115 @@ 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) => {
|
||||||
const response = await memoStore.fetchMemos({
|
setIsRequesting(true);
|
||||||
parent: props.owner || "",
|
|
||||||
state: props.state || State.NORMAL,
|
try {
|
||||||
direction: props.direction || Direction.DESC,
|
const response = await memoStore.fetchMemos({
|
||||||
filter: props.filter || "",
|
parent: props.owner || "",
|
||||||
oldFilter: props.oldFilter || "",
|
state: props.state || State.NORMAL,
|
||||||
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
direction: props.direction || Direction.DESC,
|
||||||
pageToken: nextPageToken,
|
filter: props.filter || "",
|
||||||
});
|
oldFilter: props.oldFilter || "",
|
||||||
setState(() => ({
|
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||||
isRequesting: false,
|
pageToken,
|
||||||
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 () => {
|
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;
|
||||||
|
Reference in New Issue
Block a user