mirror of
https://github.com/docmost/docmost.git
synced 2026-05-10 00:13:36 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a750744f1e | |||
| cb10cf155f | |||
| 9fc5f36bc2 | |||
| 974089c9f6 | |||
| 20aa40a9fd | |||
| a40b90d0d9 | |||
| 8cdfba7281 | |||
| 5739a0e260 | |||
| 9ec0f46eb6 | |||
| 8282403ee8 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -123,11 +123,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
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 highlightChangesAtom = atom<boolean>(true);
|
|
||||||
|
|
||||||
export type DiffCounts = { added: number; deleted: number; total: number };
|
|
||||||
export const diffCountsAtom = atom<DiffCounts | null>(null);
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
.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,204 +1,36 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import "./css/history-diff.module.css";
|
import React, { useEffect } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from "@mantine/core";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import classes from "./history.module.css";
|
||||||
import historyClasses from "./css/history.module.css";
|
|
||||||
import { recreateTransform } from "@docmost/editor-ext";
|
|
||||||
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
diffCountsAtom,
|
|
||||||
highlightChangesAtom,
|
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
|
||||||
|
|
||||||
export interface HistoryEditorProps {
|
export interface HistoryEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
previousContent?: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryEditor({
|
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||||
title,
|
|
||||||
content,
|
|
||||||
previousContent,
|
|
||||||
}: HistoryEditorProps) {
|
|
||||||
const [highlightChanges] = useAtom(highlightChangesAtom);
|
|
||||||
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: mainExtensions,
|
extensions: mainExtensions,
|
||||||
editable: false,
|
editable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !content) return;
|
if (editor && content) {
|
||||||
|
|
||||||
let decorationSet = DecorationSet.empty;
|
|
||||||
let addedCount = 0;
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
if (previousContent) {
|
|
||||||
try {
|
|
||||||
const schema = editor.schema;
|
|
||||||
const oldContent = Node.fromJSON(schema, previousContent);
|
|
||||||
const newContent = Node.fromJSON(schema, content);
|
|
||||||
|
|
||||||
const tr = recreateTransform(oldContent, newContent, {
|
|
||||||
complexSteps: false,
|
|
||||||
wordDiffs: true,
|
|
||||||
simplifyDiff: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const changeSet = ChangeSet.create(oldContent).addSteps(
|
|
||||||
tr.doc,
|
|
||||||
tr.mapping.maps,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const changes = simplifyChanges(changeSet.changes, newContent);
|
|
||||||
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decorationSet = DecorationSet.create(newContent, decorations);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("History diff failed:", e);
|
|
||||||
editor.commands.setContent(content);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor.commands.setContent(content);
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
<Title order={1}>{title}</Title>
|
<div>
|
||||||
{editor && (
|
<Title order={1}>{title}</Title>
|
||||||
<EditorContent
|
|
||||||
editor={editor}
|
{editor && (
|
||||||
className={historyClasses.historyEditor}
|
<EditorContent editor={editor} className={classes.historyEditor} />
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,20 @@
|
|||||||
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 "./css/history.module.css";
|
import classes from "./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: IPageHistory;
|
historyItem: any;
|
||||||
index: number;
|
onSelect: (id: string) => void;
|
||||||
onSelect: (id: string, index: number) => void;
|
|
||||||
onHover?: (id: string, index: number) => void;
|
|
||||||
onHoverEnd?: () => void;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
||||||
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={handleClick}
|
onClick={() => onSelect(historyItem.id)}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={onHoverEnd}
|
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -49,11 +27,11 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
<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>
|
||||||
@@ -61,6 +39,6 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export default HistoryItem;
|
export default HistoryItem;
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
prefetchPageHistory,
|
usePageHistoryQuery,
|
||||||
} 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 {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Button,
|
pageEditorAtom,
|
||||||
ScrollArea,
|
titleEditorAtom,
|
||||||
Group,
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
Divider,
|
import { modals } from "@mantine/modals";
|
||||||
Loader,
|
import { notifications } from "@mantine/notifications";
|
||||||
Center,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
const PREFETCH_DELAY_MS = 150;
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -30,89 +32,62 @@ 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 = useSetAtom(activeHistoryPrevIdAtom);
|
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pageHistoryData,
|
data: pageHistoryList,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
|
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||||
|
|
||||||
const historyItems = useMemo(
|
const [mainEditor] = useAtom(pageEditorAtom);
|
||||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
||||||
[pageHistoryData],
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
);
|
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const { spaceSlug } = useParams();
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
const { canRestore, confirmRestore } = useHistoryRestore();
|
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 clearPrefetchTimeout = useCallback(() => {
|
const handleRestore = useCallback(() => {
|
||||||
if (prefetchTimeoutRef.current) {
|
if (activeHistoryData) {
|
||||||
clearTimeout(prefetchTimeoutRef.current);
|
mainEditorTitle
|
||||||
prefetchTimeoutRef.current = null;
|
.chain()
|
||||||
|
.clearContent()
|
||||||
|
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||||
|
.run();
|
||||||
|
mainEditor
|
||||||
|
.chain()
|
||||||
|
.clearContent()
|
||||||
|
.setContent(activeHistoryData.content)
|
||||||
|
.run();
|
||||||
|
setHistoryModalOpen(false);
|
||||||
|
notifications.show({ message: t("Successfully restored") });
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeHistoryData]);
|
||||||
|
|
||||||
const 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(() => {
|
||||||
return clearPrefetchTimeout;
|
if (
|
||||||
}, [clearPrefetchTimeout]);
|
pageHistoryList &&
|
||||||
|
pageHistoryList.items.length > 0 &&
|
||||||
const handleSelect = useCallback(
|
!activeHistoryId
|
||||||
(id: string, index: number) => {
|
) {
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(pageHistoryList.items[0].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) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -122,45 +97,40 @@ function HistoryList({ pageId }: Props) {
|
|||||||
return <div>{t("Error loading page history.")}</div>;
|
return <div>{t("Error loading page history.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (historyItems.length === 0) {
|
if (!pageHistoryList || pageHistoryList.items.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}>
|
||||||
{historyItems.map((historyItem, index) => (
|
{pageHistoryList &&
|
||||||
<HistoryItem
|
pageHistoryList.items.map((historyItem, index) => (
|
||||||
key={historyItem.id}
|
<HistoryItem
|
||||||
historyItem={historyItem}
|
key={index}
|
||||||
index={index}
|
historyItem={historyItem}
|
||||||
onSelect={handleSelect}
|
onSelect={setActiveHistoryId}
|
||||||
onHover={handleHover}
|
isActive={historyItem.id === activeHistoryId}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
/>
|
||||||
isActive={historyItem.id === activeHistoryId}
|
))}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
|
||||||
{isFetchingNextPage && (
|
|
||||||
<Center py="sm">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{canRestore && (
|
{spaceAbility.cannot(
|
||||||
|
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("Close")}
|
{t("Cancel")}
|
||||||
</Button>
|
|
||||||
<Button size="compact-md" onClick={confirmRestore}>
|
|
||||||
{t("Restore")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import {
|
import { ScrollArea } from "@mantine/core";
|
||||||
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 "./css/history.module.css";
|
import classes from "./history.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||||
activeHistoryIdAtom,
|
|
||||||
activeHistoryPrevIdAtom,
|
|
||||||
diffCountsAtom,
|
|
||||||
highlightChangesAtom,
|
|
||||||
} 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 { useRef } from "react";
|
import { useEffect } 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 { t } = useTranslation();
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
useEffect(() => {
|
||||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
setActiveHistoryId("");
|
||||||
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}>
|
||||||
@@ -49,63 +25,11 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ position: "relative", flex: 1 }}>
|
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||||
<ScrollArea
|
<div className={classes.sidebarRightSection}>
|
||||||
h={650}
|
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
||||||
w="100%"
|
</div>
|
||||||
scrollbarSize={5}
|
</ScrollArea>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
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,26 +2,21 @@ 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)");
|
|
||||||
|
|
||||||
if (isMobile) {
|
return (
|
||||||
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" }}>
|
||||||
@@ -33,37 +28,11 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
|||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body
|
<Modal.Body>
|
||||||
p={0}
|
<HistoryModalBody pageId={pageId} />
|
||||||
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,44 +1,29 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
function HistoryView() {
|
interface HistoryProps {
|
||||||
|
historyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryView({ historyId }: HistoryProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const historyId = useAtomValue(activeHistoryIdAtom);
|
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
||||||
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
|
||||||
|
|
||||||
const {
|
if (isLoading) {
|
||||||
data,
|
|
||||||
isLoading: isLoadingCurrent,
|
|
||||||
isError: isErrorCurrent,
|
|
||||||
} = usePageHistoryQuery(historyId);
|
|
||||||
const {
|
|
||||||
data: prevData,
|
|
||||||
isLoading: isLoadingPrev,
|
|
||||||
isError: isErrorPrev,
|
|
||||||
} = usePageHistoryQuery(prevHistoryId);
|
|
||||||
|
|
||||||
if (isLoadingCurrent || isLoadingPrev) {
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isErrorCurrent || !data) {
|
if (isError || !data) {
|
||||||
return <div>{t("Error fetching page data.")}</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
data && (
|
||||||
<HistoryEditor
|
<div>
|
||||||
content={data.content}
|
<HistoryEditor content={data.content} title={data.title} />
|
||||||
title={data.title}
|
</div>
|
||||||
previousContent={!isErrorPrev ? prevData?.content : undefined}
|
)
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { useDiffNavigation } from "./use-diff-navigation";
|
|
||||||
export { useHistoryRestore } from "./use-history-restore";
|
|
||||||
export { useHistoryReset } from "./use-history-reset";
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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,38 +1,19 @@
|
|||||||
import {
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
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,
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
): UseQueryResult<IPagination<IPageHistory>, Error> {
|
||||||
return useInfiniteQuery({
|
return useQuery({
|
||||||
queryKey: ["page-history-list", pageId],
|
queryKey: ["page-history-list", pageId],
|
||||||
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
|
queryFn: () => getPageHistoryList(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
initialPageParam: undefined,
|
|
||||||
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +24,6 @@ export function usePageHistoryQuery(
|
|||||||
queryKey: ["page-history", historyId],
|
queryKey: ["page-history", historyId],
|
||||||
queryFn: () => getPageHistoryById(historyId),
|
queryFn: () => getPageHistoryById(historyId),
|
||||||
enabled: !!historyId,
|
enabled: !!historyId,
|
||||||
staleTime: HISTORY_STALE_TIME,
|
staleTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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;
|
||||||
@@ -13,10 +12,7 @@ export class UpdatedPageEvent {
|
|||||||
export class HistoryListener {
|
export class HistoryListener {
|
||||||
private readonly logger = new Logger(HistoryListener.name);
|
private readonly logger = new Logger(HistoryListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
|
||||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@OnEvent('collab.page.updated')
|
@OnEvent('collab.page.updated')
|
||||||
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
||||||
@@ -24,17 +20,13 @@ 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 = this.environmentService.isDevelopment()
|
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||||
? 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 ||
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ async function bootstrap() {
|
|||||||
const logger = new Logger('CollabServer');
|
const logger = new Logger('CollabServer');
|
||||||
|
|
||||||
const port = process.env.COLLAB_PORT || 3001;
|
const port = process.env.COLLAB_PORT || 3001;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
await app.listen(port, '0.0.0.0', () => {
|
||||||
await app.listen(port, host, () => {
|
|
||||||
logger.log(`Listening on http://127.0.0.1:${port}`);
|
logger.log(`Listening on http://127.0.0.1:${port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: scope to workspaces
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ 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,32 +17,15 @@ 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,
|
||||||
opts?: {
|
trx?: KyselyTransaction,
|
||||||
includeContent?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
|
||||||
},
|
|
||||||
): Promise<PageHistory> {
|
): Promise<PageHistory> {
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.select(this.baseFields)
|
.selectAll()
|
||||||
.$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();
|
||||||
@@ -80,7 +63,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')
|
||||||
.select(this.baseFields)
|
.selectAll()
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
@@ -93,19 +76,12 @@ export class PageHistoryRepo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageLastHistory(
|
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
||||||
pageId: string,
|
const db = dbOrTx(this.db, trx);
|
||||||
opts?: {
|
|
||||||
includeContent?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.select(this.baseFields)
|
.selectAll()
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...b363767b69
@@ -104,8 +104,7 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
await app.listen(port, '0.0.0.0', () => {
|
||||||
await app.listen(port, host, () => {
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -60,7 +60,6 @@
|
|||||||
"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",
|
||||||
@@ -71,7 +70,6 @@
|
|||||||
"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",
|
||||||
@@ -100,7 +98,6 @@
|
|||||||
"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": []
|
||||||
|
|||||||
@@ -8,6 +8,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": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,3 @@ export * from "./lib/highlight";
|
|||||||
export * from "./lib/heading/heading";
|
export * from "./lib/heading/heading";
|
||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
export * from "./lib/recreate-transform";
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function copy<T>(value: T): T {
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
|
||||||
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
|
||||||
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
|
||||||
export type { Options } from "./recreateTransform";
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface AnyObject {
|
|
||||||
[p: string]: any;
|
|
||||||
}
|
|
||||||
@@ -197,15 +197,11 @@ const replace = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete the old text
|
|
||||||
tr.delete(from, to);
|
|
||||||
|
|
||||||
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
|
// Delete the old text and insert new text with preserved marks
|
||||||
if (replaceTerm) {
|
tr.delete(from, to);
|
||||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
tr.insert(from, state.schema.text(replaceTerm, marks));
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -232,14 +228,10 @@ const replaceAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete the old text
|
|
||||||
tr.delete(from, to);
|
|
||||||
|
|
||||||
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
|
// Delete and insert with preserved marks
|
||||||
if (replaceTerm) {
|
tr.delete(from, to);
|
||||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
|
|||||||
Generated
-18
@@ -7,7 +7,6 @@ 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:
|
||||||
@@ -142,9 +141,6 @@ 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
|
||||||
@@ -175,9 +171,6 @@ 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
|
||||||
@@ -6117,10 +6110,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
diff@8.0.3:
|
|
||||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
|
||||||
engines: {node: '>=0.3.1'}
|
|
||||||
|
|
||||||
dijkstrajs@1.0.3:
|
dijkstrajs@1.0.3:
|
||||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
@@ -9067,9 +9056,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rfc6902@5.1.2:
|
|
||||||
resolution: {integrity: sha512-zxcb+PWlE8PwX0tiKE6zP97THQ8/lHmeiwucRrJ3YFupWEmp25RmFSlB1dNTqjkovwqG4iq+u1gzJMBS3um8mA==}
|
|
||||||
|
|
||||||
rfdc@1.3.1:
|
rfdc@1.3.1:
|
||||||
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
|
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
|
||||||
|
|
||||||
@@ -16761,8 +16747,6 @@ snapshots:
|
|||||||
|
|
||||||
diff@5.2.0: {}
|
diff@5.2.0: {}
|
||||||
|
|
||||||
diff@8.0.3: {}
|
|
||||||
|
|
||||||
dijkstrajs@1.0.3: {}
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1: {}
|
dingbat-to-unicode@1.0.1: {}
|
||||||
@@ -20334,8 +20318,6 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rfc6902@5.1.2: {}
|
|
||||||
|
|
||||||
rfdc@1.3.1: {}
|
rfdc@1.3.1: {}
|
||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user