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 8905e43c..8f18bf22 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,5 +1,6 @@ import "@/features/editor/styles/index.css"; -import { useEffect } from "react"; +import "./history-diff.module.css"; +import { useEffect, useImperativeHandle, forwardRef, useRef } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Title } from "@mantine/core"; @@ -9,7 +10,11 @@ import { recreateTransform } from "@docmost/editor-ext"; import { DOMSerializer, Node } from "@tiptap/pm/model"; import { ChangeSet, simplifyChanges } from "prosemirror-changeset"; -export type DiffCounts = { added: number; deleted: number }; +export type DiffCounts = { added: number; deleted: number; total: number }; + +export type HistoryEditorHandle = { + scrollToChange: (index: number) => void; +}; export interface HistoryEditorProps { title: string; @@ -19,19 +24,31 @@ export interface HistoryEditorProps { onDiffCalculated?: (counts: DiffCounts) => void; } -export function HistoryEditor({ - title, - content, - previousContent, - highlightChanges = true, - onDiffCalculated, -}: HistoryEditorProps) { - const editor = useEditor({ - extensions: mainExtensions, - editable: false, - }); +export const HistoryEditor = forwardRef( + function HistoryEditor( + { title, content, previousContent, highlightChanges = true, onDiffCalculated }, + ref, + ) { + const editor = useEditor({ + extensions: mainExtensions, + editable: false, + }); + const containerRef = useRef(null); + const changeIndexRef = useRef([]); - useEffect(() => { + useImperativeHandle(ref, () => ({ + scrollToChange: (index: number) => { + if (!containerRef.current || index < 1) return; + const element = containerRef.current.querySelector( + `[data-diff-index="${index}"]`, + ); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, + })); + + useEffect(() => { if (!editor || !content) return; let decorationSet = DecorationSet.empty; @@ -74,13 +91,16 @@ export function HistoryEditor({ ]); const decorations: Decoration[] = []; + let changeIndex = 0; + for (const change of changes) { if (change.toB > change.fromB) { + changeIndex++; + const currentIndex = changeIndex; let foundSpecialNode: { node: Node; pos: number } | null = null; docNew.nodesBetween(change.fromB, change.toB, (node, pos) => { if (specialNodeTypes.has(node.type.name)) { const nodeEnd = pos + node.nodeSize; - // Only match if change spans the entire node (not just content inside) if (change.fromB <= pos && change.toB >= nodeEnd) { foundSpecialNode = { node, pos }; return false; @@ -94,23 +114,26 @@ export function HistoryEditor({ decorations.push( Decoration.node(foundSpecialNode.pos, nodeEnd, { class: "history-diff-node-added", + "data-diff-index": String(currentIndex), }), ); } else { decorations.push( Decoration.inline(change.fromB, change.toB, { class: "history-diff-added", + "data-diff-index": String(currentIndex), }), ); } addedCount += 1; } if (change.toA > change.fromA) { + changeIndex++; + const currentIndex = changeIndex; let foundDeletedNode: { node: Node; pos: number } | null = null; docOld.nodesBetween(change.fromA, change.toA, (node, pos) => { if (specialNodeTypes.has(node.type.name)) { const nodeEnd = pos + node.nodeSize; - // Only match if change spans the entire node (not just content inside) if (change.fromA <= pos && change.toA >= nodeEnd) { foundDeletedNode = { node, pos }; return false; @@ -123,6 +146,7 @@ export function HistoryEditor({ Decoration.widget(change.fromB, () => { const wrapper = document.createElement("div"); wrapper.className = "history-diff-node-deleted"; + wrapper.setAttribute("data-diff-index", String(currentIndex)); const serializer = DOMSerializer.fromSchema(schema); const dom = serializer.serializeNode(foundDeletedNode!.node); wrapper.appendChild(dom); @@ -140,6 +164,7 @@ export function HistoryEditor({ Decoration.widget(change.fromB, () => { const span = document.createElement("span"); span.className = "history-diff-deleted"; + span.setAttribute("data-diff-index", String(currentIndex)); span.textContent = deletedText; return span; }), @@ -150,6 +175,11 @@ export function HistoryEditor({ } } + changeIndexRef.current = Array.from( + { length: changeIndex }, + (_, i) => i + 1, + ); + decorationSet = DecorationSet.create(docNew, decorations); } catch (e) { console.error("History diff failed:", e); @@ -159,7 +189,8 @@ export function HistoryEditor({ editor.commands.setContent(content); } - onDiffCalculated?.({ added: addedCount, deleted: deletedCount }); + const total = addedCount + deletedCount; + onDiffCalculated?.({ added: addedCount, deleted: deletedCount, total }); editor.setOptions({ editorProps: { @@ -170,15 +201,16 @@ export function HistoryEditor({ }); }, [title, content, editor, previousContent, highlightChanges]); - return ( -
- {title} - {editor && ( - - )} -
- ); -} + return ( +
+ {title} + {editor && ( + + )} +
+ ); + }, +); 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 1763fc47..2c75853e 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 @@ -1,5 +1,16 @@ -import { Badge, Group, Paper, ScrollArea, Switch } from "@mantine/core"; -import { DiffCounts } from "@/features/page-history/components/history-editor"; +import { + ActionIcon, + Badge, + Group, + Paper, + ScrollArea, + Switch, + Text, +} from "@mantine/core"; +import { + DiffCounts, + HistoryEditorHandle, +} from "@/features/page-history/components/history-editor"; import HistoryList from "@/features/page-history/components/history-list"; import classes from "./history.module.css"; import { useAtom } from "jotai"; @@ -8,7 +19,8 @@ import { activeHistoryPrevIdAtom, } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; interface Props { pageId: string; @@ -21,12 +33,34 @@ export default function HistoryModalBody({ pageId }: Props) { ); const [highlightChanges, setHighlightChanges] = useState(true); const [diffCounts, setDiffCounts] = useState(null); + const [currentChangeIndex, setCurrentChangeIndex] = useState(0); + const historyEditorRef = useRef(null); useEffect(() => { setActiveHistoryId(""); setActiveHistoryPrevId(""); }, [pageId]); + useEffect(() => { + setCurrentChangeIndex(0); + }, [activeHistoryId]); + + const handlePrevChange = () => { + if (!diffCounts || diffCounts.total === 0) return; + const newIndex = + currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1; + setCurrentChangeIndex(newIndex); + historyEditorRef.current?.scrollToChange(newIndex); + }; + + const handleNextChange = () => { + if (!diffCounts || diffCounts.total === 0) return; + const newIndex = + currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1; + setCurrentChangeIndex(newIndex); + historyEditorRef.current?.scrollToChange(newIndex); + }; + return (