From ea4e7a16062f788f0f0afca3499a28ebf63c50fe Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 29 May 2025 07:46:40 +0800 Subject: [PATCH] refactor: memo editor (#4730) --- .../components/MasonryView/MasonryView.tsx | 142 +++++------ .../ActionButton/VisibilitySelector.tsx | 64 +++++ .../components/MemoEditor/Editor/index.tsx | 17 +- web/src/components/MemoEditor/index.tsx | 50 ++-- web/src/components/MemoView.tsx | 220 +++++++++--------- .../PagedMemoList/PagedMemoList.tsx | 150 ++++++------ web/src/components/VisibilityIcon.tsx | 5 +- 7 files changed, 335 insertions(+), 313 deletions(-) create mode 100644 web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx diff --git a/web/src/components/MasonryView/MasonryView.tsx b/web/src/components/MasonryView/MasonryView.tsx index d6fc532d..10aad9c7 100644 --- a/web/src/components/MasonryView/MasonryView.tsx +++ b/web/src/components/MasonryView/MasonryView.tsx @@ -9,22 +9,15 @@ interface Props { listMode?: boolean; } -interface LocalState { - columns: number; - itemHeights: Map; - 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(null); const resizeObserverRef = useRef(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
{renderer(memo)}
; }; -// 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, 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({ - columns: 1, - itemHeights: new Map(), - columnHeights: [0], - distribution: [[]], - }); + const [columns, setColumns] = useState(1); + const [itemHeights, setItemHeights] = useState>(new Map()); + const [distribution, setDistribution] = useState([[]]); + const containerRef = useRef(null); const prefixElementRef = useRef(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 (
- {Array.from({ length: state.columns }).map((_, columnIndex) => ( + {Array.from({ length: columns }).map((_, columnIndex) => (
- {props.prefixElement && columnIndex === 0 && ( -
- {props.prefixElement} -
- )} - {state.distribution[columnIndex]?.map((memoIndex) => { + {/* Prefix element (like memo editor) goes in first column */} + {props.prefixElement && columnIndex === 0 &&
{props.prefixElement}
} + + {distribution[columnIndex]?.map((memoIndex) => { const memo = props.memoList[memoIndex]; return memo ? ( 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 ( + + + + + +
+ {visibilityOptions.map((option) => ( + + ))} +
+
+
+ ); +}; + +export default VisibilitySelector; diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 35c02909..5df810e1 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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< >