diff --git a/apps/client/src/features/page-history/components/changeset.md b/apps/client/src/features/page-history/components/changeset.md deleted file mode 100644 index 0d9ea39d..00000000 --- a/apps/client/src/features/page-history/components/changeset.md +++ /dev/null @@ -1,145 +0,0 @@ -# prosemirror-changeset - -This is a helper module that can turn a sequence of document changes -into a set of insertions and deletions, for example to display them in -a change-tracking interface. Such a set can be built up incrementally, -in order to do such change tracking in a halfway performant way during -live editing. - -This code is licensed under an [MIT -licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE). - -## Programming interface - -Insertions and deletions are represented as ‘spans’—ranges in the -document. The deleted spans refer to the original document, whereas -the inserted ones point into the current document. - -It is possible to associate arbitrary data values with such spans, for -example to track the user that made the change, the timestamp at which -it was made, or the step data necessary to invert it again. - -### class Change`` - -A replaced range with metadata associated with it. - -* **`fromA`**`: number`\ - The start of the range deleted/replaced in the old document. - -* **`toA`**`: number`\ - The end of the range in the old document. - -* **`fromB`**`: number`\ - The start of the range inserted in the new document. - -* **`toB`**`: number`\ - The end of the range in the new document. - -* **`deleted`**`: readonly Span[]`\ - Data associated with the deleted content. The length of these - spans adds up to `this.toA - this.fromA`. - -* **`inserted`**`: readonly Span[]`\ - Data associated with the inserted content. Length adds up to - `this.toB - this.fromB`. - -* `static `**`merge`**`(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\ - This merges two changesets (the end document of x should be the - start document of y) into a single one spanning the start of x to - the end of y. - - -### class Span`` - -Stores metadata for a part of a change. - -* **`length`**`: number`\ - The length of this span. - -* **`data`**`: Data`\ - The data associated with this span. - - -### class ChangeSet`` - -A change set tracks the changes to a document from a given point -in the past. It condenses a number of step maps down to a flat -sequence of replacements, and simplifies replacments that -partially undo themselves by comparing their content. - -* **`changes`**`: readonly Change[]`\ - Replaced regions. - -* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\ - Computes a new changeset by adding the given step maps and - metadata (either as an array, per-map, or as a single value to be - associated with all maps) to the current set. Will not mutate the - old set. - - Note that due to simplification that happens after each add, - incrementally adding steps might create a different final set - than adding all those changes at once, since different document - tokens might be matched during simplification depending on the - boundaries of the current changed ranges. - -* **`startDoc`**`: Node`\ - The starting document of the change set. - -* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\ - Map the span's data values in the given set through a function - and construct a new set with the resulting data. - -* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\ - Compare two changesets and return the range in which they are - changed, if any. If the document changed between the maps, pass - the maps for the steps that changed it as second argument, and - make sure the method is called on the old set and passed the new - set. The returned positions will be in new document coordinates. - -* `static `**`create`**`(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\ - Create a changeset with the given base object and configuration. - - The `combine` function is used to compare and combine metadata—it - should return null when metadata isn't compatible, and a combined - version for a merged range when it is. - - When given, a token encoder determines how document tokens are - serialized and compared when diffing the content produced by - changes. The default is to just compare nodes by name and text - by character, ignoring marks and attributes. - - -* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\ - Simplifies a set of changes for presentation. This makes the - assumption that having both insertions and deletions within a word - is confusing, and, when such changes occur without a word boundary - between them, they should be expanded to cover the entire set of - words (in the new document) they touch. An exception is made for - single-character replacements. - - -### interface TokenEncoder`` - -A token encoder can be passed when creating a `ChangeSet` in order -to influence the way the library runs its diffing algorithm. The -encoder determines how document tokens (such as nodes and -characters) are encoded and compared. - -Note that both the encoding and the comparison may run a lot, and -doing non-trivial work in these functions could impact -performance. - -* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\ - Encode a given character, with the given marks applied. - -* **`encodeNodeStart`**`(node: Node) → T`\ - Encode the start of a node or, if this is a leaf node, the - entire node. - -* **`encodeNodeEnd`**`(node: Node) → T`\ - Encode the end token for the given node. It is valid to encode - every end token in the same way. - -* **`compareTokens`**`(a: T, b: T) → boolean`\ - Compare the given tokens. Should return true when they count as - equal. diff --git a/apps/client/src/features/page-history/components/history-diff.module.css b/apps/client/src/features/page-history/components/css/history-diff.module.css similarity index 100% rename from apps/client/src/features/page-history/components/history-diff.module.css rename to apps/client/src/features/page-history/components/css/history-diff.module.css diff --git a/apps/client/src/features/page-history/components/history-mobile.module.css b/apps/client/src/features/page-history/components/css/history-mobile.module.css similarity index 100% rename from apps/client/src/features/page-history/components/history-mobile.module.css rename to apps/client/src/features/page-history/components/css/history-mobile.module.css 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 72e78d53..b018ddf6 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,11 +1,11 @@ import "@/features/editor/styles/index.css"; -import "./history-diff.module.css"; +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 { Decoration, DecorationSet } from "@tiptap/pm/view"; -import historyClasses from "./history.module.css"; +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"; 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 6d440569..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,7 +1,7 @@ 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"; 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 c3c6ed2a..699508a5 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -1,6 +1,5 @@ import { usePageHistoryListQuery, - usePageHistoryQuery, prefetchPageHistory, } from "@/features/page-history/queries/page-history-query"; import HistoryItem from "@/features/page-history/components/history-item"; @@ -9,31 +8,18 @@ import { activeHistoryPrevIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Button, ScrollArea, Group, Divider, - Text, Loader, Center, } from "@mantine/core"; -import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; 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; @@ -44,7 +30,9 @@ interface Props { function HistoryList({ pageId }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); - const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + const { data: pageHistoryData, isLoading, @@ -53,7 +41,6 @@ function HistoryList({ pageId }: Props) { hasNextPage, isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); const historyItems = useMemo( () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], @@ -63,45 +50,7 @@ function HistoryList({ pageId }: Props) { const loadMoreRef = useRef(null); const prefetchTimeoutRef = useRef | null>(null); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); - - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); - - 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 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") }); - } - }, [activeHistoryData]); + const { canRestore, confirmRestore } = useHistoryRestore(); const clearPrefetchTimeout = useCallback(() => { if (prefetchTimeoutRef.current) { @@ -141,7 +90,12 @@ function HistoryList({ pageId }: Props) { setActiveHistoryId(historyItems[0].id); setActiveHistoryPrevId(historyItems[1]?.id ?? ""); } - }, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]); + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); useEffect(() => { const sentinel = loadMoreRef.current; @@ -194,14 +148,11 @@ function HistoryList({ pageId }: Props) { )} - {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 a2c03868..bf9c2302 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 @@ -8,8 +8,8 @@ import { Text, } from "@mantine/core"; import HistoryList from "@/features/page-history/components/history-list"; -import classes from "./history.module.css"; -import { useAtom } from "jotai"; +import classes from "./css/history.module.css"; +import { useAtom, useAtomValue } from "jotai"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, @@ -17,9 +17,13 @@ import { highlightChangesAtom, } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useEffect, useRef, useState } 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; @@ -27,60 +31,16 @@ interface Props { export default function HistoryModalBody({ pageId }: Props) { const { t } = useTranslation(); - const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); - const [activeHistoryPrevId, setActiveHistoryPrevId] = useAtom( - activeHistoryPrevIdAtom, - ); - const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); - const [diffCounts, setDiffCounts] = useAtom(diffCountsAtom); - - const [currentChangeIndex, setCurrentChangeIndex] = useState(0); const scrollViewportRef = useRef(null); - useEffect(() => { - setActiveHistoryId(""); - setActiveHistoryPrevId(""); - // @ts-ignore - setDiffCounts(null); - }, [pageId]); + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); - useEffect(() => { - if (diffCounts && diffCounts.total > 0) { - setCurrentChangeIndex(1); - requestAnimationFrame(() => scrollToChangeIndex(1)); - } else { - setCurrentChangeIndex(0); - } - }, [diffCounts]); - - const scrollToChangeIndex = (index: number) => { - const viewport = scrollViewportRef.current; - if (!viewport || index < 1) return; - const element = viewport.querySelector(`[data-diff-index="${index}"]`); - if (element instanceof HTMLElement) { - const elementTop = element.offsetTop; - const viewportHeight = viewport.clientHeight; - const scrollTarget = - elementTop - viewportHeight / 2 + element.offsetHeight / 2; - viewport.scrollTo({ top: scrollTarget, behavior: "smooth" }); - } - }; - - const handlePrevChange = () => { - if (!diffCounts || diffCounts.total === 0) return; - const newIndex = - currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1; - setCurrentChangeIndex(newIndex); - scrollToChangeIndex(newIndex); - }; - - const handleNextChange = () => { - if (!diffCounts || diffCounts.total === 0) return; - const newIndex = - currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1; - setCurrentChangeIndex(newIndex); - scrollToChangeIndex(newIndex); - }; + useHistoryReset(pageId); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); return (
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 index 16b6ef89..13f743bc 100644 --- a/apps/client/src/features/page-history/components/history-modal-mobile.tsx +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -10,7 +10,7 @@ import { Switch, Text, } from "@mantine/core"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, @@ -19,28 +19,17 @@ import { historyAtoms, } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import { - usePageHistoryListQuery, - usePageHistoryQuery, -} from "@/features/page-history/queries/page-history-query"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; import { formattedDate } from "@/lib/time"; import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability"; -import { useSpaceQuery } from "@/features/space/queries/space-query"; -import { useParams } from "react-router-dom"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type"; -import classes from "./history-mobile.module.css"; + useDiffNavigation, + useHistoryReset, + useHistoryRestore, +} from "@/features/page-history/hooks"; +import classes from "./css/history-mobile.module.css"; interface Props { pageId: string; @@ -51,12 +40,11 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); - const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); - const [diffCounts, setDiffCounts] = useAtom(diffCountsAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); + const diffCounts = useAtomValue(diffCountsAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); - const [currentChangeIndex, setCurrentChangeIndex] = useState(0); const scrollViewportRef = useRef(null); const dropdownViewportRef = useRef(null); @@ -67,7 +55,6 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { hasNextPage, isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); const historyItems = useMemo( () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], @@ -84,41 +71,22 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { [historyItems], ); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); - - const canRestore = spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ); - - useEffect(() => { - setActiveHistoryId(""); - setActiveHistoryPrevId(""); - // @ts-ignore - setDiffCounts(null); - }, [pageId]); + 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]); - - useEffect(() => { - if (diffCounts && diffCounts.total > 0) { - setCurrentChangeIndex(1); - requestAnimationFrame(() => scrollToChangeIndex(1)); - } else { - setCurrentChangeIndex(0); - } - }, [diffCounts]); + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); const handleDropdownScroll = useCallback(() => { const viewport = dropdownViewportRef.current; @@ -132,35 +100,6 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { } }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - const scrollToChangeIndex = (index: number) => { - const viewport = scrollViewportRef.current; - if (!viewport || index < 1) return; - const element = viewport.querySelector(`[data-diff-index="${index}"]`); - if (element instanceof HTMLElement) { - const elementTop = element.offsetTop; - const viewportHeight = viewport.clientHeight; - const scrollTarget = - elementTop - viewportHeight / 2 + element.offsetHeight / 2; - viewport.scrollTo({ top: scrollTarget, behavior: "smooth" }); - } - }; - - const handlePrevChange = () => { - if (!diffCounts || diffCounts.total === 0) return; - const newIndex = - currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1; - setCurrentChangeIndex(newIndex); - scrollToChangeIndex(newIndex); - }; - - const handleNextChange = () => { - if (!diffCounts || diffCounts.total === 0) return; - const newIndex = - currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1; - setCurrentChangeIndex(newIndex); - scrollToChangeIndex(newIndex); - }; - const handleSelectVersion = useCallback( (value: string | null) => { if (!value) return; @@ -170,40 +109,9 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); } }, - [historyItems], + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], ); - const confirmRestore = () => - 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 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") }); - } - }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); - if (isLoading) { return null; } diff --git a/apps/client/src/features/page-history/hooks/index.ts b/apps/client/src/features/page-history/hooks/index.ts new file mode 100644 index 00000000..b69ae325 --- /dev/null +++ b/apps/client/src/features/page-history/hooks/index.ts @@ -0,0 +1,3 @@ +export { useDiffNavigation } from "./use-diff-navigation"; +export { useHistoryRestore } from "./use-history-restore"; +export { useHistoryReset } from "./use-history-reset"; diff --git a/apps/client/src/features/page-history/hooks/use-diff-navigation.ts b/apps/client/src/features/page-history/hooks/use-diff-navigation.ts new file mode 100644 index 00000000..3a01977a --- /dev/null +++ b/apps/client/src/features/page-history/hooks/use-diff-navigation.ts @@ -0,0 +1,58 @@ +import { useAtomValue } from "jotai"; +import { RefObject, useCallback, useEffect, useState } from "react"; +import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms"; + +/** + * Manages navigation between diff changes in the history view. + * Provides prev/next handlers and auto-scrolls to the current change. + */ +export function useDiffNavigation( + scrollViewportRef: RefObject, +) { + const diffCounts = useAtomValue(diffCountsAtom); + const [currentChangeIndex, setCurrentChangeIndex] = useState(0); + + const scrollToChangeIndex = useCallback( + (index: number) => { + const viewport = scrollViewportRef.current; + if (!viewport || index < 1) return; + + const element = viewport.querySelector(`[data-diff-index="${index}"]`); + if (element instanceof HTMLElement) { + const elementTop = element.offsetTop; + const viewportHeight = viewport.clientHeight; + const scrollTarget = + elementTop - viewportHeight / 2 + element.offsetHeight / 2; + viewport.scrollTo({ top: scrollTarget, behavior: "smooth" }); + } + }, + [scrollViewportRef], + ); + + useEffect(() => { + if (diffCounts && diffCounts.total > 0) { + setCurrentChangeIndex(1); + requestAnimationFrame(() => scrollToChangeIndex(1)); + } else { + setCurrentChangeIndex(0); + } + }, [diffCounts, scrollToChangeIndex]); + + const handlePrevChange = useCallback(() => { + if (!diffCounts || diffCounts.total === 0) return; + const newIndex = + currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1; + setCurrentChangeIndex(newIndex); + scrollToChangeIndex(newIndex); + }, [diffCounts, currentChangeIndex, scrollToChangeIndex]); + + const handleNextChange = useCallback(() => { + if (!diffCounts || diffCounts.total === 0) return; + const newIndex = + currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1; + setCurrentChangeIndex(newIndex); + scrollToChangeIndex(newIndex); + }, [diffCounts, currentChangeIndex, scrollToChangeIndex]); + + return { currentChangeIndex, handlePrevChange, handleNextChange }; +} diff --git a/apps/client/src/features/page-history/hooks/use-history-reset.ts b/apps/client/src/features/page-history/hooks/use-history-reset.ts new file mode 100644 index 00000000..c9ff503c --- /dev/null +++ b/apps/client/src/features/page-history/hooks/use-history-reset.ts @@ -0,0 +1,24 @@ +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, +} from "@/features/page-history/atoms/history-atoms"; + +/** + * Resets history state when pageId changes. + * Clears active selection and diff counts. + */ +export function useHistoryReset(pageId: string) { + const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); + const [, setDiffCounts] = useAtom(diffCountsAtom); + + useEffect(() => { + setActiveHistoryId(""); + setActiveHistoryPrevId(""); + // @ts-ignore - null is valid to clear the counts + setDiffCounts(null); + }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]); +} diff --git a/apps/client/src/features/page-history/hooks/use-history-restore.tsx b/apps/client/src/features/page-history/hooks/use-history-restore.tsx new file mode 100644 index 00000000..808ed9db --- /dev/null +++ b/apps/client/src/features/page-history/hooks/use-history-restore.tsx @@ -0,0 +1,82 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { useParams } from "react-router-dom"; +import { + activeHistoryIdAtom, + historyAtoms, +} from "@/features/page-history/atoms/history-atoms"; +import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; +import { + pageEditorAtom, + titleEditorAtom, +} from "@/features/editor/atoms/editor-atoms"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability"; +import { useSpaceQuery } from "@/features/space/queries/space-query"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type"; + +/** + * Manages history restore functionality including permission checking, + * confirmation modal, and the actual restore operation. + */ +export function useHistoryRestore() { + const { t } = useTranslation(); + + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); + + const mainEditor = useAtomValue(pageEditorAtom); + const mainEditorTitle = useAtomValue(titleEditorAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const { spaceSlug } = useParams(); + const { data: space } = useSpaceQuery(spaceSlug); + const spaceAbility = useSpaceAbility(space?.membership?.permissions); + + const canRestore = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Page, + ); + + const handleRestore = useCallback(() => { + if (!activeHistoryData) return; + + mainEditorTitle + .chain() + .clearContent() + .setContent(activeHistoryData.title, { emitUpdate: true }) + .run(); + + mainEditor + .chain() + .clearContent() + .setContent(activeHistoryData.content) + .run(); + + setHistoryModalOpen(false); + notifications.show({ message: t("Successfully restored") }); + }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); + + const confirmRestore = useCallback(() => { + 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, + }); + }, [t, handleRestore]); + + return { canRestore, confirmRestore }; +}