mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 23:33:09 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02447c1c48 | |||
| 9981d15794 |
@@ -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;
|
||||||
|
}
|
||||||
+8
-8
@@ -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 oldContent = Node.fromJSON(schema, previousContent);
|
||||||
|
const newContent = Node.fromJSON(schema, content);
|
||||||
|
|
||||||
addedCount = aCount;
|
const tr = recreateTransform(oldContent, newContent, {
|
||||||
deletedCount = dCount;
|
complexSteps: false,
|
||||||
|
wordDiffs: true,
|
||||||
|
simplifyDiff: true,
|
||||||
|
});
|
||||||
|
|
||||||
const decos = addedNodeRanges.map((r) =>
|
const changeSet = ChangeSet.create(oldContent).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, newContent);
|
||||||
|
|
||||||
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;
|
||||||
|
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||||
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||||
|
foundSpecialNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundSpecialNode) {
|
||||||
|
const nodeEnd =
|
||||||
|
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
|
||||||
|
decorations.push(
|
||||||
|
Decoration.node(foundSpecialNode.pos, nodeEnd, {
|
||||||
|
class: "history-diff-node-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(change.fromB, change.toB, {
|
||||||
|
class: "history-diff-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addedCount += 1;
|
||||||
|
}
|
||||||
|
if (change.toA > change.fromA) {
|
||||||
|
changeIndex++;
|
||||||
|
const currentIndex = changeIndex;
|
||||||
|
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||||
|
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||||
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||||
|
foundDeletedNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundDeletedNode) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "history-diff-node-deleted";
|
||||||
|
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
const serializer = DOMSerializer.fromSchema(schema);
|
||||||
|
const dom = serializer.serializeNode(foundDeletedNode!.node);
|
||||||
|
wrapper.appendChild(dom);
|
||||||
|
return wrapper;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const deletedText = oldContent.textBetween(
|
||||||
|
change.fromA,
|
||||||
|
change.toA,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
if (deletedText) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "history-diff-deleted";
|
||||||
|
span.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
span.textContent = deletedText;
|
||||||
|
return span;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedCount += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
decorationSet = DecorationSet.create(newContent, 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,13 +24,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: scope to workspaces
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export class PageHistoryService {
|
|||||||
constructor(private pageHistoryRepo: PageHistoryRepo) {}
|
constructor(private pageHistoryRepo: PageHistoryRepo) {}
|
||||||
|
|
||||||
async findById(historyId: string): Promise<PageHistory> {
|
async findById(historyId: string): Promise<PageHistory> {
|
||||||
return await this.pageHistoryRepo.findById(historyId);
|
return await this.pageHistoryRepo.findById(historyId, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findHistoryByPageId(
|
async findHistoryByPageId(
|
||||||
|
|||||||
@@ -17,15 +17,32 @@ import { DB } from '@docmost/db/types/db';
|
|||||||
export class PageHistoryRepo {
|
export class PageHistoryRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
private baseFields: Array<keyof PageHistory> = [
|
||||||
|
'id',
|
||||||
|
'pageId',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'coverPhoto',
|
||||||
|
'lastUpdatedById',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
pageHistoryId: string,
|
pageHistoryId: string,
|
||||||
trx?: KyselyTransaction,
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<PageHistory> {
|
): Promise<PageHistory> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('id', '=', pageHistoryId)
|
.where('id', '=', pageHistoryId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -63,7 +80,7 @@ export class PageHistoryRepo {
|
|||||||
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
@@ -76,12 +93,19 @@ export class PageHistoryRepo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
async findPageLastHistory(
|
||||||
const db = dbOrTx(this.db, trx);
|
pageId: string,
|
||||||
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
# prosemirror-changeset
|
|
||||||
|
|
||||||
This is a helper module that can turn a sequence of document changes
|
|
||||||
into a set of insertions and deletions, for example to display them in
|
|
||||||
a change-tracking interface. Such a set can be built up incrementally,
|
|
||||||
in order to do such change tracking in a halfway performant way during
|
|
||||||
live editing.
|
|
||||||
|
|
||||||
This code is licensed under an [MIT
|
|
||||||
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
|
|
||||||
|
|
||||||
## Programming interface
|
|
||||||
|
|
||||||
Insertions and deletions are represented as ‘spans’—ranges in the
|
|
||||||
document. The deleted spans refer to the original document, whereas
|
|
||||||
the inserted ones point into the current document.
|
|
||||||
|
|
||||||
It is possible to associate arbitrary data values with such spans, for
|
|
||||||
example to track the user that made the change, the timestamp at which
|
|
||||||
it was made, or the step data necessary to invert it again.
|
|
||||||
|
|
||||||
### class Change`<Data = any>`
|
|
||||||
|
|
||||||
A replaced range with metadata associated with it.
|
|
||||||
|
|
||||||
* **`fromA`**`: number`\
|
|
||||||
The start of the range deleted/replaced in the old document.
|
|
||||||
|
|
||||||
* **`toA`**`: number`\
|
|
||||||
The end of the range in the old document.
|
|
||||||
|
|
||||||
* **`fromB`**`: number`\
|
|
||||||
The start of the range inserted in the new document.
|
|
||||||
|
|
||||||
* **`toB`**`: number`\
|
|
||||||
The end of the range in the new document.
|
|
||||||
|
|
||||||
* **`deleted`**`: readonly Span[]`\
|
|
||||||
Data associated with the deleted content. The length of these
|
|
||||||
spans adds up to `this.toA - this.fromA`.
|
|
||||||
|
|
||||||
* **`inserted`**`: readonly Span[]`\
|
|
||||||
Data associated with the inserted content. Length adds up to
|
|
||||||
`this.toB - this.fromB`.
|
|
||||||
|
|
||||||
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
|
|
||||||
This merges two changesets (the end document of x should be the
|
|
||||||
start document of y) into a single one spanning the start of x to
|
|
||||||
the end of y.
|
|
||||||
|
|
||||||
|
|
||||||
### class Span`<Data = any>`
|
|
||||||
|
|
||||||
Stores metadata for a part of a change.
|
|
||||||
|
|
||||||
* **`length`**`: number`\
|
|
||||||
The length of this span.
|
|
||||||
|
|
||||||
* **`data`**`: Data`\
|
|
||||||
The data associated with this span.
|
|
||||||
|
|
||||||
|
|
||||||
### class ChangeSet`<Data = any>`
|
|
||||||
|
|
||||||
A change set tracks the changes to a document from a given point
|
|
||||||
in the past. It condenses a number of step maps down to a flat
|
|
||||||
sequence of replacements, and simplifies replacments that
|
|
||||||
partially undo themselves by comparing their content.
|
|
||||||
|
|
||||||
* **`changes`**`: readonly Change[]`\
|
|
||||||
Replaced regions.
|
|
||||||
|
|
||||||
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
|
|
||||||
Computes a new changeset by adding the given step maps and
|
|
||||||
metadata (either as an array, per-map, or as a single value to be
|
|
||||||
associated with all maps) to the current set. Will not mutate the
|
|
||||||
old set.
|
|
||||||
|
|
||||||
Note that due to simplification that happens after each add,
|
|
||||||
incrementally adding steps might create a different final set
|
|
||||||
than adding all those changes at once, since different document
|
|
||||||
tokens might be matched during simplification depending on the
|
|
||||||
boundaries of the current changed ranges.
|
|
||||||
|
|
||||||
* **`startDoc`**`: Node`\
|
|
||||||
The starting document of the change set.
|
|
||||||
|
|
||||||
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
|
|
||||||
Map the span's data values in the given set through a function
|
|
||||||
and construct a new set with the resulting data.
|
|
||||||
|
|
||||||
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
|
|
||||||
Compare two changesets and return the range in which they are
|
|
||||||
changed, if any. If the document changed between the maps, pass
|
|
||||||
the maps for the steps that changed it as second argument, and
|
|
||||||
make sure the method is called on the old set and passed the new
|
|
||||||
set. The returned positions will be in new document coordinates.
|
|
||||||
|
|
||||||
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
|
|
||||||
Create a changeset with the given base object and configuration.
|
|
||||||
|
|
||||||
The `combine` function is used to compare and combine metadata—it
|
|
||||||
should return null when metadata isn't compatible, and a combined
|
|
||||||
version for a merged range when it is.
|
|
||||||
|
|
||||||
When given, a token encoder determines how document tokens are
|
|
||||||
serialized and compared when diffing the content produced by
|
|
||||||
changes. The default is to just compare nodes by name and text
|
|
||||||
by character, ignoring marks and attributes.
|
|
||||||
|
|
||||||
|
|
||||||
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
|
|
||||||
Simplifies a set of changes for presentation. This makes the
|
|
||||||
assumption that having both insertions and deletions within a word
|
|
||||||
is confusing, and, when such changes occur without a word boundary
|
|
||||||
between them, they should be expanded to cover the entire set of
|
|
||||||
words (in the new document) they touch. An exception is made for
|
|
||||||
single-character replacements.
|
|
||||||
|
|
||||||
|
|
||||||
### interface TokenEncoder`<T>`
|
|
||||||
|
|
||||||
A token encoder can be passed when creating a `ChangeSet` in order
|
|
||||||
to influence the way the library runs its diffing algorithm. The
|
|
||||||
encoder determines how document tokens (such as nodes and
|
|
||||||
characters) are encoded and compared.
|
|
||||||
|
|
||||||
Note that both the encoding and the comparison may run a lot, and
|
|
||||||
doing non-trivial work in these functions could impact
|
|
||||||
performance.
|
|
||||||
|
|
||||||
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
|
|
||||||
Encode a given character, with the given marks applied.
|
|
||||||
|
|
||||||
* **`encodeNodeStart`**`(node: Node) → T`\
|
|
||||||
Encode the start of a node or, if this is a leaf node, the
|
|
||||||
entire node.
|
|
||||||
|
|
||||||
* **`encodeNodeEnd`**`(node: Node) → T`\
|
|
||||||
Encode the end token for the given node. It is valid to encode
|
|
||||||
every end token in the same way.
|
|
||||||
|
|
||||||
* **`compareTokens`**`(a: T, b: T) → boolean`\
|
|
||||||
Compare the given tokens. Should return true when they count as
|
|
||||||
equal.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps
|
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
||||||
// https://github.com/sueddeutsche/prosemirror-recreate-transform
|
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
||||||
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
||||||
export type { Options } from "./recreateTransform";
|
export type { Options } from "./recreateTransform";
|
||||||
|
|||||||
Generated
+8
-11
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user