Show actual history changes

This commit is contained in:
Jason Norwood-Young
2026-01-08 12:21:48 +01:00
committed by Philipinho
parent 5cd0ba6902
commit 669ff0435f
8 changed files with 380 additions and 22 deletions
@@ -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,
};
}