From 669ff0435f8814071ff5c89a17e699ba7cd6bdaa Mon Sep 17 00:00:00 2001 From: Jason Norwood-Young Date: Thu, 8 Jan 2026 12:21:48 +0100 Subject: [PATCH] Show actual history changes --- .../page-history/atoms/history-atoms.ts | 3 +- .../components/history-diff.module.css | 59 ++++++ .../components/history-editor.tsx | 99 +++++++++- .../page-history/components/history-list.tsx | 14 +- .../components/history-modal-body.tsx | 18 +- .../page-history/components/history-view.tsx | 24 ++- .../components/history.module.css | 2 +- .../page-history/utils/history-diff.ts | 183 ++++++++++++++++++ 8 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 apps/client/src/features/page-history/components/history-diff.module.css create mode 100644 apps/client/src/features/page-history/utils/history-diff.ts diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts index 023aaa36..a20d40a3 100644 --- a/apps/client/src/features/page-history/atoms/history-atoms.ts +++ b/apps/client/src/features/page-history/atoms/history-atoms.ts @@ -1,4 +1,5 @@ import { atom } from "jotai"; export const historyAtoms = atom(false); -export const activeHistoryIdAtom = atom(''); +export const activeHistoryIdAtom = atom(""); +export const activeHistoryPrevIdAtom = atom(""); diff --git a/apps/client/src/features/page-history/components/history-diff.module.css b/apps/client/src/features/page-history/components/history-diff.module.css new file mode 100644 index 00000000..389681bf --- /dev/null +++ b/apps/client/src/features/page-history/components/history-diff.module.css @@ -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; +} + + diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index 4b5839af..0847b0af 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -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 ( <> -
+
{title} + {previousContent && ( + <> + +
+ + Changes + + +{diffCounts.added} added + + + -{diffCounts.deleted} deleted + + + (added = green, deleted = red/strikethrough) + + +
+ + + )} + {editor && }
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 af178eac..46452e64 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -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) => ( { + setActiveHistoryId(id); + setActiveHistoryPrevId( + pageHistoryList.items[index + 1]?.id ?? "", + ); + }} isActive={historyItem.id === activeHistoryId} /> ))} diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 199601fc..c106db0a 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -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) {
- +
- {activeHistoryId && } + {activeHistoryId && ( + + )}
diff --git a/apps/client/src/features/page-history/components/history-view.tsx b/apps/client/src/features/page-history/components/history-view.tsx index 10693428..1579d949 100644 --- a/apps/client/src/features/page-history/components/history-view.tsx +++ b/apps/client/src/features/page-history/components/history-view.tsx @@ -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
{t("Error fetching page data.")}
; } return ( data && (
- +
) ); diff --git a/apps/client/src/features/page-history/components/history.module.css b/apps/client/src/features/page-history/components/history.module.css index 5d23cb0c..40c7b0e7 100644 --- a/apps/client/src/features/page-history/components/history.module.css +++ b/apps/client/src/features/page-history/components/history.module.css @@ -33,5 +33,5 @@ .sidebarRightSection { flex: 1; - padding: rem(16px) rem(40px); + padding: rem(16px) rem(40px) rem(96px); } diff --git a/apps/client/src/features/page-history/utils/history-diff.ts b/apps/client/src/features/page-history/utils/history-diff.ts new file mode 100644 index 00000000..d37f9fb4 --- /dev/null +++ b/apps/client/src/features/page-history/utils/history-diff.ts @@ -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; + 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, + }; +} + +