From 718ca2b6742c46318c8200d74e8af13866b25f40 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 31 Jan 2026 23:19:56 +0000 Subject: [PATCH] lazy load history --- .../page-history/components/history-list.tsx | 86 +++++--- .../queries/page-history-query.ts | 16 +- .../services/page-history-service.ts | 2 + .../page-history/utils/history-diff.ts | 183 ------------------ 4 files changed, 74 insertions(+), 213 deletions(-) delete mode 100644 apps/client/src/features/page-history/utils/history-diff.ts diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 356c43c7..b4a674c3 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -9,8 +9,16 @@ import { historyAtoms, } from "@/features/page-history/atoms/history-atoms"; import { useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Button, + ScrollArea, + Group, + Divider, + Text, + Loader, + Center, +} from "@mantine/core"; import { pageEditorAtom, titleEditorAtom, @@ -35,12 +43,22 @@ function HistoryList({ pageId }: Props) { const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const { - data: pageHistoryList, + data: pageHistoryData, isLoading, isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = usePageHistoryListQuery(pageId); const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const loadMoreRef = useRef(null); + const [mainEditor] = useAtom(pageEditorAtom); const [mainEditorTitle] = useAtom(titleEditorAtom); const [, setHistoryModalOpen] = useAtom(historyAtoms); @@ -82,15 +100,28 @@ function HistoryList({ pageId }: Props) { }, [activeHistoryData]); useEffect(() => { - if ( - pageHistoryList && - pageHistoryList.items.length > 0 && - !activeHistoryId - ) { - setActiveHistoryId(pageHistoryList.items[0].id); - setActiveHistoryPrevId(pageHistoryList.items[1]?.id ?? ""); + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); } - }, [pageHistoryList, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]); + }, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]); + + useEffect(() => { + const sentinel = loadMoreRef.current; + if (!sentinel || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); if (isLoading) { return <>; @@ -100,27 +131,30 @@ function HistoryList({ pageId }: Props) { return
{t("Error loading page history.")}
; } - if (!pageHistoryList || pageHistoryList.items.length === 0) { + if (historyItems.length === 0) { return <>{t("No page history saved yet.")}; } return (
- {pageHistoryList && - pageHistoryList.items.map((historyItem, index) => ( - { - setActiveHistoryId(id); - setActiveHistoryPrevId( - pageHistoryList.items[index + 1]?.id ?? "", - ); - }} - isActive={historyItem.id === activeHistoryId} - /> - ))} + {historyItems.map((historyItem, index) => ( + { + setActiveHistoryId(id); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + }} + isActive={historyItem.id === activeHistoryId} + /> + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} {spaceAbility.cannot( diff --git a/apps/client/src/features/page-history/queries/page-history-query.ts b/apps/client/src/features/page-history/queries/page-history-query.ts index 32c1dafc..6ca93f99 100644 --- a/apps/client/src/features/page-history/queries/page-history-query.ts +++ b/apps/client/src/features/page-history/queries/page-history-query.ts @@ -1,4 +1,10 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { + InfiniteData, + useInfiniteQuery, + UseInfiniteQueryResult, + useQuery, + UseQueryResult, +} from "@tanstack/react-query"; import { getPageHistoryById, getPageHistoryList, @@ -8,12 +14,14 @@ import { IPagination } from "@/lib/types.ts"; export function usePageHistoryListQuery( pageId: string, -): UseQueryResult, Error> { - return useQuery({ +): UseInfiniteQueryResult, unknown>> { + return useInfiniteQuery({ queryKey: ["page-history-list", pageId], - queryFn: () => getPageHistoryList(pageId), + queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam), enabled: !!pageId, gcTime: 0, + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined, }); } diff --git a/apps/client/src/features/page-history/services/page-history-service.ts b/apps/client/src/features/page-history/services/page-history-service.ts index 329ad70d..0e38118b 100644 --- a/apps/client/src/features/page-history/services/page-history-service.ts +++ b/apps/client/src/features/page-history/services/page-history-service.ts @@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts"; export async function getPageHistoryList( pageId: string, + cursor?: string, ): Promise> { const req = await api.post("/pages/history", { pageId, + cursor, }); return req.data; } diff --git a/apps/client/src/features/page-history/utils/history-diff.ts b/apps/client/src/features/page-history/utils/history-diff.ts deleted file mode 100644 index d37f9fb4..00000000 --- a/apps/client/src/features/page-history/utils/history-diff.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { Node as PMNode } from "@tiptap/pm/model"; - -function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(",")}]`; - } - - const obj = value as Record; - const keys = Object.keys(obj).sort(); - return `{${keys - .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`) - .join(",")}}`; -} - -type DiffOp = - | { type: "equal"; aIndex: number; bIndex: number } - | { type: "insert"; bIndex: number } - | { type: "delete"; aIndex: number }; - -function myersDiff(a: string[], b: string[]): DiffOp[] { - const N = a.length; - const M = b.length; - const max = N + M; - - let v = new Map(); - v.set(1, 0); - const trace: Array> = []; - - for (let d = 0; d <= max; d += 1) { - const vNew = new Map(); - for (let k = -d; k <= d; k += 2) { - const vKMinus = v.get(k - 1) ?? 0; - const vKPlus = v.get(k + 1) ?? 0; - - let x: number; - if (k === -d || (k !== d && vKMinus < vKPlus)) { - x = vKPlus; - } else { - x = vKMinus + 1; - } - - let y = x - k; - while (x < N && y < M && a[x] === b[y]) { - x += 1; - y += 1; - } - vNew.set(k, x); - - if (x >= N && y >= M) { - trace.push(vNew); - return backtrack(trace, a, b); - } - } - trace.push(vNew); - v = vNew; - } - - return []; -} - -function backtrack(trace: Array>, a: string[], b: string[]) { - let x = a.length; - let y = b.length; - const ops: DiffOp[] = []; - - for (let d = trace.length - 1; d > 0; d -= 1) { - const v = trace[d]; - const prevV = trace[d - 1]; - - const k = x - y; - - const prevK = - k === -d || (k !== d && (prevV.get(k - 1) ?? 0) < (prevV.get(k + 1) ?? 0)) - ? k + 1 - : k - 1; - - const prevX = prevV.get(prevK) ?? 0; - const prevY = prevX - prevK; - - while (x > prevX && y > prevY) { - ops.push({ type: "equal", aIndex: x - 1, bIndex: y - 1 }); - x -= 1; - y -= 1; - } - - if (x === prevX) { - ops.push({ type: "insert", bIndex: y - 1 }); - y -= 1; - } else { - ops.push({ type: "delete", aIndex: x - 1 }); - x -= 1; - } - } - - while (x > 0 && y > 0) { - ops.push({ type: "equal", aIndex: x - 1, bIndex: y - 1 }); - x -= 1; - y -= 1; - } - while (x > 0) { - ops.push({ type: "delete", aIndex: x - 1 }); - x -= 1; - } - while (y > 0) { - ops.push({ type: "insert", bIndex: y - 1 }); - y -= 1; - } - - ops.reverse(); - return ops; -} - -export interface HistoryBlockDiffResult { - diffDoc: PMNode; - addedNodeRanges: Array<{ from: number; to: number }>; - deletedNodeRanges: Array<{ from: number; to: number }>; - addedCount: number; - deletedCount: number; -} - -export function computeHistoryBlockDiff( - currentDoc: PMNode, - prevDoc: PMNode, -): HistoryBlockDiffResult { - const currentTop = Array.from({ length: currentDoc.childCount }, (_, i) => - currentDoc.child(i), - ); - const prevTop = Array.from({ length: prevDoc.childCount }, (_, i) => - prevDoc.child(i), - ); - - const currentHashes = currentTop.map((n) => stableStringify(n.toJSON())); - const prevHashes = prevTop.map((n) => stableStringify(n.toJSON())); - - const ops = myersDiff(prevHashes, currentHashes); - - const nodes: PMNode[] = []; - const addedIndices: number[] = []; - const deletedIndices: number[] = []; - - for (const op of ops) { - if (op.type === "equal") { - nodes.push(currentTop[op.bIndex]); - continue; - } - if (op.type === "insert") { - addedIndices.push(nodes.length); - nodes.push(currentTop[op.bIndex]); - continue; - } - deletedIndices.push(nodes.length); - nodes.push(prevTop[op.aIndex]); - } - - const diffDoc = currentDoc.type.create(null, nodes); - - const addedNodeRanges: Array<{ from: number; to: number }> = []; - const deletedNodeRanges: Array<{ from: number; to: number }> = []; - - let pos = 0; - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const from = pos; - const to = pos + node.nodeSize; - if (addedIndices.includes(i)) addedNodeRanges.push({ from, to }); - if (deletedIndices.includes(i)) deletedNodeRanges.push({ from, to }); - pos = to; - } - - return { - diffDoc, - addedNodeRanges, - deletedNodeRanges, - addedCount: addedIndices.length, - deletedCount: deletedIndices.length, - }; -} - -