mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
lazy load history
This commit is contained in:
@@ -9,8 +9,16 @@ import {
|
|||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
|
import {
|
||||||
|
Button,
|
||||||
|
ScrollArea,
|
||||||
|
Group,
|
||||||
|
Divider,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
@@ -35,12 +43,22 @@ function HistoryList({ pageId }: Props) {
|
|||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||||
const {
|
const {
|
||||||
data: pageHistoryList,
|
data: pageHistoryData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
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 [mainEditor] = useAtom(pageEditorAtom);
|
||||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
@@ -82,15 +100,28 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, [activeHistoryData]);
|
}, [activeHistoryData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (historyItems.length > 0 && !activeHistoryId) {
|
||||||
pageHistoryList &&
|
setActiveHistoryId(historyItems[0].id);
|
||||||
pageHistoryList.items.length > 0 &&
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
!activeHistoryId
|
|
||||||
) {
|
|
||||||
setActiveHistoryId(pageHistoryList.items[0].id);
|
|
||||||
setActiveHistoryPrevId(pageHistoryList.items[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) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -100,27 +131,30 @@ function HistoryList({ pageId }: Props) {
|
|||||||
return <div>{t("Error loading page history.")}</div>;
|
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 <>{t("No page history saved yet.")}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{pageHistoryList &&
|
{historyItems.map((historyItem, index) => (
|
||||||
pageHistoryList.items.map((historyItem, index) => (
|
<HistoryItem
|
||||||
<HistoryItem
|
key={historyItem.id}
|
||||||
key={historyItem.id}
|
historyItem={historyItem}
|
||||||
historyItem={historyItem}
|
onSelect={(id) => {
|
||||||
onSelect={(id) => {
|
setActiveHistoryId(id);
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
setActiveHistoryPrevId(
|
}}
|
||||||
pageHistoryList.items[index + 1]?.id ?? "",
|
isActive={historyItem.id === activeHistoryId}
|
||||||
);
|
/>
|
||||||
}}
|
))}
|
||||||
isActive={historyItem.id === activeHistoryId}
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
/>
|
{isFetchingNextPage && (
|
||||||
))}
|
<Center py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{spaceAbility.cannot(
|
{spaceAbility.cannot(
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import {
|
||||||
|
InfiniteData,
|
||||||
|
useInfiniteQuery,
|
||||||
|
UseInfiniteQueryResult,
|
||||||
|
useQuery,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getPageHistoryById,
|
getPageHistoryById,
|
||||||
getPageHistoryList,
|
getPageHistoryList,
|
||||||
@@ -8,12 +14,14 @@ import { IPagination } from "@/lib/types.ts";
|
|||||||
|
|
||||||
export function usePageHistoryListQuery(
|
export function usePageHistoryListQuery(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<IPagination<IPageHistory>, Error> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||||
return useQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["page-history-list", pageId],
|
queryKey: ["page-history-list", pageId],
|
||||||
queryFn: () => getPageHistoryList(pageId),
|
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
|
initialPageParam: undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
|
|||||||
|
|
||||||
export async function getPageHistoryList(
|
export async function getPageHistoryList(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
cursor?: string,
|
||||||
): Promise<IPagination<IPageHistory>> {
|
): Promise<IPagination<IPageHistory>> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
|
cursor,
|
||||||
});
|
});
|
||||||
return req.data;
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user