mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Show actual history changes
This commit is contained in:
committed by
Philipinho
parent
5cd0ba6902
commit
669ff0435f
@@ -1,4 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const historyAtoms = atom<boolean>(false);
|
||||
export const activeHistoryIdAtom = atom<string>('');
|
||||
export const activeHistoryIdAtom = atom<string>("");
|
||||
export const activeHistoryPrevIdAtom = atom<string>("");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.diffSummary {
|
||||
border: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: rem(10px);
|
||||
padding: rem(12px);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
:global(.history-diff-added) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:global(.history-diff-added)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
left: rem(-12px);
|
||||
right: 0;
|
||||
border-left: rem(4px) solid
|
||||
light-dark(var(--mantine-color-green-6), var(--mantine-color-green-4));
|
||||
background: light-dark(var(--mantine-color-green-0), rgba(0, 255, 0, 0.06));
|
||||
border-radius: rem(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.history-diff-deleted) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
:global(.history-diff-deleted)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
left: rem(-12px);
|
||||
right: 0;
|
||||
border-left: rem(4px) solid
|
||||
light-dark(var(--mantine-color-red-6), var(--mantine-color-red-4));
|
||||
border-top: rem(1px) dashed
|
||||
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
|
||||
border-right: rem(1px) dashed
|
||||
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
|
||||
border-bottom: rem(1px) dashed
|
||||
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
|
||||
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.08));
|
||||
border-radius: rem(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,112 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { Title } from '@mantine/core';
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Badge, Divider, Group, Text, Title } from "@mantine/core";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { computeHistoryBlockDiff } from "@/features/page-history/utils/history-diff";
|
||||
import classes from "./history-diff.module.css";
|
||||
|
||||
export interface HistoryEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
previousContent?: any;
|
||||
}
|
||||
|
||||
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||
export function HistoryEditor({
|
||||
title,
|
||||
content,
|
||||
previousContent,
|
||||
}: HistoryEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
});
|
||||
|
||||
const [diffCounts, setDiffCounts] = useState<{ added: number; deleted: number }>({
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
editor.commands.setContent(content);
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
if (previousContent) {
|
||||
try {
|
||||
const currentDoc = editor.schema.nodeFromJSON(content);
|
||||
const prevDoc = editor.schema.nodeFromJSON(previousContent);
|
||||
const {
|
||||
diffDoc,
|
||||
addedNodeRanges,
|
||||
deletedNodeRanges,
|
||||
addedCount: aCount,
|
||||
deletedCount: dCount,
|
||||
} = computeHistoryBlockDiff(currentDoc, prevDoc);
|
||||
|
||||
editor.commands.setContent(diffDoc.toJSON());
|
||||
|
||||
addedCount = aCount;
|
||||
deletedCount = dCount;
|
||||
|
||||
const decos = addedNodeRanges.map((r) =>
|
||||
Decoration.node(r.from, r.to, { class: "history-diff-added" }),
|
||||
);
|
||||
const deletedDecos = deletedNodeRanges.map((r) =>
|
||||
Decoration.node(r.from, r.to, { class: "history-diff-deleted" }),
|
||||
);
|
||||
|
||||
decorationSet = DecorationSet.create(diffDoc, [...decos, ...deletedDecos]);
|
||||
} catch {
|
||||
decorationSet = DecorationSet.empty;
|
||||
addedCount = 0;
|
||||
deletedCount = 0;
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
} else {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
|
||||
setDiffCounts({ added: addedCount, deleted: deletedCount });
|
||||
|
||||
const existingEditorProps = editor.options.editorProps ?? {};
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
...existingEditorProps,
|
||||
decorations: () => decorationSet,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [title, content, editor]);
|
||||
}, [title, content, editor, previousContent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className={classes.container}>
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{previousContent && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<div className={classes.diffSummary}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text fw={600}>Changes</Text>
|
||||
<Badge variant="light" color="green">
|
||||
+{diffCounts.added} added
|
||||
</Badge>
|
||||
<Badge variant="light" color="red">
|
||||
-{diffCounts.deleted} deleted
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
(added = green, deleted = red/strikethrough)
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
<Divider my="md" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{editor && <EditorContent editor={editor} />}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import HistoryItem from "@/features/page-history/components/history-item";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
function HistoryList({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||
const {
|
||||
data: pageHistoryList,
|
||||
isLoading,
|
||||
@@ -86,8 +88,9 @@ function HistoryList({ pageId }: Props) {
|
||||
!activeHistoryId
|
||||
) {
|
||||
setActiveHistoryId(pageHistoryList.items[0].id);
|
||||
setActiveHistoryPrevId(pageHistoryList.items[1]?.id ?? "");
|
||||
}
|
||||
}, [pageHistoryList]);
|
||||
}, [pageHistoryList, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
@@ -107,9 +110,14 @@ function HistoryList({ pageId }: Props) {
|
||||
{pageHistoryList &&
|
||||
pageHistoryList.items.map((historyItem, index) => (
|
||||
<HistoryItem
|
||||
key={index}
|
||||
key={historyItem.id}
|
||||
historyItem={historyItem}
|
||||
onSelect={setActiveHistoryId}
|
||||
onSelect={(id) => {
|
||||
setActiveHistoryId(id);
|
||||
setActiveHistoryPrevId(
|
||||
pageHistoryList.items[index + 1]?.id ?? "",
|
||||
);
|
||||
}}
|
||||
isActive={historyItem.id === activeHistoryId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { ScrollArea } from "@mantine/core";
|
||||
import HistoryList from "@/features/page-history/components/history-list";
|
||||
import classes from "./history.module.css";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -12,9 +15,13 @@ interface Props {
|
||||
|
||||
export default function HistoryModalBody({ pageId }: Props) {
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const [activeHistoryPrevId, setActiveHistoryPrevId] = useAtom(
|
||||
activeHistoryPrevIdAtom,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHistoryId("");
|
||||
setActiveHistoryPrevId("");
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
@@ -25,9 +32,14 @@ export default function HistoryModalBody({ pageId }: Props) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<ScrollArea h={650} w="100%" scrollbarSize={5}>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
||||
{activeHistoryId && (
|
||||
<HistoryView
|
||||
historyId={activeHistoryId}
|
||||
prevHistoryId={activeHistoryPrevId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -4,24 +4,38 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HistoryProps {
|
||||
historyId: string;
|
||||
prevHistoryId?: string;
|
||||
}
|
||||
|
||||
function HistoryView({ historyId }: HistoryProps) {
|
||||
function HistoryView({ historyId, prevHistoryId }: HistoryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingCurrent,
|
||||
isError: isErrorCurrent,
|
||||
} = usePageHistoryQuery(historyId);
|
||||
const {
|
||||
data: prevData,
|
||||
isLoading: isLoadingPrev,
|
||||
isError: isErrorPrev,
|
||||
} = usePageHistoryQuery(prevHistoryId ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoadingCurrent || isLoadingPrev) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
if (isErrorCurrent || !data) {
|
||||
return <div>{t("Error fetching page data.")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
data && (
|
||||
<div>
|
||||
<HistoryEditor content={data.content} title={data.title} />
|
||||
<HistoryEditor
|
||||
content={data.content}
|
||||
title={data.title}
|
||||
previousContent={!isErrorPrev ? prevData?.content : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
padding: rem(16px) rem(40px) rem(96px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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