lazy load history

This commit is contained in:
Philipinho
2026-01-31 23:19:56 +00:00
parent e189d01ce0
commit 718ca2b674
4 changed files with 74 additions and 213 deletions
@@ -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<HTMLDivElement>(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 <div>{t("Error loading page history.")}</div>;
}
if (!pageHistoryList || pageHistoryList.items.length === 0) {
if (historyItems.length === 0) {
return <>{t("No page history saved yet.")}</>;
}
return (
<div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{pageHistoryList &&
pageHistoryList.items.map((historyItem, index) => (
<HistoryItem
key={historyItem.id}
historyItem={historyItem}
onSelect={(id) => {
setActiveHistoryId(id);
setActiveHistoryPrevId(
pageHistoryList.items[index + 1]?.id ?? "",
);
}}
isActive={historyItem.id === activeHistoryId}
/>
))}
{historyItems.map((historyItem, index) => (
<HistoryItem
key={historyItem.id}
historyItem={historyItem}
onSelect={(id) => {
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}}
isActive={historyItem.id === activeHistoryId}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea>
{spaceAbility.cannot(
@@ -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<IPagination<IPageHistory>, Error> {
return useQuery({
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, 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,
});
}
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList(
pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", {
pageId,
cursor,
});
return req.data;
}
@@ -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<string, unknown>;
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<number, number>();
v.set(1, 0);
const trace: Array<Map<number, number>> = [];
for (let d = 0; d <= max; d += 1) {
const vNew = new Map<number, number>();
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<Map<number, number>>, 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,
};
}