Compare commits

...

31 Commits

Author SHA1 Message Date
Philipinho fe3732add9 responsive highlight button 2026-02-01 23:55:10 +00:00
Philipinho 9db9eb852c fix text shift 2026-02-01 23:43:36 +00:00
Philipinho 3007060ac4 colors 2026-02-01 23:38:45 +00:00
Philipinho ba9e58ede9 remove counter 2026-02-01 19:05:58 +00:00
Philipinho f2bc0b5049 WIP 2026-02-01 18:44:25 +00:00
Philipinho eba5ae2eb3 dry 2026-02-01 18:42:06 +00:00
Philipinho b6344f2e08 mobile scroll 2026-02-01 18:18:07 +00:00
Philipinho db52b6036e add diff to mobile 2026-02-01 18:01:24 +00:00
Philipinho fe5b236d41 WIP 2026-02-01 17:54:21 +00:00
Philipinho b3c5ca6d5f WIP 2026-02-01 17:39:55 +00:00
Philipinho 483e39db1c WIP 2026-02-01 11:16:38 +00:00
Philipinho 56f476649c scroll 2026-02-01 10:52:45 +00:00
Philipinho 1b82959859 type 2026-02-01 10:46:32 +00:00
Philipinho 4d322e9157 atom 2026-02-01 02:16:53 +00:00
Philipinho 20a7acfccc fix scroll 2026-02-01 00:39:29 +00:00
Philipinho 040ad04a27 scroll 2026-02-01 00:15:58 +00:00
Philipinho d3ca1ed72c legible 2026-02-01 00:01:17 +00:00
Philipinho 207b1b593a cleanup diff count 2026-01-31 23:59:40 +00:00
Philipinho 6c664a366f fix flicker 2026-01-31 23:50:55 +00:00
Philipinho 4873f7b9ff prefetch history 2026-01-31 23:33:25 +00:00
Philipinho 718ca2b674 lazy load history 2026-01-31 23:19:56 +00:00
Philipinho e189d01ce0 dev mode history 2026-01-31 22:46:21 +00:00
Philipinho edd3754e46 WIP 2026-01-31 22:44:52 +00:00
Philipinho b2b147f1bd node deletion 2026-01-31 22:15:16 +00:00
Philipinho 129e21f728 fix inline changes in nested nodes 2026-01-31 21:59:11 +00:00
Philipinho 70124475ab nodes 2026-01-31 21:54:22 +00:00
Philipinho 7d7decb459 highlights 2026-01-31 20:03:43 +00:00
Philipinho 1eba6e93cc WIP 3 2026-01-31 19:03:26 +00:00
Philipinho cd52acc415 WIP 2 2026-01-31 19:01:43 +00:00
Philipinho a09e35ba8f WIP 2026-01-31 18:34:25 +00:00
Philipinho 8e2cf9bb02 fix 2026-01-31 16:46:40 +00:00
24 changed files with 989 additions and 493 deletions
@@ -123,6 +123,11 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "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.", "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",
"pages": "pages", "pages": "pages",
@@ -3,3 +3,7 @@ import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false); export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom<string>(""); export const activeHistoryIdAtom = atom<string>("");
export const activeHistoryPrevIdAtom = atom<string>(""); export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -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);
}
@@ -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;
}
@@ -26,12 +26,12 @@
} }
.sidebar { .sidebar {
max-height: rem(700px); max-height: rem(700px);
width: rem(250px); width: rem(250px);
padding: var(--mantine-spacing-sm); padding: var(--mantine-spacing-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: rem(1px) solid border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
} }
@@ -44,6 +44,6 @@
} }
.sidebarRightSection { .sidebarRightSection {
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
@@ -1,59 +0,0 @@
.container {
width: 100%;
}
.diffSummary {
border: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: rem(10px);
padding: rem(12px);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
:global(.history-diff-added) {
position: relative;
z-index: 0;
}
:global(.history-diff-added)::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
left: rem(-12px);
right: 0;
border-left: rem(4px) solid
light-dark(var(--mantine-color-green-6), var(--mantine-color-green-4));
background: light-dark(var(--mantine-color-green-0), rgba(0, 255, 0, 0.06));
border-radius: rem(6px);
pointer-events: none;
}
:global(.history-diff-deleted) {
position: relative;
z-index: 0;
text-decoration: line-through;
opacity: 0.9;
}
:global(.history-diff-deleted)::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
left: rem(-12px);
right: 0;
border-left: rem(4px) solid
light-dark(var(--mantine-color-red-6), var(--mantine-color-red-4));
border-top: rem(1px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
border-right: rem(1px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
border-bottom: rem(1px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.08));
border-radius: rem(6px);
pointer-events: none;
}
@@ -1,11 +1,19 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect, useState } from "react"; import "./css/history-diff.module.css";
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 { Badge, Divider, Group, Text, Title } from "@mantine/core"; import { Title } from "@mantine/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { computeHistoryBlockDiff } from "@/features/page-history/utils/history-diff"; import historyClasses from "./css/history.module.css";
import classes from "./history-diff.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 { export interface HistoryEditorProps {
title: string; title: string;
@@ -18,99 +26,179 @@ export function HistoryEditor({
content, content,
previousContent, previousContent,
}: HistoryEditorProps) { }: HistoryEditorProps) {
const [highlightChanges] = useAtom(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
}); });
const [diffCounts, setDiffCounts] = useState<{ added: number; deleted: number }>({
added: 0,
deleted: 0,
});
useEffect(() => { useEffect(() => {
if (editor && content) { if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) { let decorationSet = DecorationSet.empty;
try { let addedCount = 0;
const currentDoc = editor.schema.nodeFromJSON(content); let deletedCount = 0;
const prevDoc = editor.schema.nodeFromJSON(previousContent);
const {
diffDoc,
addedNodeRanges,
deletedNodeRanges,
addedCount: aCount,
deletedCount: dCount,
} = computeHistoryBlockDiff(currentDoc, prevDoc);
editor.commands.setContent(diffDoc.toJSON()); if (previousContent) {
try {
const schema = editor.schema;
const docOld = Node.fromJSON(schema, previousContent);
const docNew = Node.fromJSON(schema, content);
addedCount = aCount; const tr = recreateTransform(docOld, docNew, {
deletedCount = dCount; complexSteps: true,
wordDiffs: true,
simplifyDiff: true,
});
const decos = addedNodeRanges.map((r) => const changeSet = ChangeSet.create(docOld).addSteps(
Decoration.node(r.from, r.to, { class: "history-diff-added" }), tr.doc,
); tr.mapping.maps,
const deletedDecos = deletedNodeRanges.map((r) => [],
Decoration.node(r.from, r.to, { class: "history-diff-deleted" }), );
); const changes = simplifyChanges(changeSet.changes, docNew);
decorationSet = DecorationSet.create(diffDoc, [...decos, ...deletedDecos]); editor.commands.setContent(content);
} catch {
decorationSet = DecorationSet.empty; const specialNodeTypes = new Set([
addedCount = 0; "image",
deletedCount = 0; "attachment",
editor.commands.setContent(content); "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;
docNew.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;
docOld.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 = docOld.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;
}
} }
} else {
decorationSet = DecorationSet.create(docNew, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content); editor.commands.setContent(content);
} }
} else {
setDiffCounts({ added: addedCount, deleted: deletedCount }); editor.commands.setContent(content);
const existingEditorProps = editor.options.editorProps ?? {};
editor.setOptions({
editorProps: {
...existingEditorProps,
decorations: () => decorationSet,
},
});
} }
}, [title, content, editor, previousContent]);
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 ( return (
<> <div>
<div className={classes.container}> <Title order={1}>{title}</Title>
<Title order={1}>{title}</Title> {editor && (
<EditorContent
{previousContent && ( editor={editor}
<> className={historyClasses.historyEditor}
<Divider my="md" /> />
<div className={classes.diffSummary}> )}
<Group gap="xs" wrap="wrap"> </div>
<Text fw={600}>Changes</Text>
<Badge variant="light" color="green">
+{diffCounts.added} added
</Badge>
<Badge variant="light" color="red">
-{diffCounts.deleted} deleted
</Badge>
<Text size="sm" c="dimmed">
(added = green, deleted = red/strikethrough)
</Text>
</Group>
</div>
<Divider my="md" />
</>
)}
{editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
</div>
</>
); );
} }
@@ -1,20 +1,42 @@
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 { memo, useCallback } from "react";
interface HistoryItemProps { interface HistoryItemProps {
historyItem: any; historyItem: IPageHistory;
onSelect: (id: string) => void; index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean; 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 ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
onClick={() => onSelect(historyItem.id)} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<CustomAvatar <CustomAvatar
size="sm" size="sm"
avatarUrl={historyItem.lastUpdatedBy.avatarUrl} avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy.name} name={historyItem.lastUpdatedBy?.name}
/> />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy.name} {historyItem.lastUpdatedBy?.name}
</Text> </Text>
</Group> </Group>
</div> </div>
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
} });
export default HistoryItem; export default HistoryItem;
@@ -1,6 +1,6 @@
import { import {
usePageHistoryListQuery, usePageHistoryListQuery,
usePageHistoryQuery, 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";
import { import {
@@ -8,23 +8,20 @@ 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 } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
import { import {
pageEditorAtom, Button,
titleEditorAtom, ScrollArea,
} from "@/features/editor/atoms/editor-atoms"; Group,
import { modals } from "@mantine/modals"; Divider,
import { notifications } from "@mantine/notifications"; Loader,
Center,
} from "@mantine/core";
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"; const PREFETCH_DELAY_MS = 150;
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -33,64 +30,89 @@ 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: pageHistoryList, data: pageHistoryData,
isLoading, isLoading,
isError, isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId); } = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const [mainEditor] = useAtom(pageEditorAtom); const historyItems = useMemo(
const [mainEditorTitle] = useAtom(titleEditorAtom); () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
const [, setHistoryModalOpen] = useAtom(historyAtoms); [pageHistoryData],
);
const { spaceSlug } = useParams(); const loadMoreRef = useRef<HTMLDivElement>(null);
const { data: space } = useSpaceQuery(spaceSlug); const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const confirmModal = () => const { canRestore, confirmRestore } = useHistoryRestore();
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(() => { const clearPrefetchTimeout = useCallback(() => {
if (activeHistoryData) { if (prefetchTimeoutRef.current) {
mainEditorTitle clearTimeout(prefetchTimeoutRef.current);
.chain() prefetchTimeoutRef.current = null;
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
} }
}, [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(() => { useEffect(() => {
if ( return clearPrefetchTimeout;
pageHistoryList && }, [clearPrefetchTimeout]);
pageHistoryList.items.length > 0 &&
!activeHistoryId const handleSelect = useCallback(
) { (id: string, index: number) => {
setActiveHistoryId(pageHistoryList.items[0].id); setActiveHistoryId(id);
setActiveHistoryPrevId(pageHistoryList.items[1]?.id ?? ""); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
} }
}, [pageHistoryList, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]); }, [
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) { if (isLoading) {
return <></>; return <></>;
@@ -100,45 +122,45 @@ function HistoryList({ pageId }: Props) {
return <div>{t("Error loading page history.")}</div>; return <div>{t("Error loading page history.")}</div>;
} }
if (!pageHistoryList || pageHistoryList.items.length === 0) { if (historyItems.length === 0) {
return <>{t("No page history saved yet.")}</>; return <>{t("No page history saved yet.")}</>;
} }
return ( return (
<div> <div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}> <ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{pageHistoryList && {historyItems.map((historyItem, index) => (
pageHistoryList.items.map((historyItem, index) => ( <HistoryItem
<HistoryItem key={historyItem.id}
key={historyItem.id} historyItem={historyItem}
historyItem={historyItem} index={index}
onSelect={(id) => { onSelect={handleSelect}
setActiveHistoryId(id); onHover={handleHover}
setActiveHistoryPrevId( onHoverEnd={clearPrefetchTimeout}
pageHistoryList.items[index + 1]?.id ?? "", isActive={historyItem.id === activeHistoryId}
); />
}} ))}
isActive={historyItem.id === activeHistoryId} {hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
/> {isFetchingNextPage && (
))} <Center py="sm">
<Loader size="sm" />
</Center>
)}
</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}>
{t("Restore")}
</Button>
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
onClick={() => setHistoryModalOpen(false)} onClick={() => setHistoryModalOpen(false)}
> >
{t("Cancel")} {t("Close")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button> </Button>
</Group> </Group>
</> </>
@@ -1,28 +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 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,
diffCountsAtom,
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 } 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 { interface Props {
pageId: string; pageId: string;
} }
export default function HistoryModalBody({ pageId }: Props) { export default function HistoryModalBody({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const { t } = useTranslation();
const [activeHistoryPrevId, setActiveHistoryPrevId] = useAtom( const scrollViewportRef = useRef<HTMLDivElement>(null);
activeHistoryPrevIdAtom,
);
useEffect(() => { const activeHistoryId = useAtomValue(activeHistoryIdAtom);
setActiveHistoryId(""); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
setActiveHistoryPrevId(""); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
}, [pageId]); const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return ( return (
<div className={classes.sidebarFlex}> <div className={classes.sidebarFlex}>
@@ -32,16 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
</div> </div>
</nav> </nav>
<ScrollArea h={650} w="100%" scrollbarSize={5}> <div style={{ position: "relative", flex: 1 }}>
<div className={classes.sidebarRightSection}> <ScrollArea
{activeHistoryId && ( h={650}
<HistoryView w="100%"
historyId={activeHistoryId} scrollbarSize={5}
prevHistoryId={activeHistoryPrevId} viewportRef={scrollViewportRef}
/> >
)} <div className={classes.sidebarRightSection}>
</div> {activeHistoryId && <HistoryView />}
</ScrollArea> </div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
px="md"
py="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
}}
>
<Group gap="md" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</div>
</div> </div>
); );
} }
@@ -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<HTMLDivElement>(null);
const dropdownViewportRef = useRef<HTMLDivElement>(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 (
<Box className={classes.container}>
<Box className={classes.selectorWrapper}>
<Select
data={selectData}
value={activeHistoryId}
onChange={handleSelectVersion}
placeholder={t("Select version")}
checkIconPosition="right"
maxDropdownHeight={300}
renderOption={({ option, checked }) => (
<Group justify="space-between" wrap="nowrap" w="100%">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{(option as { userName?: string }).userName}
</Text>
</div>
{checked && <IconCheck size={16} />}
</Group>
)}
comboboxProps={{ withinPortal: false }}
scrollAreaProps={{
viewportRef: dropdownViewportRef,
onScrollPositionChange: handleDropdownScroll,
}}
/>
</Box>
<ScrollArea
className={classes.editorArea}
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box className={classes.editorContent}>
{activeHistoryId && <HistoryView />}
</Box>
</ScrollArea>
{canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Close")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
{activeHistoryId && (
<Paper
shadow="sm"
radius="xl"
px="md"
py="xs"
className={classes.floatingBar}
>
<Group gap="sm" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
size="sm"
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap={4} wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</Box>
);
}
@@ -2,21 +2,26 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body"; import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
pageTitle?: string;
} }
export default function HistoryModal({ pageId }: Props) {
export default function HistoryModal({ pageId, pageTitle }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms); const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
const isMobile = useMediaQuery("(max-width: 800px)");
return ( if (isMobile) {
<> return (
<Modal.Root <Modal.Root
size={1200}
opened={isModalOpen} opened={isModalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
fullScreen
> >
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body
<HistoryModalBody pageId={pageId} /> p={0}
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
>
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
</> );
}
return (
<Modal.Root
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
</Modal.Body>
</Modal.Content>
</Modal.Root>
); );
} }
@@ -1,14 +1,17 @@
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor"; import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface HistoryProps { function HistoryView() {
historyId: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: HistoryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const { const {
data, data,
isLoading: isLoadingCurrent, isLoading: isLoadingCurrent,
@@ -18,7 +21,7 @@ function HistoryView({ historyId, prevHistoryId }: HistoryProps) {
data: prevData, data: prevData,
isLoading: isLoadingPrev, isLoading: isLoadingPrev,
isError: isErrorPrev, isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId ?? ""); } = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) { if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
@@ -29,15 +32,13 @@ function HistoryView({ historyId, prevHistoryId }: HistoryProps) {
} }
return ( return (
data && ( <div>
<div> <HistoryEditor
<HistoryEditor content={data.content}
content={data.content} title={data.title}
title={data.title} previousContent={!isErrorPrev ? prevData?.content : undefined}
previousContent={!isErrorPrev ? prevData?.content : undefined} />
/> </div>
</div>
)
); );
} }
@@ -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
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -0,0 +1,78 @@
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";
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 };
}
@@ -1,19 +1,38 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { import {
getPageHistoryById, getPageHistoryById,
getPageHistoryList, getPageHistoryList,
} from "@/features/page-history/services/page-history-service"; } from "@/features/page-history/services/page-history-service";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main";
const HISTORY_STALE_TIME = 60 * 60 * 1000;
export function prefetchPageHistory(historyId: string) {
return queryClient.prefetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseQueryResult<IPagination<IPageHistory>, Error> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
return useQuery({ return useInfiniteQuery({
queryKey: ["page-history-list", pageId], queryKey: ["page-history-list", pageId],
queryFn: () => getPageHistoryList(pageId), queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
enabled: !!pageId, enabled: !!pageId,
gcTime: 0, gcTime: 0,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
queryKey: ["page-history", historyId], queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId), queryFn: () => getPageHistoryById(historyId),
enabled: !!historyId, enabled: !!historyId,
staleTime: 10 * 60 * 1000, staleTime: HISTORY_STALE_TIME,
}); });
} }
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList( export async function getPageHistoryList(
pageId: string, pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> { ): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", { const req = await api.post("/pages/history", {
pageId, pageId,
cursor,
}); });
return req.data; return req.data;
} }
@@ -1,183 +0,0 @@
import type { Node as PMNode } from "@tiptap/pm/model";
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
.join(",")}}`;
}
type DiffOp =
| { type: "equal"; aIndex: number; bIndex: number }
| { type: "insert"; bIndex: number }
| { type: "delete"; aIndex: number };
function myersDiff(a: string[], b: string[]): DiffOp[] {
const N = a.length;
const M = b.length;
const max = N + M;
let v = new Map<number, number>();
v.set(1, 0);
const trace: Array<Map<number, number>> = [];
for (let d = 0; d <= max; d += 1) {
const vNew = new Map<number, number>();
for (let k = -d; k <= d; k += 2) {
const vKMinus = v.get(k - 1) ?? 0;
const vKPlus = v.get(k + 1) ?? 0;
let x: number;
if (k === -d || (k !== d && vKMinus < vKPlus)) {
x = vKPlus;
} else {
x = vKMinus + 1;
}
let y = x - k;
while (x < N && y < M && a[x] === b[y]) {
x += 1;
y += 1;
}
vNew.set(k, x);
if (x >= N && y >= M) {
trace.push(vNew);
return backtrack(trace, a, b);
}
}
trace.push(vNew);
v = vNew;
}
return [];
}
function backtrack(trace: Array<Map<number, number>>, a: string[], b: string[]) {
let x = a.length;
let y = b.length;
const ops: DiffOp[] = [];
for (let d = trace.length - 1; d > 0; d -= 1) {
const v = trace[d];
const prevV = trace[d - 1];
const k = x - y;
const prevK =
k === -d || (k !== d && (prevV.get(k - 1) ?? 0) < (prevV.get(k + 1) ?? 0))
? k + 1
: k - 1;
const prevX = prevV.get(prevK) ?? 0;
const prevY = prevX - prevK;
while (x > prevX && y > prevY) {
ops.push({ type: "equal", aIndex: x - 1, bIndex: y - 1 });
x -= 1;
y -= 1;
}
if (x === prevX) {
ops.push({ type: "insert", bIndex: y - 1 });
y -= 1;
} else {
ops.push({ type: "delete", aIndex: x - 1 });
x -= 1;
}
}
while (x > 0 && y > 0) {
ops.push({ type: "equal", aIndex: x - 1, bIndex: y - 1 });
x -= 1;
y -= 1;
}
while (x > 0) {
ops.push({ type: "delete", aIndex: x - 1 });
x -= 1;
}
while (y > 0) {
ops.push({ type: "insert", bIndex: y - 1 });
y -= 1;
}
ops.reverse();
return ops;
}
export interface HistoryBlockDiffResult {
diffDoc: PMNode;
addedNodeRanges: Array<{ from: number; to: number }>;
deletedNodeRanges: Array<{ from: number; to: number }>;
addedCount: number;
deletedCount: number;
}
export function computeHistoryBlockDiff(
currentDoc: PMNode,
prevDoc: PMNode,
): HistoryBlockDiffResult {
const currentTop = Array.from({ length: currentDoc.childCount }, (_, i) =>
currentDoc.child(i),
);
const prevTop = Array.from({ length: prevDoc.childCount }, (_, i) =>
prevDoc.child(i),
);
const currentHashes = currentTop.map((n) => stableStringify(n.toJSON()));
const prevHashes = prevTop.map((n) => stableStringify(n.toJSON()));
const ops = myersDiff(prevHashes, currentHashes);
const nodes: PMNode[] = [];
const addedIndices: number[] = [];
const deletedIndices: number[] = [];
for (const op of ops) {
if (op.type === "equal") {
nodes.push(currentTop[op.bIndex]);
continue;
}
if (op.type === "insert") {
addedIndices.push(nodes.length);
nodes.push(currentTop[op.bIndex]);
continue;
}
deletedIndices.push(nodes.length);
nodes.push(prevTop[op.aIndex]);
}
const diffDoc = currentDoc.type.create(null, nodes);
const addedNodeRanges: Array<{ from: number; to: number }> = [];
const deletedNodeRanges: Array<{ from: number; to: number }> = [];
let pos = 0;
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const from = pos;
const to = pos + node.nodeSize;
if (addedIndices.includes(i)) addedNodeRanges.push({ from, to });
if (deletedIndices.includes(i)) deletedNodeRanges.push({ from, to });
pos = to;
}
return {
diffDoc,
addedNodeRanges,
deletedNodeRanges,
addedCount: addedIndices.length,
deletedCount: deletedIndices.length,
};
}
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent { export class UpdatedPageEvent {
page: Page; page: Page;
@@ -12,7 +13,10 @@ export class UpdatedPageEvent {
export class HistoryListener { export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name); private readonly logger = new Logger(HistoryListener.name);
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {} constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated') @OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) { async handleCreatePageHistory(event: UpdatedPageEvent) {
@@ -20,7 +24,9 @@ export class HistoryListener {
const pageCreationTime = new Date(page.createdAt).getTime(); const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now(); const currentTime = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000; const FIVE_MINUTES = this.environmentService.isDevelopment()
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) { if (currentTime - pageCreationTime < FIVE_MINUTES) {
return; return;
+3
View File
@@ -60,6 +60,7 @@
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -70,6 +71,7 @@
"marked": "13.0.3", "marked": "13.0.3",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rfc6902": "5.1.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7", "y-prosemirror": "1.3.7",
@@ -98,6 +100,7 @@
"overrides": { "overrides": {
"jsdom": "25.0.1", "jsdom": "25.0.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7" "y-prosemirror": "1.3.7"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
+1 -5
View File
@@ -9,9 +9,5 @@
"main": "dist/index.js", "main": "dist/index.js",
"module": "./src/index.ts", "module": "./src/index.ts",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"dependencies": { "dependencies": {}
"diff": "^8.0.3",
"prosemirror-changeset": "^2.3.1",
"rfc6902": "^5.1.2"
}
} }
+8 -11
View File
@@ -7,6 +7,7 @@ settings:
overrides: overrides:
jsdom: 25.0.1 jsdom: 25.0.1
jsonwebtoken: 9.0.3 jsonwebtoken: 9.0.3
prosemirror-changeset: 2.3.1
y-prosemirror: 1.3.7 y-prosemirror: 1.3.7
patchedDependencies: patchedDependencies:
@@ -141,6 +142,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
diff:
specifier: 8.0.3
version: 8.0.3
dompurify: dompurify:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.6 version: 3.2.6
@@ -171,6 +175,9 @@ importers:
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
rfc6902:
specifier: 5.1.2
version: 5.1.2
uuid: uuid:
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0 version: 11.1.0
@@ -742,17 +749,7 @@ importers:
specifier: ^8.24.1 specifier: ^8.24.1
version: 8.24.1(eslint@9.20.1(jiti@1.21.0))(typescript@5.7.3) version: 8.24.1(eslint@9.20.1(jiti@1.21.0))(typescript@5.7.3)
packages/editor-ext: packages/editor-ext: {}
dependencies:
diff:
specifier: ^8.0.3
version: 8.0.3
prosemirror-changeset:
specifier: ^2.3.1
version: 2.3.1
rfc6902:
specifier: ^5.1.2
version: 5.1.2
packages: packages: