mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 07:54:05 +08:00
dry
This commit is contained in:
@@ -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`<Data = any>`
|
|
||||||
|
|
||||||
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`**`<Data>(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`<Data = any>`
|
|
||||||
|
|
||||||
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`<Data = any>`
|
|
||||||
|
|
||||||
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`**`<Data = any>(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`<T>`
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import "./history-diff.module.css";
|
import "./css/history-diff.module.css";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from "@mantine/core";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
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 { recreateTransform } from "@docmost/editor-ext";
|
||||||
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text, Group, UnstyledButton } from "@mantine/core";
|
import { Text, Group, UnstyledButton } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
usePageHistoryQuery,
|
|
||||||
prefetchPageHistory,
|
prefetchPageHistory,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem from "@/features/page-history/components/history-item";
|
||||||
@@ -9,31 +8,18 @@ import {
|
|||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Group,
|
Group,
|
||||||
Divider,
|
Divider,
|
||||||
Text,
|
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
} from "@mantine/core";
|
} 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 { useTranslation } from "react-i18next";
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
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";
|
|
||||||
|
|
||||||
const PREFETCH_DELAY_MS = 150;
|
const PREFETCH_DELAY_MS = 150;
|
||||||
|
|
||||||
@@ -44,7 +30,9 @@ interface Props {
|
|||||||
function HistoryList({ pageId }: Props) {
|
function HistoryList({ pageId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pageHistoryData,
|
data: pageHistoryData,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -53,7 +41,6 @@ function HistoryList({ pageId }: Props) {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
|
||||||
|
|
||||||
const historyItems = useMemo(
|
const historyItems = useMemo(
|
||||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||||
@@ -63,45 +50,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [mainEditor] = useAtom(pageEditorAtom);
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
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: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
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 clearPrefetchTimeout = useCallback(() => {
|
const clearPrefetchTimeout = useCallback(() => {
|
||||||
if (prefetchTimeoutRef.current) {
|
if (prefetchTimeoutRef.current) {
|
||||||
@@ -141,7 +90,12 @@ function HistoryList({ pageId }: Props) {
|
|||||||
setActiveHistoryId(historyItems[0].id);
|
setActiveHistoryId(historyItems[0].id);
|
||||||
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
}
|
}
|
||||||
}, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]);
|
}, [
|
||||||
|
historyItems,
|
||||||
|
activeHistoryId,
|
||||||
|
setActiveHistoryId,
|
||||||
|
setActiveHistoryPrevId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = loadMoreRef.current;
|
const sentinel = loadMoreRef.current;
|
||||||
@@ -194,14 +148,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{spaceAbility.cannot(
|
{canRestore && (
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
) ? null : (
|
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group p="xs" wrap="nowrap">
|
<Group p="xs" wrap="nowrap">
|
||||||
<Button size="compact-md" onClick={confirmModal}>
|
<Button size="compact-md" onClick={confirmRestore}>
|
||||||
{t("Restore")}
|
{t("Restore")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -209,7 +160,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
size="compact-md"
|
size="compact-md"
|
||||||
onClick={() => setHistoryModalOpen(false)}
|
onClick={() => setHistoryModalOpen(false)}
|
||||||
>
|
>
|
||||||
{t("Cancel")}
|
{t("Close")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import HistoryList from "@/features/page-history/components/history-list";
|
import HistoryList from "@/features/page-history/components/history-list";
|
||||||
import classes from "./history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
@@ -17,9 +17,13 @@ import {
|
|||||||
highlightChangesAtom,
|
highlightChangesAtom,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
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 { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useDiffNavigation,
|
||||||
|
useHistoryReset,
|
||||||
|
} from "@/features/page-history/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -27,60 +31,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function HistoryModalBody({ pageId }: Props) {
|
export default function HistoryModalBody({ pageId }: Props) {
|
||||||
const { t } = useTranslation();
|
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<HTMLDivElement>(null);
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||||
setActiveHistoryId("");
|
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
setActiveHistoryPrevId("");
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
// @ts-ignore
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
setDiffCounts(null);
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useHistoryReset(pageId);
|
||||||
if (diffCounts && diffCounts.total > 0) {
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
setCurrentChangeIndex(1);
|
useDiffNavigation(scrollViewportRef);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.sidebarFlex}>
|
<div className={classes.sidebarFlex}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
@@ -19,28 +19,17 @@ import {
|
|||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
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 { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
usePageHistoryListQuery,
|
|
||||||
usePageHistoryQuery,
|
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
useDiffNavigation,
|
||||||
titleEditorAtom,
|
useHistoryReset,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
useHistoryRestore,
|
||||||
import { modals } from "@mantine/modals";
|
} from "@/features/page-history/hooks";
|
||||||
import { notifications } from "@mantine/notifications";
|
import classes from "./css/history-mobile.module.css";
|
||||||
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";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -51,12 +40,11 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
const [diffCounts, setDiffCounts] = useAtom(diffCountsAtom);
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
|
|
||||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const dropdownViewportRef = useRef<HTMLDivElement>(null);
|
const dropdownViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -67,7 +55,6 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
|
||||||
|
|
||||||
const historyItems = useMemo(
|
const historyItems = useMemo(
|
||||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||||
@@ -84,41 +71,22 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
[historyItems],
|
[historyItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [mainEditor] = useAtom(pageEditorAtom);
|
useHistoryReset(pageId);
|
||||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
const { spaceSlug } = useParams();
|
useDiffNavigation(scrollViewportRef);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historyItems.length > 0 && !activeHistoryId) {
|
if (historyItems.length > 0 && !activeHistoryId) {
|
||||||
setActiveHistoryId(historyItems[0].id);
|
setActiveHistoryId(historyItems[0].id);
|
||||||
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
}
|
}
|
||||||
}, [historyItems, activeHistoryId]);
|
}, [
|
||||||
|
historyItems,
|
||||||
useEffect(() => {
|
activeHistoryId,
|
||||||
if (diffCounts && diffCounts.total > 0) {
|
setActiveHistoryId,
|
||||||
setCurrentChangeIndex(1);
|
setActiveHistoryPrevId,
|
||||||
requestAnimationFrame(() => scrollToChangeIndex(1));
|
]);
|
||||||
} else {
|
|
||||||
setCurrentChangeIndex(0);
|
|
||||||
}
|
|
||||||
}, [diffCounts]);
|
|
||||||
|
|
||||||
const handleDropdownScroll = useCallback(() => {
|
const handleDropdownScroll = useCallback(() => {
|
||||||
const viewport = dropdownViewportRef.current;
|
const viewport = dropdownViewportRef.current;
|
||||||
@@ -132,35 +100,6 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
}
|
}
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
}, [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(
|
const handleSelectVersion = useCallback(
|
||||||
(value: string | null) => {
|
(value: string | null) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
@@ -170,40 +109,9 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[historyItems],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmRestore = () =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Please confirm your action"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
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) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useDiffNavigation } from "./use-diff-navigation";
|
||||||
|
export { useHistoryRestore } from "./use-history-restore";
|
||||||
|
export { useHistoryReset } from "./use-history-reset";
|
||||||
@@ -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<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
|
onConfirm: handleRestore,
|
||||||
|
});
|
||||||
|
}, [t, handleRestore]);
|
||||||
|
|
||||||
|
return { canRestore, confirmRestore };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user