Compare commits

...

34 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
Philipinho cff4ba8e50 Merge branch 'main' into feat/history-diff 2026-01-31 16:40:44 +00:00
Philipinho f44bb3f121 V2 - WIP 2026-01-21 11:45:41 +00:00
Jason Norwood-Young 669ff0435f Show actual history changes 2026-01-20 22:47:29 +00:00
33 changed files with 1572 additions and 144 deletions
@@ -123,6 +123,11 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Version history for": "Version history for",
"document": "document",
"Select version": "Select version",
"Close": "Close",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages",
"pages": "pages",
@@ -1,4 +1,9 @@
import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom<string>('');
export const activeHistoryIdAtom = 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;
}
@@ -1,36 +1,204 @@
import "@/features/editor/styles/index.css";
import React, { useEffect } from "react";
import "./css/history-diff.module.css";
import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core";
import classes from "./history.module.css";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { useAtom } from "jotai";
import {
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
export interface HistoryEditorProps {
title: string;
content: any;
previousContent?: any;
}
export function HistoryEditor({ title, content }: HistoryEditorProps) {
export function HistoryEditor({
title,
content,
previousContent,
}: HistoryEditorProps) {
const [highlightChanges] = useAtom(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const editor = useEditor({
extensions: mainExtensions,
editable: false,
});
useEffect(() => {
if (editor && content) {
if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) {
try {
const schema = editor.schema;
const docOld = Node.fromJSON(schema, previousContent);
const docNew = Node.fromJSON(schema, content);
const tr = recreateTransform(docOld, docNew, {
complexSteps: true,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(docOld).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, docNew);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
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;
}
}
decorationSet = DecorationSet.create(docNew, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content);
}
}, [title, content, editor]);
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () =>
highlightChanges ? decorationSet : DecorationSet.empty,
},
});
}, [
title,
content,
editor,
previousContent,
highlightChanges,
setDiffCounts,
]);
return (
<>
<div>
<Title order={1}>{title}</Title>
{editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
</div>
</>
<div>
<Title order={1}>{title}</Title>
{editor && (
<EditorContent
editor={editor}
className={historyClasses.historyEditor}
/>
)}
</div>
);
}
@@ -1,20 +1,42 @@
import { Text, Group, UnstyledButton } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./history.module.css";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
interface HistoryItemProps {
historyItem: any;
onSelect: (id: string) => void;
historyItem: IPageHistory;
index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
}
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
const HistoryItem = memo(function HistoryItem({
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
return (
<UnstyledButton
p="xs"
onClick={() => onSelect(historyItem.id)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Group wrap="nowrap">
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
<Group gap={4} wrap="nowrap">
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
name={historyItem.lastUpdatedBy.name}
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy.name}
{historyItem.lastUpdatedBy?.name}
</Text>
</Group>
</div>
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
</Group>
</UnstyledButton>
);
}
});
export default HistoryItem;
@@ -1,29 +1,27 @@
import {
usePageHistoryListQuery,
usePageHistoryQuery,
prefetchPageHistory,
} from "@/features/page-history/queries/page-history-query";
import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
Button,
ScrollArea,
Group,
Divider,
Loader,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useHistoryRestore } from "@/features/page-history/hooks";
const PREFETCH_DELAY_MS = 150;
interface Props {
pageId: string;
@@ -32,62 +30,89 @@ interface Props {
function HistoryList({ pageId }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const {
data: pageHistoryList,
data: pageHistoryData,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const [mainEditor] = useAtom(pageEditorAtom);
const [mainEditorTitle] = useAtom(titleEditorAtom);
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const loadMoreRef = useRef<HTMLDivElement>(null);
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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 { canRestore, confirmRestore } = useHistoryRestore();
const handleRestore = useCallback(() => {
if (activeHistoryData) {
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
const clearPrefetchTimeout = useCallback(() => {
if (prefetchTimeoutRef.current) {
clearTimeout(prefetchTimeoutRef.current);
prefetchTimeoutRef.current = null;
}
}, [activeHistoryData]);
}, []);
const handleHover = useCallback(
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
}, PREFETCH_DELAY_MS);
},
[clearPrefetchTimeout, historyItems],
);
useEffect(() => {
if (
pageHistoryList &&
pageHistoryList.items.length > 0 &&
!activeHistoryId
) {
setActiveHistoryId(pageHistoryList.items[0].id);
return clearPrefetchTimeout;
}, [clearPrefetchTimeout]);
const handleSelect = useCallback(
(id: string, index: number) => {
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
}
}, [pageHistoryList]);
}, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) {
return <></>;
@@ -97,40 +122,45 @@ function HistoryList({ pageId }: Props) {
return <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 (
<div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{pageHistoryList &&
pageHistoryList.items.map((historyItem, index) => (
<HistoryItem
key={index}
historyItem={historyItem}
onSelect={setActiveHistoryId}
isActive={historyItem.id === activeHistoryId}
/>
))}
{historyItems.map((historyItem, index) => (
<HistoryItem
key={historyItem.id}
historyItem={historyItem}
index={index}
onSelect={handleSelect}
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea>
{spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
{canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
{t("Close")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group>
</>
@@ -1,21 +1,45 @@
import { ScrollArea } from "@mantine/core";
import {
ActionIcon,
Group,
Paper,
ScrollArea,
Switch,
Text,
} from "@mantine/core";
import HistoryList from "@/features/page-history/components/history-list";
import classes from "./history.module.css";
import { useAtom } from "jotai";
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
import classes from "./css/history.module.css";
import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useEffect } from "react";
import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
interface Props {
pageId: string;
}
export default function HistoryModalBody({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const { t } = useTranslation();
const scrollViewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setActiveHistoryId("");
}, [pageId]);
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return (
<div className={classes.sidebarFlex}>
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
</div>
</nav>
<ScrollArea h="650" w="100%" scrollbarSize={5}>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
</div>
</ScrollArea>
<div style={{ position: "relative", flex: 1 }}>
<ScrollArea
h={650}
w="100%"
scrollbarSize={5}
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
</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>
);
}
@@ -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 { historyAtoms } from "@/features/page-history/atoms/history-atoms";
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 { useMediaQuery } from "@mantine/hooks";
interface Props {
pageId: string;
pageTitle?: string;
}
export default function HistoryModal({ pageId }: Props) {
export default function HistoryModal({ pageId, pageTitle }: Props) {
const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
const isMobile = useMediaQuery("(max-width: 800px)");
return (
<>
if (isMobile) {
return (
<Modal.Root
size={1200}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
fullScreen
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
<Modal.Body
p={0}
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
>
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
</Modal.Body>
</Modal.Content>
</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,29 +1,44 @@
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface HistoryProps {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
function HistoryView() {
const { t } = useTranslation();
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
if (isLoading) {
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
}
if (isError || !data) {
if (isErrorCurrent || !data) {
return <div>{t("Error fetching page data.")}</div>;
}
return (
data && (
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
)
<div>
<HistoryEditor
content={data.content}
title={data.title}
previousContent={!isErrorPrev ? prevData?.content : undefined}
/>
</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 {
getPageHistoryById,
getPageHistoryList,
} from "@/features/page-history/services/page-history-service";
import { IPageHistory } from "@/features/page-history/types/page.types";
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(
pageId: string,
): UseQueryResult<IPagination<IPageHistory>, Error> {
return useQuery({
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-history-list", pageId],
queryFn: () => getPageHistoryList(pageId),
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
enabled: !!pageId,
gcTime: 0,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(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(
pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", {
pageId,
cursor,
});
return req.data;
}
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent {
page: Page;
@@ -12,7 +13,10 @@ export class UpdatedPageEvent {
export class HistoryListener {
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')
async handleCreatePageHistory(event: UpdatedPageEvent) {
@@ -20,7 +24,9 @@ export class HistoryListener {
const pageCreationTime = new Date(page.createdAt).getTime();
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) {
return;
+3
View File
@@ -60,6 +60,7 @@
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
@@ -70,6 +71,7 @@
"marked": "13.0.3",
"ms": "3.0.0-canary.1",
"qrcode": "^1.5.4",
"rfc6902": "5.1.2",
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7",
@@ -98,6 +100,7 @@
"overrides": {
"jsdom": "25.0.1",
"jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7"
},
"neverBuiltDependencies": []
+2 -1
View File
@@ -8,5 +8,6 @@
},
"main": "dist/index.js",
"module": "./src/index.ts",
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"dependencies": {}
}
+1
View File
@@ -24,3 +24,4 @@ export * from "./lib/highlight";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
@@ -0,0 +1,145 @@
# 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.
@@ -0,0 +1,30 @@
# prosemirror-recreate-transform
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
This is a non-core module of [ProseMirror](http://prosemirror.net).
ProseMirror is a well-behaved rich semantic content editor based on
contentEditable, with support for collaborative editing and custom
document schemas.
Every change to the document is recorded by ProseMirror as a step.
This module allows recreating the steps needed to go from document
A to B should these not be available otherwise. Recreating steps
can be interesting for example in order to show the changes between
two document versions without having access to the original steps.
Recreating a `Transform` works this way:
```js
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
let tr = recreateTransform(
startDoc,
endDoc,
{
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
simplifyDiffs: true // Whether steps should be merged, where possible
}
);
```
@@ -0,0 +1,3 @@
export function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
@@ -0,0 +1,17 @@
import { AnyObject } from "./types";
/**
* get target value from json-pointer (e.g. /content/0/content)
* @param {AnyObject} obj object to resolve path into
* @param {string} path json-pointer
* @return {any} target value
*/
export function getFromPath(obj: AnyObject, path: string): any {
const pathParts = path.split("/");
pathParts.shift(); // remove root-entry
while (pathParts.length) {
const property = pathParts.shift();
obj = obj[property];
}
return obj;
}
@@ -0,0 +1,29 @@
import { ReplaceStep } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
let start = toDoc.content.findDiffStart(fromDoc.content);
if (start === null) {
return false;
}
// @ts-ignore property access to content
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
const overlap = start - Math.min(endA, endB);
if (overlap > 0) {
// If there is an overlap, there is some freedom of choice in how to calculate the
// start/end boundary. for an inserted/removed slice. We choose the extreme with
// the lowest depth value.
if (
fromDoc.resolve(start - overlap).depth <
toDoc.resolve(endA + overlap).depth
) {
start -= overlap;
} else {
endA += overlap;
endB += overlap;
}
}
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
}
@@ -0,0 +1,4 @@
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps
// https://github.com/sueddeutsche/prosemirror-recreate-transform
export { recreateTransform, RecreateTransform } from "./recreateTransform";
export type { Options } from "./recreateTransform";
@@ -0,0 +1,279 @@
import { Transform } from "@tiptap/pm/transform";
import { Node, Schema } from "@tiptap/pm/model";
import { applyPatch, createPatch, Operation } from "rfc6902";
import { diffWordsWithSpace, diffChars } from "diff";
import { AnyObject } from "./types";
import { getReplaceStep } from "./getReplaceStep";
import { simplifyTransform } from "./simplifyTransform";
import { removeMarks } from "./removeMarks";
import { getFromPath } from "./getFromPath";
import { copy } from "./copy";
export interface Options {
complexSteps?: boolean;
wordDiffs?: boolean;
simplifyDiff?: boolean;
}
export class RecreateTransform {
fromDoc: Node;
toDoc: Node;
complexSteps: boolean;
wordDiffs: boolean;
simplifyDiff: boolean;
schema: Schema;
tr: Transform;
/* current working document data, may get updated while recalculating node steps */
currentJSON: AnyObject;
/* final document as json data */
finalJSON: AnyObject;
ops: Array<Operation>;
constructor(fromDoc: Node, toDoc: Node, options: Options = {}) {
const o = {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
...options,
};
this.fromDoc = fromDoc;
this.toDoc = toDoc;
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
this.simplifyDiff = o.simplifyDiff;
this.schema = fromDoc.type.schema;
this.tr = new Transform(fromDoc);
}
init() {
if (this.complexSteps) {
// For First steps: we create versions of the documents without marks as
// these will only confuse the diffing mechanism and marks won't cause
// any mapping changes anyway.
this.currentJSON = removeMarks(this.fromDoc).toJSON();
this.finalJSON = removeMarks(this.toDoc).toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
this.recreateChangeMarkSteps();
} else {
// We don't differentiate between mark changes and other changes.
this.currentJSON = this.fromDoc.toJSON();
this.finalJSON = this.toDoc.toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
}
if (this.simplifyDiff) {
this.tr = simplifyTransform(this.tr) || this.tr;
}
return this.tr;
}
/** convert json-diff to prosemirror steps */
recreateChangeContentSteps() {
// First step: find content changing steps.
let ops = [];
while (this.ops.length) {
// get next
let op = this.ops.shift();
ops.push(op);
let toDoc;
const afterStepJSON = copy(this.currentJSON); // working document receiving patches
const pathParts = op.path.split("/");
// collect operations until we receive a valid document:
// apply ops-patches until a valid prosemirror document is retrieved,
// then try to create a transformation step or retry with next operation
while (toDoc == null) {
applyPatch(afterStepJSON, [op]);
try {
toDoc = this.schema.nodeFromJSON(afterStepJSON);
toDoc.check();
} catch (error) {
toDoc = null;
if (this.ops.length > 0) {
op = this.ops.shift();
ops.push(op);
} else {
throw new Error(`No valid diff possible applying ${op.path}`);
}
}
}
// apply operation (ignoring afterStepJSON)
if (
this.complexSteps &&
ops.length === 1 &&
(pathParts.includes("attrs") || pathParts.includes("type"))
) {
// Node markup is changing
this.addSetNodeMarkup(); // a lost update is ignored
ops = [];
// console.log("%cop", logStyle, "- update node", ops);
} else if (
ops.length === 1 &&
op.op === "replace" &&
pathParts[pathParts.length - 1] === "text"
) {
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
this.addReplaceTextSteps(op, afterStepJSON);
ops = [];
// console.log("%cop", logStyle, "- replace", ops);
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
// operations have been applied
ops = [];
// console.log("%cop", logStyle, "- other", ops);
}
}
}
/** update node with attrs and marks, may also change type */
addSetNodeMarkup() {
// first diff in document is supposed to be a node-change (in type and/or attributes)
// thus simply find the first change and apply a node change step, then recalculate the diff
// after updating the document
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
const start = toDoc.content.findDiffStart(fromDoc.content);
// @note start is the same (first) position for current and target document
const fromNode = fromDoc.nodeAt(start);
const toNode = toDoc.nodeAt(start);
if (start != null) {
// @note this completly updates all attributes in one step, by completely replacing node
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
try {
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
} catch (e) {
// if nodetypes differ, the updated node-type and contents might not be compatible
// with schema and requires a replace
if (nodeType && e.message.includes("Invalid content")) {
// @todo add test-case for this scenario
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
} else {
throw e;
}
}
this.currentJSON = removeMarks(this.tr.doc).toJSON();
// setting the node markup may have invalidated the following ops, so we calculate them again.
this.ops = createPatch(this.currentJSON, this.finalJSON);
return true;
}
return false;
}
recreateChangeMarkSteps() {
// Now the documents should be the same, except their marks, so everything should map 1:1.
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
this.toDoc.descendants((tNode, tPos) => {
if (!tNode.isInline) {
return true;
}
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
if (!fNode.isInline) {
return true;
}
const from = Math.max(tPos, fPos);
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
fNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(tNode.marks)) {
this.tr.removeMark(from, to, nodeMark);
}
});
tNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(fNode.marks)) {
this.tr.addMark(from, to, nodeMark);
}
});
});
});
}
/**
* retrieve and possibly apply replace-step based from doc changes
* From http://prosemirror.net/examples/footnote/
*/
addReplaceStep(toDoc: Node, afterStepJSON: AnyObject) {
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const step = getReplaceStep(fromDoc, toDoc);
if (!step) {
return false;
} else if (!this.tr.maybeStep(step).failed) {
this.currentJSON = afterStepJSON;
return true; // @change previously null
}
throw new Error("No valid step found.");
}
/** retrieve and possibly apply text replace-steps based from doc changes */
addReplaceTextSteps(op, afterStepJSON) {
// We find the position number of the first character in the string
const op1 = { ...op, value: "xx" };
const op2 = { ...op, value: "yy" };
const afterOP1JSON = copy(this.currentJSON);
const afterOP2JSON = copy(this.currentJSON);
applyPatch(afterOP1JSON, [op1]);
applyPatch(afterOP2JSON, [op2]);
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
// get text diffs
const finalText = op.value;
const currentText = getFromPath(this.currentJSON, op.path);
const textDiffs = this.wordDiffs
? diffWordsWithSpace(currentText, finalText)
: diffChars(currentText, finalText);
let offset = op1Doc.content.findDiffStart(op2Doc.content);
const marks = op1Doc.resolve(offset + 1).marks();
while (textDiffs.length) {
const diff = textDiffs.shift();
if (diff.added) {
const textNode = this.schema
.nodeFromJSON({ type: "text", text: diff.value })
.mark(marks);
if (textDiffs.length && textDiffs[0].removed) {
const nextDiff = textDiffs.shift();
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
} else {
this.tr.insert(offset, textNode);
}
offset += diff.value.length;
} else if (diff.removed) {
if (textDiffs.length && textDiffs[0].added) {
const nextDiff = textDiffs.shift();
const textNode = this.schema
.nodeFromJSON({ type: "text", text: nextDiff.value })
.mark(marks);
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
offset += nextDiff.value.length;
} else {
this.tr.delete(offset, offset + diff.value.length);
}
} else {
offset += diff.value.length;
}
}
this.currentJSON = afterStepJSON;
}
}
export function recreateTransform(
fromDoc: Node,
toDoc: Node,
options: Options = {},
): Transform {
const recreator = new RecreateTransform(fromDoc, toDoc, options);
return recreator.init();
}
@@ -0,0 +1,8 @@
import { Transform } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function removeMarks(doc: Node) {
const tr = new Transform(doc);
tr.removeMark(0, doc.nodeSize - 2);
return tr.doc;
}
@@ -0,0 +1,30 @@
import { Transform, ReplaceStep, Step } from "@tiptap/pm/transform";
import { getReplaceStep } from "./getReplaceStep";
// join adjacent ReplaceSteps
export function simplifyTransform(tr: Transform) {
if (!tr.steps.length) {
return undefined;
}
const newTr = new Transform(tr.docs[0]);
const oldSteps = tr.steps.slice();
while (oldSteps.length) {
let step = oldSteps.shift();
while (oldSteps.length && step.merge(oldSteps[0])) {
const addedStep = oldSteps.shift();
if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) {
step = getReplaceStep(
newTr.doc,
addedStep.apply(step.apply(newTr.doc).doc).doc,
// @ts-ignore
) as Step<any>;
} else {
step = step.merge(addedStep);
}
}
newTr.step(step);
}
return newTr;
}
@@ -0,0 +1,3 @@
export interface AnyObject {
[p: string]: any;
}
+18
View File
@@ -7,6 +7,7 @@ settings:
overrides:
jsdom: 25.0.1
jsonwebtoken: 9.0.3
prosemirror-changeset: 2.3.1
y-prosemirror: 1.3.7
patchedDependencies:
@@ -141,6 +142,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
diff:
specifier: 8.0.3
version: 8.0.3
dompurify:
specifier: ^3.2.6
version: 3.2.6
@@ -171,6 +175,9 @@ importers:
qrcode:
specifier: ^1.5.4
version: 1.5.4
rfc6902:
specifier: 5.1.2
version: 5.1.2
uuid:
specifier: ^11.1.0
version: 11.1.0
@@ -6110,6 +6117,10 @@ packages:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@@ -9056,6 +9067,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfc6902@5.1.2:
resolution: {integrity: sha512-zxcb+PWlE8PwX0tiKE6zP97THQ8/lHmeiwucRrJ3YFupWEmp25RmFSlB1dNTqjkovwqG4iq+u1gzJMBS3um8mA==}
rfdc@1.3.1:
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
@@ -16747,6 +16761,8 @@ snapshots:
diff@5.2.0: {}
diff@8.0.3: {}
dijkstrajs@1.0.3: {}
dingbat-to-unicode@1.0.1: {}
@@ -20318,6 +20334,8 @@ snapshots:
reusify@1.1.0: {}
rfc6902@5.1.2: {}
rfdc@1.3.1: {}
rimraf@3.0.2: