From 5506eb194b605c828dfc7c31c2e2234761af74a5 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:55:20 -0800 Subject: [PATCH] feat: page history diff (#1891) * Show actual history changes * V2 - WIP * feat: page history diff * fix: exclude content from history listing --------- Co-authored-by: Jason Norwood-Young --- .../public/locales/en-US/translation.json | 5 + .../page-history/atoms/history-atoms.ts | 7 +- .../components/css/history-diff.module.css | 38 +++ .../components/css/history-mobile.module.css | 69 +++++ .../components/{ => css}/history.module.css | 0 .../components/history-editor.tsx | 196 +++++++++++- .../page-history/components/history-item.tsx | 40 ++- .../page-history/components/history-list.tsx | 186 +++++++----- .../components/history-modal-body.tsx | 104 ++++++- .../components/history-modal-mobile.tsx | 208 +++++++++++++ .../page-history/components/history-modal.tsx | 45 ++- .../page-history/components/history-view.tsx | 41 ++- .../src/features/page-history/hooks/index.ts | 3 + .../page-history/hooks/use-diff-navigation.ts | 58 ++++ .../page-history/hooks/use-history-reset.ts | 24 ++ .../hooks/use-history-restore.tsx | 78 +++++ .../queries/page-history-query.ts | 29 +- .../services/page-history-service.ts | 2 + .../listeners/history.listener.ts | 14 +- apps/server/src/core/page/page.controller.ts | 1 - .../page/services/page-history.service.ts | 4 +- .../database/repos/page/page-history.repo.ts | 38 ++- package.json | 3 + packages/editor-ext/package.json | 3 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/recreate-transform/copy.ts | 3 + .../src/lib/recreate-transform/getFromPath.ts | 17 ++ .../lib/recreate-transform/getReplaceStep.ts | 29 ++ .../src/lib/recreate-transform/index.ts | 4 + .../recreate-transform/recreateTransform.ts | 279 ++++++++++++++++++ .../src/lib/recreate-transform/removeMarks.ts | 8 + .../recreate-transform/simplifyTransform.ts | 30 ++ .../src/lib/recreate-transform/types.ts | 3 + pnpm-lock.yaml | 18 ++ 34 files changed, 1434 insertions(+), 154 deletions(-) create mode 100644 apps/client/src/features/page-history/components/css/history-diff.module.css create mode 100644 apps/client/src/features/page-history/components/css/history-mobile.module.css rename apps/client/src/features/page-history/components/{ => css}/history.module.css (100%) create mode 100644 apps/client/src/features/page-history/components/history-modal-mobile.tsx create mode 100644 apps/client/src/features/page-history/hooks/index.ts create mode 100644 apps/client/src/features/page-history/hooks/use-diff-navigation.ts create mode 100644 apps/client/src/features/page-history/hooks/use-history-reset.ts create mode 100644 apps/client/src/features/page-history/hooks/use-history-restore.tsx create mode 100644 packages/editor-ext/src/lib/recreate-transform/copy.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/getFromPath.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/getReplaceStep.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/index.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/removeMarks.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/simplifyTransform.ts create mode 100644 packages/editor-ext/src/lib/recreate-transform/types.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0578d2b..a10f6762 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -123,6 +123,11 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "Page history": "Page history", + "Version history for": "Version history for", + "document": "document", + "Select version": "Select version", + "Close": "Close", + "Highlight changes": "Highlight changes", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Pages": "Pages", "pages": "pages", 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..2acf163d 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,9 @@ import { atom } from "jotai"; export const historyAtoms = atom(false); -export const activeHistoryIdAtom = atom(''); +export const activeHistoryIdAtom = atom(""); +export const activeHistoryPrevIdAtom = atom(""); +export const highlightChangesAtom = atom(true); + +export type DiffCounts = { added: number; deleted: number; total: number }; +export const diffCountsAtom = atom(null); diff --git a/apps/client/src/features/page-history/components/css/history-diff.module.css b/apps/client/src/features/page-history/components/css/history-diff.module.css new file mode 100644 index 00000000..5626e050 --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history-diff.module.css @@ -0,0 +1,38 @@ +.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) { + background: light-dark(#e1f3f2, #01654a) !important; + color: light-dark(#007b69, #cafff7) !important; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +:global(.history-diff-deleted) { + text-decoration: line-through; + color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4)); + background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1)); + border-radius: rem(2px); + padding: 0 rem(2px); +} + +:global(.history-diff-node-added) { + outline: rem(2px) solid light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7)); + outline-offset: rem(2px); + border-radius: rem(4px); +} + +:global(.history-diff-node-deleted) { + opacity: 0.5; + outline: rem(2px) dashed light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6)); + outline-offset: rem(4px); + border-radius: rem(4px); +} diff --git a/apps/client/src/features/page-history/components/css/history-mobile.module.css b/apps/client/src/features/page-history/components/css/history-mobile.module.css new file mode 100644 index 00000000..2db6d10c --- /dev/null +++ b/apps/client/src/features/page-history/components/css/history-mobile.module.css @@ -0,0 +1,69 @@ +.container { + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + position: relative; + overflow: hidden; +} + +.selectorWrapper { + padding: var(--mantine-spacing-sm); + border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + flex-shrink: 0; +} + +.selector { + width: 100%; + text-align: left; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + cursor: pointer; + + &:hover { + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); + } +} + +.dropdown { + max-height: rem(300px); +} + +.option { + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + + &[data-combobox-selected] { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } + + &:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} + +.editorArea { + flex: 1; + min-height: 0; +} + +.editorContent { + padding: var(--mantine-spacing-md); + padding-bottom: rem(60px); +} + +.actionButtons { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + padding-bottom: rem(70px); + border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + flex-shrink: 0; +} + +.floatingBar { + position: fixed; + bottom: var(--mantine-spacing-md); + left: 50%; + transform: translateX(-50%); + z-index: 100; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + white-space: nowrap; +} diff --git a/apps/client/src/features/page-history/components/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css similarity index 100% rename from apps/client/src/features/page-history/components/history.module.css rename to apps/client/src/features/page-history/components/css/history.module.css 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 5fa8cf42..f1dbcae9 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,36 +1,204 @@ import "@/features/editor/styles/index.css"; -import React, { useEffect } from "react"; +import "./css/history-diff.module.css"; +import { useEffect } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Title } from "@mantine/core"; -import classes from "./history.module.css"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import historyClasses from "./css/history.module.css"; +import { recreateTransform } from "@docmost/editor-ext"; +import { DOMSerializer, Node } from "@tiptap/pm/model"; +import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; +import { useAtom } from "jotai"; +import { + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; export interface HistoryEditorProps { title: string; content: any; + previousContent?: any; } -export function HistoryEditor({ title, content }: HistoryEditorProps) { +export function HistoryEditor({ + title, + content, + previousContent, +}: HistoryEditorProps) { + const [highlightChanges] = useAtom(highlightChangesAtom); + const [, setDiffCounts] = useAtom(diffCountsAtom); + const editor = useEditor({ extensions: mainExtensions, editable: false, }); useEffect(() => { - if (editor && content) { + if (!editor || !content) return; + + let decorationSet = DecorationSet.empty; + let addedCount = 0; + let deletedCount = 0; + + if (previousContent) { + try { + const schema = editor.schema; + const oldContent = Node.fromJSON(schema, previousContent); + const newContent = Node.fromJSON(schema, content); + + const tr = recreateTransform(oldContent, newContent, { + complexSteps: false, + wordDiffs: true, + simplifyDiff: true, + }); + + const changeSet = ChangeSet.create(oldContent).addSteps( + tr.doc, + tr.mapping.maps, + [], + ); + const changes = simplifyChanges(changeSet.changes, newContent); + + editor.commands.setContent(content); + + const specialNodeTypes = new Set([ + "image", + "attachment", + "video", + "excalidraw", + "drawio", + "mermaid", + "mathBlock", + "mathInline", + "table", + "details", + "callout", + ]); + + 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; + newContent.nodesBetween(change.fromB, change.toB, (node, pos) => { + if (specialNodeTypes.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromB <= pos && change.toB >= nodeEnd) { + foundSpecialNode = { node, pos }; + return false; + } + } + }); + + if (foundSpecialNode) { + const nodeEnd = + foundSpecialNode.pos + foundSpecialNode.node.nodeSize; + 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; + oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => { + if (specialNodeTypes.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromA <= pos && change.toA >= nodeEnd) { + foundDeletedNode = { node, pos }; + return false; + } + } + }); + + if (foundDeletedNode) { + decorations.push( + 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); + return wrapper; + }), + ); + } else { + const deletedText = oldContent.textBetween( + change.fromA, + change.toA, + "", + ); + if (deletedText) { + decorations.push( + 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; + }), + ); + } + } + deletedCount += 1; + } + } + + decorationSet = DecorationSet.create(newContent, decorations); + } catch (e) { + console.error("History diff failed:", e); + editor.commands.setContent(content); + } + } else { editor.commands.setContent(content); } - }, [title, content, editor]); + + const total = addedCount + deletedCount; + // @ts-ignore + setDiffCounts({ added: addedCount, deleted: deletedCount, total }); + + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + decorations: () => + highlightChanges ? decorationSet : DecorationSet.empty, + }, + }); + }, [ + title, + content, + editor, + previousContent, + highlightChanges, + setDiffCounts, + ]); return ( - <> -
- {title} - - {editor && ( - - )} -
- +
+ {title} + {editor && ( + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index eb348bd6..e44614c4 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,20 +1,42 @@ import { Text, Group, UnstyledButton } from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; -import classes from "./history.module.css"; +import classes from "./css/history.module.css"; import clsx from "clsx"; +import { IPageHistory } from "@/features/page-history/types/page.types"; +import { memo, useCallback } from "react"; interface HistoryItemProps { - historyItem: any; - onSelect: (id: string) => void; + historyItem: IPageHistory; + index: number; + onSelect: (id: string, index: number) => void; + onHover?: (id: string, index: number) => void; + onHoverEnd?: () => void; isActive: boolean; } -function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { +const HistoryItem = memo(function HistoryItem({ + historyItem, + index, + onSelect, + onHover, + onHoverEnd, + isActive, +}: HistoryItemProps) { + const handleClick = useCallback(() => { + onSelect(historyItem.id, index); + }, [onSelect, historyItem.id, index]); + + const handleMouseEnter = useCallback(() => { + onHover?.(historyItem.id, index); + }, [onHover, historyItem.id, index]); + return ( onSelect(historyItem.id)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={onHoverEnd} className={clsx(classes.history, { [classes.active]: isActive })} > @@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { - {historyItem.lastUpdatedBy.name} + {historyItem.lastUpdatedBy?.name} @@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { ); -} +}); export default HistoryItem; 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 7b0d9ea2..0853b6d1 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -1,29 +1,27 @@ import { usePageHistoryListQuery, - usePageHistoryQuery, + prefetchPageHistory, } from "@/features/page-history/queries/page-history-query"; import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, + activeHistoryPrevIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core"; +import { useAtom, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; + Button, + ScrollArea, + Group, + Divider, + Loader, + Center, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; -import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; -import { useParams } from "react-router-dom"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type.ts"; +import { useHistoryRestore } from "@/features/page-history/hooks"; + +const PREFETCH_DELAY_MS = 150; interface Props { pageId: string; @@ -32,62 +30,89 @@ interface Props { function HistoryList({ pageId }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + const { - data: pageHistoryList, + data: pageHistoryData, isLoading, isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); + const loadMoreRef = useRef(null); + const prefetchTimeoutRef = useRef | null>(null); - const confirmModal = () => - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); + const { canRestore, confirmRestore } = useHistoryRestore(); - const handleRestore = useCallback(() => { - if (activeHistoryData) { - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); + const clearPrefetchTimeout = useCallback(() => { + if (prefetchTimeoutRef.current) { + clearTimeout(prefetchTimeoutRef.current); + prefetchTimeoutRef.current = null; } - }, [activeHistoryData]); + }, []); + + const handleHover = useCallback( + (historyId: string, index: number) => { + clearPrefetchTimeout(); + prefetchTimeoutRef.current = setTimeout(() => { + prefetchPageHistory(historyId); + const prevId = historyItems[index + 1]?.id; + if (prevId) { + prefetchPageHistory(prevId); + } + }, PREFETCH_DELAY_MS); + }, + [clearPrefetchTimeout, historyItems], + ); useEffect(() => { - if ( - pageHistoryList && - pageHistoryList.items.length > 0 && - !activeHistoryId - ) { - setActiveHistoryId(pageHistoryList.items[0].id); + return clearPrefetchTimeout; + }, [clearPrefetchTimeout]); + + const handleSelect = useCallback( + (id: string, index: number) => { + setActiveHistoryId(id); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); } - }, [pageHistoryList]); + }, [ + 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) { return <>; @@ -97,40 +122,45 @@ function HistoryList({ pageId }: Props) { return
{t("Error loading page history.")}
; } - if (!pageHistoryList || pageHistoryList.items.length === 0) { + if (historyItems.length === 0) { return <>{t("No page history saved yet.")}; } return (
- {pageHistoryList && - pageHistoryList.items.map((historyItem, index) => ( - - ))} + {historyItems.map((historyItem, index) => ( + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} - {spaceAbility.cannot( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) ? null : ( + {canRestore && ( <> - + 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..5673c82a 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,21 +1,45 @@ -import { ScrollArea } from "@mantine/core"; +import { + ActionIcon, + Group, + Paper, + ScrollArea, + Switch, + Text, +} 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 classes from "./css/history.module.css"; +import { useAtom, useAtomValue } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useEffect } from "react"; +import { useRef } from "react"; +import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + useDiffNavigation, + useHistoryReset, +} from "@/features/page-history/hooks"; interface Props { pageId: string; } export default function HistoryModalBody({ pageId }: Props) { - const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const { t } = useTranslation(); + const scrollViewportRef = useRef(null); - useEffect(() => { - setActiveHistoryId(""); - }, [pageId]); + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + + useHistoryReset(pageId); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); return (
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
- -
- {activeHistoryId && } -
-
+
+ +
+ {activeHistoryId && } +
+
+ + {activeHistoryId && activeHistoryPrevId && ( + + + setHighlightChanges(e.currentTarget.checked)} + styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} + /> + {highlightChanges && diffCounts && diffCounts.total > 0 && ( + + + {currentChangeIndex} of {diffCounts.total} + + + + + + + + + )} + + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-modal-mobile.tsx b/apps/client/src/features/page-history/components/history-modal-mobile.tsx new file mode 100644 index 00000000..1f2362a9 --- /dev/null +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -0,0 +1,208 @@ +import { + ActionIcon, + Box, + Button, + Group, + Paper, + ScrollArea, + Select, + Switch, + Text, +} from "@mantine/core"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, + historyAtoms, +} from "@/features/page-history/atoms/history-atoms"; +import HistoryView from "@/features/page-history/components/history-view"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; +import { formattedDate } from "@/lib/time"; +import { + useDiffNavigation, + useHistoryReset, + useHistoryRestore, +} from "@/features/page-history/hooks"; +import classes from "./css/history-mobile.module.css"; + +interface Props { + pageId: string; + pageTitle?: string; +} + +export default function HistoryModalMobile({ pageId, pageTitle }: Props) { + const { t } = useTranslation(); + + const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const scrollViewportRef = useRef(null); + const dropdownViewportRef = useRef(null); + + const { + data: pageHistoryData, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePageHistoryListQuery(pageId); + + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const selectData = useMemo( + () => + historyItems.map((item) => ({ + value: item.id, + label: formattedDate(new Date(item.createdAt)), + userName: item.lastUpdatedBy?.name, + })), + [historyItems], + ); + + useHistoryReset(pageId); + const { canRestore, confirmRestore } = useHistoryRestore(); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); + } + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); + + const handleDropdownScroll = useCallback(() => { + const viewport = dropdownViewportRef.current; + if (!viewport || !hasNextPage || isFetchingNextPage) return; + + const { scrollTop, scrollHeight, clientHeight } = viewport; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isNearBottom) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const handleSelectVersion = useCallback( + (value: string | null) => { + if (!value) return; + const index = historyItems.findIndex((item) => item.id === value); + if (index >= 0) { + setActiveHistoryId(value); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + } + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + if (isLoading) { + return null; + } + + return ( + + +