feat: optimize filters sync (#4260)

* refactor: add bi-directional filters sync between filterStore and searchParams

* fix: tag redirection from memos detail page, https://github.com/usememos/memos/issues/4232
pull/4279/head
Chris Curry 4 months ago committed by GitHub
parent e3d1967db8
commit d81174ad7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -75,7 +75,7 @@ const EmbeddedMemo = ({ resourceId: uid, params: paramsStr }: Props) => {
<span className="text-xs opacity-60 leading-5 cursor-pointer hover:opacity-80" onClick={() => copyMemoUid(memo.uid)}>
{memo.uid.slice(0, 6)}
</span>
<Link className="opacity-60 hover:opacity-80" to={`/m/${memo.uid}`} viewTransition>
<Link className="opacity-60 hover:opacity-80" to={`/m/${memo.uid}`} state={{ from: context.parentPage }} viewTransition>
<ArrowUpRightIcon className="w-5 h-auto" />
</Link>
</div>

@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useContext, useEffect } from "react";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useMemoStore } from "@/store/v1";
import { RendererContext } from "../types";
import Error from "./Error";
interface Props {
@ -15,6 +16,7 @@ const ReferencedMemo = ({ resourceId: uid, params: paramsStr }: Props) => {
const memoStore = useMemoStore();
const memo = memoStore.getMemoByUid(uid);
const params = new URLSearchParams(paramsStr);
const context = useContext(RendererContext);
useEffect(() => {
memoStore.fetchMemoByUid(uid).finally(() => loadingState.setFinish());
@ -31,7 +33,11 @@ const ReferencedMemo = ({ resourceId: uid, params: paramsStr }: Props) => {
const displayContent = paramsText || (memo.snippet.length > 12 ? `${memo.snippet.slice(0, 12)}...` : memo.snippet);
const handleGotoMemoDetailPage = () => {
navigateTo(`/m/${memo.uid}`);
navigateTo(`/m/${memo.uid}`, {
state: {
from: context.parentPage,
},
});
};
return (

@ -1,6 +1,9 @@
import clsx from "clsx";
import { useContext } from "react";
import { useMemoFilterStore } from "@/store/v1";
import { useLocation } from "react-router-dom";
import useNavigateTo from "@/hooks/useNavigateTo";
import { Routes } from "@/router";
import { stringifyFilters, useMemoFilterStore } from "@/store/v1";
import { RendererContext } from "./types";
interface Props {
@ -10,12 +13,24 @@ interface Props {
const Tag: React.FC<Props> = ({ content }: Props) => {
const context = useContext(RendererContext);
const memoFilterStore = useMemoFilterStore();
const location = useLocation();
const navigateTo = useNavigateTo();
const handleTagClick = () => {
if (context.disableFilter) {
return;
}
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) {
const pathname = context.parentPage || Routes.ROOT;
const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: content }]));
navigateTo(`${pathname}?${searchParams.toString()}`);
return;
}
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter) => filter.value === content);
if (isActive) {
memoFilterStore.removeFilter((f) => f.factor === "tagSearch" && f.value === content);

@ -24,6 +24,7 @@ interface Props {
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
parentPage?: string;
}
type ContentCompactView = "ALL" | "SNIPPET";
@ -79,6 +80,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
readonly: !allowEdit,
disableFilter: props.disableFilter,
embeddedMemos: embeddedMemos || new Set(),
parentPage: props.parentPage,
}}
>
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-400 ${className || ""}`}>

@ -9,6 +9,7 @@ interface Context {
memoName?: string;
readonly?: boolean;
disableFilter?: boolean;
parentPage?: string;
}
export const RendererContext = createContext<Context>({

@ -9,9 +9,10 @@ import MemoRelationForceGraph from "../MemoRelationForceGraph";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const MemoDetailSidebar = ({ memo, className }: Props) => {
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const property = MemoProperty.fromPartial(memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
@ -27,7 +28,7 @@ const MemoDetailSidebar = ({ memo, className }: Props) => {
<div className="flex flex-col justify-start items-start w-full px-1 gap-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
{shouldShowRelationGraph && (
<div className="relative w-full h-36 border rounded-lg bg-zinc-50 dark:bg-zinc-900 dark:border-zinc-800">
<MemoRelationForceGraph className="w-full h-full" memo={memo} />
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
<div className="absolute top-1 left-2 text-xs opacity-60 font-mono gap-1 flex flex-row items-center">
<span>Relations</span>
<span className="text-xs opacity-60">(Beta)</span>

@ -8,9 +8,10 @@ import MemoDetailSidebar from "./MemoDetailSidebar";
interface Props {
memo: Memo;
parentPage?: string;
}
const MemoDetailSidebarDrawer = ({ memo }: Props) => {
const MemoDetailSidebarDrawer = ({ memo, parentPage }: Props) => {
const location = useLocation();
const [open, setOpen] = useState(false);
@ -32,7 +33,7 @@ const MemoDetailSidebarDrawer = ({ memo }: Props) => {
</Button>
<Drawer anchor="right" size="sm" open={open} onClose={toggleDrawer(false)}>
<div className="w-full h-full px-4 bg-zinc-100 dark:bg-zinc-900">
<MemoDetailSidebar className="py-4" memo={memo} />
<MemoDetailSidebar className="py-4" memo={memo} parentPage={parentPage} />
</div>
</Drawer>
</>

@ -1,47 +1,61 @@
import { isEqual } from "lodash-es";
import { CalendarIcon, CheckCircleIcon, CodeIcon, EyeIcon, FilterIcon, LinkIcon, SearchIcon, TagIcon, XIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious";
import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters, useMemoFilterStore } from "@/store/v1";
const MemoFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const memoFilterStore = useMemoFilterStore();
const filters = memoFilterStore.filters;
const prevFilters = usePrevious(filters);
const orderByTimeAsc = memoFilterStore.orderByTimeAsc;
const prevOrderByTimeAsc = usePrevious(orderByTimeAsc);
const lastUpdateRef = useRef<"url" | "store">("url");
// Sync the filters and orderByTimeAsc to the search params.
// set lastUpdateRef to store when filters or orderByTimeAsc changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams);
lastUpdateRef.current = "store";
}, [filters, orderByTimeAsc]);
if (prevOrderByTimeAsc !== orderByTimeAsc) {
if (orderByTimeAsc) {
newSearchParams.set("orderBy", "asc");
} else {
newSearchParams.delete("orderBy");
}
}
// set lastUpdateRef to url when searchParams changes
useEffect(() => {
lastUpdateRef.current = "url";
}, [searchParams]);
const checkAndSync = () => {
const filtersInURL = searchParams.get("filter") || "";
const orderByTimeAscInURL = searchParams.get("orderBy") === "asc";
const storeMatchesURL = filtersInURL === stringifyFilters(filters) && orderByTimeAscInURL === orderByTimeAsc;
if (!storeMatchesURL) {
if (lastUpdateRef.current === "url") {
// Sync URL -> Store
memoFilterStore.setState({
filters: parseFilterQuery(filtersInURL),
orderByTimeAsc: orderByTimeAscInURL,
});
} else if (lastUpdateRef.current === "store") {
// Sync Store -> URL
const newSearchParams = new URLSearchParams(searchParams);
if (prevFilters && stringifyFilters(prevFilters) !== stringifyFilters(filters)) {
if (filters.length > 0) {
newSearchParams.set("filter", stringifyFilters(filters));
} else {
newSearchParams.delete("filter");
if (orderByTimeAsc) {
newSearchParams.set("orderBy", "asc");
} else {
newSearchParams.delete("orderBy");
}
if (filters.length > 0) {
newSearchParams.set("filter", stringifyFilters(filters));
} else {
newSearchParams.delete("filter");
}
setSearchParams(newSearchParams);
}
}
};
setSearchParams(newSearchParams);
}, [prevOrderByTimeAsc, orderByTimeAsc, prevFilters, filters, searchParams]);
// Sync the search params to the filters and orderByTimeAsc when the component is mounted.
useEffect(() => {
const newFilters = parseFilterQuery(searchParams.get("filter"));
const newOrderByTimeAsc = searchParams.get("orderBy") === "asc";
memoFilterStore.setState({ filters: newFilters, orderByTimeAsc: newOrderByTimeAsc });
}, []);
// Watch both URL and store changes
useEffect(checkAndSync, [searchParams, filters, orderByTimeAsc]);
const getFilterDisplayText = (filter: MemoFilter) => {
if (filter.value) {

@ -11,12 +11,13 @@ import { convertMemoRelationsToGraphData } from "./utils";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const MAIN_NODE_COLOR = "#14b8a6";
const DEFAULT_NODE_COLOR = "#a1a1aa";
const MemoRelationForceGraph = ({ className, memo }: Props) => {
const MemoRelationForceGraph = ({ className, memo, parentPage }: Props) => {
const navigateTo = useNavigateTo();
const { mode } = useColorScheme();
const containerRef = useRef<HTMLDivElement>(null);
@ -30,7 +31,11 @@ const MemoRelationForceGraph = ({ className, memo }: Props) => {
const onNodeClick = (node: NodeObject<NodeType>) => {
if (node.memo.uid === memo.uid) return;
navigateTo(`/m/${node.memo.uid}`);
navigateTo(`/m/${node.memo.uid}`, {
state: {
from: parentPage,
},
});
};
return (

@ -8,10 +8,11 @@ import { Memo } from "@/types/proto/api/v1/memo_service";
interface Props {
memo: Memo;
relations: MemoRelation[];
parentPage?: string;
}
const MemoRelationListView = (props: Props) => {
const { memo, relations: relationList } = props;
const { memo, relations: relationList, parentPage } = props;
const referencingMemoList = relationList
.filter((relation) => relation.memo?.name === memo.name && relation.relatedMemo?.name !== memo.name)
.map((relation) => relation.relatedMemo!);
@ -65,6 +66,9 @@ const MemoRelationListView = (props: Props) => {
className="w-auto max-w-full flex flex-row justify-start items-center text-sm leading-5 text-gray-600 dark:text-gray-400 dark:border-zinc-700 dark:bg-zinc-900 hover:underline"
to={`/m/${memo.uid}`}
viewTransition
state={{
from: parentPage,
}}
>
<span className="text-xs opacity-60 leading-4 border font-mono px-1 rounded-full mr-1 dark:border-zinc-700">
{memo.uid.slice(0, 6)}
@ -84,6 +88,9 @@ const MemoRelationListView = (props: Props) => {
className="w-auto max-w-full flex flex-row justify-start items-center text-sm leading-5 text-gray-600 dark:text-gray-400 dark:border-zinc-700 dark:bg-zinc-900 hover:underline"
to={`/m/${memo.uid}`}
viewTransition
state={{
from: parentPage,
}}
>
<span className="text-xs opacity-60 leading-4 border font-mono px-1 rounded-full mr-1 dark:border-zinc-700">
{memo.uid.slice(0, 6)}

@ -35,6 +35,7 @@ interface Props {
showVisibility?: boolean;
showPinned?: boolean;
className?: string;
parentPage?: string;
}
const MemoView: React.FC<Props> = (props: Props) => {
@ -60,6 +61,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
const relativeTimeFormat = Date.now() - memo.displayTime!.getTime() > 1000 * 60 * 60 * 24 ? "datetime" : "auto";
const readonly = memo.creator !== user?.name && !isSuperUser(user);
const isInMemoDetailPage = location.pathname.startsWith(`/m/${memo.uid}`);
const parentPage = props.parentPage || location.pathname;
// Initial related data: creator.
useAsyncEffect(async () => {
@ -68,8 +70,12 @@ const MemoView: React.FC<Props> = (props: Props) => {
}, []);
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/m/${memo.uid}`);
}, [memo.uid]);
navigateTo(`/m/${memo.uid}`, {
state: {
from: parentPage,
},
});
}, [memo.uid, parentPage]);
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
@ -217,6 +223,9 @@ const MemoView: React.FC<Props> = (props: Props) => {
)}
to={`/m/${memo.uid}#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>}
@ -242,10 +251,11 @@ const MemoView: React.FC<Props> = (props: Props) => {
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={props.compact && workspaceMemoRelatedSetting.enableAutoCompact}
parentPage={parentPage}
/>
{memo.location && <MemoLocationView location={memo.location} />}
<MemoResourceListView resources={memo.resources} />
<MemoRelationListView memo={memo} relations={referencedMemos} />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
</>
)}

@ -1,15 +1,15 @@
import { useNavigate } from "react-router-dom";
import { NavigateOptions, useNavigate } from "react-router-dom";
const useNavigateTo = () => {
const navigateTo = useNavigate();
const navigateToWithViewTransition = (to: string) => {
const navigateToWithViewTransition = (to: string, options?: NavigateOptions) => {
const document = window.document as any;
if (!document.startViewTransition) {
navigateTo(to);
navigateTo(to, options);
} else {
document.startViewTransition(() => {
navigateTo(to);
navigateTo(to, options);
});
}
};

@ -4,7 +4,7 @@ import { ArrowUpLeftFromCircleIcon, MessageCircleIcon } from "lucide-react";
import { ClientError } from "nice-grpc-web";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Link, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView";
@ -23,6 +23,7 @@ const MemoDetail = () => {
const { md } = useResponsiveWidth();
const params = useParams();
const navigateTo = useNavigateTo();
const { state: locationState } = useLocation();
const workspaceSettingStore = useWorkspaceSettingStore();
const currentUser = useCurrentUser();
const memoStore = useMemoStore();
@ -86,7 +87,7 @@ const MemoDetail = () => {
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && (
<MobileHeader>
<MemoDetailSidebarDrawer memo={memo} />
<MemoDetailSidebarDrawer memo={memo} parentPage={locationState?.from} />
</MobileHeader>
)}
<div className={clsx("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
@ -96,6 +97,7 @@ const MemoDetail = () => {
<Link
className="px-3 py-1 border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-gray-600 dark:text-gray-400 dark:border-gray-500 hover:shadow hover:opacity-80"
to={`/m/${parentMemo.uid}`}
state={locationState}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
@ -108,6 +110,7 @@ const MemoDetail = () => {
className="shadow hover:shadow-md transition-all"
memo={memo}
compact={false}
parentPage={locationState?.from}
showCreator
showVisibility
showPinned
@ -141,7 +144,13 @@ const MemoDetail = () => {
)}
</div>
{comments.map((comment) => (
<MemoView key={`${comment.name}-${comment.displayTime}`} memo={comment} showCreator compact />
<MemoView
key={`${comment.name}-${comment.displayTime}`}
memo={comment}
parentPage={locationState?.from}
showCreator
compact
/>
))}
</>
)}
@ -162,7 +171,7 @@ const MemoDetail = () => {
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<MemoDetailSidebar className="py-6" memo={memo} />
<MemoDetailSidebar className="py-6" memo={memo} parentPage={locationState?.from} />
</div>
)}
</div>

Loading…
Cancel
Save