feat: surface newly created memo above pinned list
This commit is contained in:
@@ -4,13 +4,24 @@ import { defaultSchema } from "rehype-sanitize";
|
||||
export const TASK_LIST_CLASS = "contains-task-list";
|
||||
export const TASK_LIST_ITEM_CLASS = "task-list-item";
|
||||
|
||||
// Content rows use leading-6 (1.5rem line-height = 24px at the 16px root font).
|
||||
const ROW_HEIGHT_PX = 24;
|
||||
|
||||
// Compact mode display settings
|
||||
export const COMPACT_MODE_CONFIG = {
|
||||
maxHeightVh: 60, // 60% of viewport height
|
||||
gradientHeight: "h-24", // Tailwind class for gradient overlay
|
||||
previewRows: 6, // collapsed preview height, in content rows
|
||||
triggerRows: 8, // only fold when content is taller than this (2-row buffer over the preview)
|
||||
gradientHeight: "h-12", // Tailwind class for the bottom fade overlay (~2 rows)
|
||||
} as const;
|
||||
|
||||
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
|
||||
// Height the collapsed preview is clamped to, in pixels.
|
||||
export const getPreviewMaxHeightPx = () => COMPACT_MODE_CONFIG.previewRows * ROW_HEIGHT_PX;
|
||||
|
||||
// Height a memo must exceed before it gets folded at all, in pixels.
|
||||
export const getCompactTriggerHeightPx = () => COMPACT_MODE_CONFIG.triggerRows * ROW_HEIGHT_PX;
|
||||
|
||||
// Whether content of the given rendered height should be collapsed. Kept pure for unit testing.
|
||||
export const shouldCompactContent = (contentHeightPx: number, triggerHeightPx: number) => contentHeightPx > triggerHeightPx;
|
||||
|
||||
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
|
||||
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
|
||||
import { COMPACT_STATES, getCompactTriggerHeightPx, shouldCompactContent } from "./constants";
|
||||
import type { ContentCompactView } from "./types";
|
||||
|
||||
export const useCompactMode = (enabled: boolean, revision: string) => {
|
||||
@@ -12,8 +12,8 @@ export const useCompactMode = (enabled: boolean, revision: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxHeight = getMaxDisplayHeight();
|
||||
const shouldCompact = Math.max(containerRef.current.scrollHeight, containerRef.current.getBoundingClientRect().height) > maxHeight;
|
||||
const contentHeight = Math.max(containerRef.current.scrollHeight, containerRef.current.getBoundingClientRect().height);
|
||||
const shouldCompact = shouldCompactContent(contentHeight, getCompactTriggerHeightPx());
|
||||
setMode((currentMode) => {
|
||||
if (!shouldCompact) {
|
||||
return undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { memo, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
import { COMPACT_MODE_CONFIG } from "./constants";
|
||||
import { COMPACT_MODE_CONFIG, getPreviewMaxHeightPx } from "./constants";
|
||||
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
|
||||
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
||||
@@ -36,7 +36,7 @@ const MemoContent = (props: MemoContentProps) => {
|
||||
showCompactMode === "ALL" && "overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
|
||||
style={showCompactMode === "ALL" ? { maxHeight: `${getPreviewMaxHeightPx()}px` } : undefined}
|
||||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
@@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const { aiSetting, fetchSetting } = useInstance();
|
||||
const { markNewMemo } = useNewMemo();
|
||||
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
|
||||
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
|
||||
|
||||
@@ -280,6 +282,12 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
|
||||
}
|
||||
|
||||
// Surface a freshly created top-level memo at the top of the list so it
|
||||
// stays visible even when pinned memos would otherwise push it down.
|
||||
if (!memoName && !parentMemoName) {
|
||||
markNewMemo(result.memoName);
|
||||
}
|
||||
|
||||
// Notify parent component of successful save
|
||||
onConfirm?.(result.memoName);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookmarkIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -23,6 +24,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
||||
|
||||
const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
|
||||
const { createTime, updateTime, displayTime: memoDisplayTime, isDisplayingUpdatedTime, relativeTimeFormat } = useMemoViewDerived();
|
||||
const { newMemoName } = useNewMemo();
|
||||
|
||||
const navigateTo = useNavigateTo();
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
@@ -59,6 +61,11 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
||||
) : (
|
||||
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
|
||||
)}
|
||||
{memo.name === newMemoName && (
|
||||
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium leading-none text-primary">
|
||||
{t("memo.new-badge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||
|
||||
@@ -6,8 +6,10 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
|
||||
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
@@ -102,8 +104,13 @@ const PagedMemoList = (props: Props) => {
|
||||
// Flatten pages into a single array of memos
|
||||
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
|
||||
|
||||
// Apply custom sorting if provided, otherwise use memos directly
|
||||
const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]);
|
||||
// Apply custom sorting if provided, otherwise use memos directly, then hoist
|
||||
// a freshly created memo to the very top so it stays visible above pins.
|
||||
const { newMemoName } = useNewMemo();
|
||||
const sortedMemoList = useMemo(() => {
|
||||
const sorted = props.listSort ? props.listSort(memos) : memos;
|
||||
return hoistMemoToFront(sorted, newMemoName);
|
||||
}, [memos, props.listSort, newMemoName]);
|
||||
|
||||
// Prefetch creators when new data arrives to improve performance
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
||||
|
||||
interface NewMemoContextValue {
|
||||
// Name of the most recently created memo in this view, or null. Only one
|
||||
// memo is ever "new" at a time: creating another overwrites it, which clears
|
||||
// the previous one for free.
|
||||
newMemoName: string | null;
|
||||
markNewMemo: (name: string) => void;
|
||||
}
|
||||
|
||||
// Default is a safe no-op so MemoEditor/MemoView rendered outside a provider
|
||||
// (dialogs, detail pages, other lists) keep working without a "new" marker.
|
||||
const NewMemoContext = createContext<NewMemoContextValue>({
|
||||
newMemoName: null,
|
||||
markNewMemo: () => {},
|
||||
});
|
||||
|
||||
export function NewMemoProvider({ children }: { children: ReactNode }) {
|
||||
const [newMemoName, setNewMemoName] = useState<string | null>(null);
|
||||
// setNewMemoName is identity-stable, so the value only changes with newMemoName.
|
||||
// Memoize it so a Home re-render doesn't cascade into every subscribed memo row.
|
||||
const value = useMemo(() => ({ newMemoName, markNewMemo: setNewMemoName }), [newMemoName]);
|
||||
|
||||
return <NewMemoContext.Provider value={value}>{children}</NewMemoContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNewMemo() {
|
||||
return useContext(NewMemoContext);
|
||||
}
|
||||
@@ -20,6 +20,19 @@ const getMemoSortTime = (memo: Memo, timeBasis: MemoTimeBasis): Date | undefined
|
||||
return timestamp ? timestampDate(timestamp) : undefined;
|
||||
};
|
||||
|
||||
// Moves the memo with the given name to the front of the list, above pinned
|
||||
// memos, so a freshly created memo is immediately visible. Returns the input
|
||||
// unchanged when the name is null, absent, or already first.
|
||||
export const hoistMemoToFront = (memos: Memo[], name: string | null): Memo[] => {
|
||||
if (!name) return memos;
|
||||
const index = memos.findIndex((memo) => memo.name === name);
|
||||
if (index <= 0) return memos;
|
||||
const reordered = memos.slice();
|
||||
const [memo] = reordered.splice(index, 1);
|
||||
reordered.unshift(memo);
|
||||
return reordered;
|
||||
};
|
||||
|
||||
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
|
||||
const { pinnedFirst = false, state = State.NORMAL } = options;
|
||||
const { orderByTimeAsc, timeBasis } = useView();
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"load-more": "Load more",
|
||||
"no-archived-memos": "No archived memos.",
|
||||
"no-memos": "No memos.",
|
||||
"new-badge": "New",
|
||||
"newest-first": "Newest first",
|
||||
"oldest-first": "Oldest first",
|
||||
"order": "Order",
|
||||
|
||||
+11
-8
@@ -1,6 +1,7 @@
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
@@ -23,14 +24,16 @@ const Home = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-full bg-background text-foreground">
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
|
||||
listSort={listSort}
|
||||
orderBy={orderBy}
|
||||
filter={memoFilter}
|
||||
enabled={isInitialized}
|
||||
showMemoEditor
|
||||
/>
|
||||
<NewMemoProvider>
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
|
||||
listSort={listSort}
|
||||
orderBy={orderBy}
|
||||
filter={memoFilter}
|
||||
enabled={isInitialized}
|
||||
showMemoEditor
|
||||
/>
|
||||
</NewMemoProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const memo = (name: string) => ({ name }) as Memo;
|
||||
|
||||
describe("hoistMemoToFront", () => {
|
||||
it("moves the named memo to the front, above everything else", () => {
|
||||
const list = [memo("memos/pin1"), memo("memos/pin2"), memo("memos/new")];
|
||||
expect(hoistMemoToFront(list, "memos/new").map((m) => m.name)).toEqual(["memos/new", "memos/pin1", "memos/pin2"]);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the name is null", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, null)).toBe(list);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the name is absent", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, "memos/missing")).toBe(list);
|
||||
});
|
||||
|
||||
it("returns the list unchanged when the memo is already first", () => {
|
||||
const list = [memo("memos/a"), memo("memos/b")];
|
||||
expect(hoistMemoToFront(list, "memos/a")).toBe(list);
|
||||
});
|
||||
|
||||
it("does not mutate the input list", () => {
|
||||
const list = [memo("memos/a"), memo("memos/new")];
|
||||
hoistMemoToFront(list, "memos/new");
|
||||
expect(list.map((m) => m.name)).toEqual(["memos/a", "memos/new"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMPACT_MODE_CONFIG,
|
||||
getCompactTriggerHeightPx,
|
||||
getPreviewMaxHeightPx,
|
||||
shouldCompactContent,
|
||||
} from "@/components/MemoContent/constants";
|
||||
|
||||
describe("compact folded preview sizing", () => {
|
||||
it("clamps the collapsed preview to fewer rows than the fold trigger", () => {
|
||||
// Preview must be shorter than the trigger, otherwise folding shows everything.
|
||||
expect(COMPACT_MODE_CONFIG.previewRows).toBeLessThan(COMPACT_MODE_CONFIG.triggerRows);
|
||||
expect(getPreviewMaxHeightPx()).toBeLessThan(getCompactTriggerHeightPx());
|
||||
});
|
||||
|
||||
it("folds only when content is taller than the trigger height", () => {
|
||||
const trigger = getCompactTriggerHeightPx();
|
||||
expect(shouldCompactContent(trigger + 1, trigger)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves content at or below the trigger height fully expanded", () => {
|
||||
const trigger = getCompactTriggerHeightPx();
|
||||
// A memo a row or two over the preview but within the buffer stays open.
|
||||
expect(shouldCompactContent(getPreviewMaxHeightPx() + 1, trigger)).toBe(false);
|
||||
expect(shouldCompactContent(trigger, trigger)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user