mirror of
https://github.com/docmost/docmost.git
synced 2026-05-24 03:02:42 +08:00
WIP
This commit is contained in:
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
|
|||||||
export const showCommentPopupAtom = atom<boolean>(false);
|
export const showCommentPopupAtom = atom<boolean>(false);
|
||||||
export const activeCommentIdAtom = atom<string>('');
|
export const activeCommentIdAtom = atom<string>('');
|
||||||
export const draftCommentIdAtom = atom<string>('');
|
export const draftCommentIdAtom = atom<string>('');
|
||||||
|
|
||||||
|
// Read-only comment state
|
||||||
|
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
||||||
|
export type YjsSelection = {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
export type ReadOnlyCommentData = {
|
||||||
|
yjsSelection: YjsSelection;
|
||||||
|
selectedText: string;
|
||||||
|
};
|
||||||
|
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
|
createReadOnlyComment,
|
||||||
|
CreateReadOnlyCommentData,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -106,4 +108,23 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateReadOnlyCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
|
||||||
|
mutationFn: (data) => createReadOnlyComment(data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
||||||
|
notifications.show({ message: t("Comment created successfully") });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Error creating comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
|
|||||||
@@ -40,3 +40,20 @@ export async function getPageComments(
|
|||||||
export async function deleteComment(commentId: string): Promise<void> {
|
export async function deleteComment(commentId: string): Promise<void> {
|
||||||
await api.post("/comments/delete", { commentId });
|
await api.post("/comments/delete", { commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateReadOnlyCommentData = {
|
||||||
|
pageId: string;
|
||||||
|
content: string;
|
||||||
|
selection?: string;
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createReadOnlyComment(
|
||||||
|
data: CreateReadOnlyCommentData,
|
||||||
|
): Promise<IComment> {
|
||||||
|
const req = await api.post<IComment>("/comments/create-readonly", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
BubbleMenu,
|
||||||
|
isNodeSelection,
|
||||||
|
isTextSelection,
|
||||||
|
useEditor,
|
||||||
|
} from "@tiptap/react";
|
||||||
|
import { FC, useEffect, useRef } from "react";
|
||||||
|
import { IconMessage } from "@tabler/icons-react";
|
||||||
|
import classes from "./bubble-menu.module.css";
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ySyncPluginKey } from "y-prosemirror";
|
||||||
|
import { getRelativeSelection } from "y-prosemirror";
|
||||||
|
|
||||||
|
type ReadOnlyBubbleMenuProps = {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
);
|
||||||
|
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
||||||
|
const showPopupRef = useRef(showReadOnlyCommentPopup);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showPopupRef.current = showReadOnlyCommentPopup;
|
||||||
|
}, [showReadOnlyCommentPopup]);
|
||||||
|
|
||||||
|
const handleCommentClick = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const view = editor.view;
|
||||||
|
const ystate = ySyncPluginKey.getState(view.state);
|
||||||
|
|
||||||
|
if (ystate?.binding) {
|
||||||
|
const selection = getRelativeSelection(ystate.binding, view.state);
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
const selectedText = editor.state.doc.textBetween(from, to);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
setReadOnlyCommentData({
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: selection.anchor,
|
||||||
|
head: selection.head,
|
||||||
|
},
|
||||||
|
selectedText,
|
||||||
|
});
|
||||||
|
setShowReadOnlyCommentPopup(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if editor is not available or is editable
|
||||||
|
if (!editor || editor.isEditable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="readonly"
|
||||||
|
shouldShow={({ state, editor }) => {
|
||||||
|
// Safety check - don't show if editor became editable
|
||||||
|
if (!editor || editor.isEditable || editor.isDestroyed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selection } = state;
|
||||||
|
const { empty, from, to } = selection;
|
||||||
|
|
||||||
|
if (
|
||||||
|
editor.isActive("image") ||
|
||||||
|
empty ||
|
||||||
|
isNodeSelection(selection) ||
|
||||||
|
isCellSelection(selection) ||
|
||||||
|
showPopupRef?.current
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if actual text is selected (not just empty block)
|
||||||
|
const hasText = state.doc.textBetween(from, to).length > 0;
|
||||||
|
return isTextSelection(selection) && hasText;
|
||||||
|
}}
|
||||||
|
tippyOptions={{
|
||||||
|
moveTransition: "transform 0.15s ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={classes.bubbleMenu}>
|
||||||
|
<Tooltip label={t("Comment")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="0"
|
||||||
|
aria-label={t("Comment")}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
onClick={handleCommentClick}
|
||||||
|
>
|
||||||
|
<IconMessage size={16} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Dialog, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useClickOutside } from "@mantine/hooks";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
activeCommentIdAtom,
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
|
||||||
|
type ReadOnlyCommentDialogProps = {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
|
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
|
const useClickOutsideRef = useClickOutside(() => {
|
||||||
|
handleDialogClose();
|
||||||
|
});
|
||||||
|
const createCommentMutation = useCreateReadOnlyCommentMutation();
|
||||||
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setShowReadOnlyCommentPopup(false);
|
||||||
|
setReadOnlyCommentData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!readOnlyCommentData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentData = {
|
||||||
|
pageId: pageId,
|
||||||
|
content: JSON.stringify(comment),
|
||||||
|
selection: readOnlyCommentData.selectedText,
|
||||||
|
yjsSelection: readOnlyCommentData.yjsSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdComment =
|
||||||
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
|
||||||
|
setActiveCommentId(createdComment.id);
|
||||||
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||||
|
const commentElement = document.querySelector(selector);
|
||||||
|
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
operation: "invalidateComment",
|
||||||
|
pageId: pageId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setShowReadOnlyCommentPopup(false);
|
||||||
|
setReadOnlyCommentData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentEditorChange = (newContent: any) => {
|
||||||
|
setComment(newContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
opened={true}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
ref={useClickOutsideRef}
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
w={300}
|
||||||
|
position={{ bottom: 500, right: 50 }}
|
||||||
|
withCloseButton
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group>
|
||||||
|
<CustomAvatar
|
||||||
|
size="sm"
|
||||||
|
avatarUrl={currentUser.user.avatarUrl}
|
||||||
|
name={currentUser.user.name}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{currentUser.user.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<CommentEditor
|
||||||
|
onUpdate={handleCommentEditorChange}
|
||||||
|
onSave={handleAddComment}
|
||||||
|
placeholder={t("Write a comment")}
|
||||||
|
editable={true}
|
||||||
|
autofocus={true}
|
||||||
|
/>
|
||||||
|
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadOnlyCommentDialog;
|
||||||
@@ -28,9 +28,12 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||||
|
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
|
||||||
|
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
|
||||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
@@ -84,6 +87,7 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
|
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const ydocRef = useRef<Y.Doc | null>(null);
|
||||||
if (!ydocRef.current) {
|
if (!ydocRef.current) {
|
||||||
ydocRef.current = new Y.Doc();
|
ydocRef.current = new Y.Doc();
|
||||||
@@ -429,7 +433,13 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editor && !editorIsEditable && (
|
||||||
|
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
|
||||||
|
)}
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
|
{showReadOnlyCommentPopup && (
|
||||||
|
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
|||||||
@@ -67,4 +67,8 @@ export class CollaborationGateway {
|
|||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
await this.hocuspocus.destroy();
|
await this.hocuspocus.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openDirectConnection(documentName: string) {
|
||||||
|
return this.hocuspocus.openDirectConnection(documentName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
|
import { EditorState, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import {
|
||||||
|
initProseMirrorDoc,
|
||||||
|
relativePositionToAbsolutePosition,
|
||||||
|
updateYFragment,
|
||||||
|
} from 'y-prosemirror';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Document } from '@hocuspocus/server';
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
import { TaskList } from '@tiptap/extension-task-list';
|
import { TaskList } from '@tiptap/extension-task-list';
|
||||||
import { TaskItem } from '@tiptap/extension-task-item';
|
import { TaskItem } from '@tiptap/extension-task-item';
|
||||||
@@ -116,3 +124,96 @@ export function jsonToNode(tiptapJson: JSONContent) {
|
|||||||
export function getPageId(documentName: string) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
return documentName.split('.')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type YjsSelection = {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setYjsMark(
|
||||||
|
doc: Document,
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
yjsSelection: YjsSelection,
|
||||||
|
markName: string,
|
||||||
|
markAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const schema = getSchema(tiptapExtensions);
|
||||||
|
const { doc: pNode, mapping } = initProseMirrorDoc(fragment, schema);
|
||||||
|
|
||||||
|
// Convert JSON positions to Y.js RelativePosition objects
|
||||||
|
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
||||||
|
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
||||||
|
|
||||||
|
console.log(anchorRelPos, headRelPos);
|
||||||
|
|
||||||
|
const anchor = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
anchorRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
const head = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
headRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('second')
|
||||||
|
console.log(anchor, head);
|
||||||
|
|
||||||
|
if (anchor === null || head === null) {
|
||||||
|
throw new Error('Could not resolve Y.js relative positions to absolute positions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: pNode,
|
||||||
|
schema: schema,
|
||||||
|
selection: TextSelection.create(pNode, anchor, head),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tr = setMarkInProsemirror(schema.marks[markName], markAttributes, state);
|
||||||
|
|
||||||
|
// Update the Y.js fragment with the modified ProseMirror document
|
||||||
|
// @ts-ignore
|
||||||
|
updateYFragment(doc, fragment, tr.doc, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMarkInProsemirror(
|
||||||
|
type: any,
|
||||||
|
attributes: Record<string, any>,
|
||||||
|
state: EditorState,
|
||||||
|
) {
|
||||||
|
let tr = state.tr;
|
||||||
|
const { selection } = state;
|
||||||
|
const { ranges } = selection;
|
||||||
|
|
||||||
|
ranges.forEach((range) => {
|
||||||
|
const from = range.$from.pos;
|
||||||
|
const to = range.$to.pos;
|
||||||
|
|
||||||
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
const trimmedFrom = Math.max(pos, from);
|
||||||
|
const trimmedTo = Math.min(pos + node.nodeSize, to);
|
||||||
|
const someHasMark = node.marks.find((mark) => mark.type === type);
|
||||||
|
|
||||||
|
if (someHasMark) {
|
||||||
|
node.marks.forEach((mark) => {
|
||||||
|
if (type === mark.type) {
|
||||||
|
tr = tr.addMark(
|
||||||
|
trimmedFrom,
|
||||||
|
trimmedTo,
|
||||||
|
type.create({
|
||||||
|
...mark.attrs,
|
||||||
|
...attributes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tr = tr.addMark(trimmedFrom, trimmedTo, type.create(attributes));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
lastUpdatedById: context.user.id,
|
lastUpdatedById: context?.user?.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user.id;
|
const userId = data.context?.user?.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
if (!this.contributors.has(documentName)) {
|
if (!this.contributors.has(documentName)) {
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||||
|
import test from "ava";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
|
||||||
|
|
||||||
|
test("direct connection prevents document from being removed from memory", async (t) => {
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
await server.openDirectConnection("hocuspocus-test");
|
||||||
|
|
||||||
|
const provider = newHocuspocusProvider(server, {
|
||||||
|
onSynced() {
|
||||||
|
provider.configuration.websocketProvider.destroy();
|
||||||
|
provider.destroy();
|
||||||
|
|
||||||
|
sleep(server.configuration.debounce + 50).then(() => {
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
resolve("done");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("direct connection works even if provider is connected", async (t) => {
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
const provider = newHocuspocusProvider(server, {
|
||||||
|
onSynced() {
|
||||||
|
provider.document.getMap("config").set("a", "valueFromProvider");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const directConnection =
|
||||||
|
await server.openDirectConnection("hocuspocus-test");
|
||||||
|
await directConnection.transact((doc) => {
|
||||||
|
t.is("valueFromProvider", String(doc.getMap("config").get("a")));
|
||||||
|
doc.getMap("config").set("b", "valueFromServerDirectConnection");
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
t.is(
|
||||||
|
"valueFromServerDirectConnection",
|
||||||
|
String(provider.document.getMap("config").get("b")),
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve(1);
|
||||||
|
t.pass();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct connection can apply yjsUpdate", async (t) => {
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
const provider = newHocuspocusProvider(server);
|
||||||
|
|
||||||
|
t.is("", provider.document.getXmlFragment("default").toJSON());
|
||||||
|
|
||||||
|
const directConnection =
|
||||||
|
await server.openDirectConnection("hocuspocus-test");
|
||||||
|
await directConnection.transact((doc) => {
|
||||||
|
Y.applyUpdate(
|
||||||
|
doc,
|
||||||
|
Y.encodeStateAsUpdate(
|
||||||
|
TiptapTransformer.toYdoc({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Example Paragraph",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
"<paragraph>Example Paragraph</paragraph>",
|
||||||
|
provider.document.getXmlFragment("default").toJSON(),
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve(1);
|
||||||
|
t.pass();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct connection can transact", async (t) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||||
|
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(direct.document?.getArray("test").toJSON()[0], "value");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct connection cannot transact once closed", async (t) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||||
|
await direct.disconnect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
});
|
||||||
|
t.fail(
|
||||||
|
"DirectConnection should throw an error when transacting on closed connection",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === "direct connection closed") {
|
||||||
|
t.pass();
|
||||||
|
} else {
|
||||||
|
t.fail("unknown error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("if a direct connection closes, the document should be unloaded if there is no other connection left", async (t) => {
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus();
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test1");
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await direct.disconnect();
|
||||||
|
|
||||||
|
t.is(server.getConnectionsCount(), 0);
|
||||||
|
t.is(server.getDocumentsCount(), 0);
|
||||||
|
resolve("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct connection transact awaits until onStoreDocument has finished", async (t) => {
|
||||||
|
let onStoreDocumentFinished = false;
|
||||||
|
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus({
|
||||||
|
onStoreDocument: async () => {
|
||||||
|
onStoreDocumentFinished = false;
|
||||||
|
await sleep(200);
|
||||||
|
onStoreDocumentFinished = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test2");
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
|
||||||
|
t.is(onStoreDocumentFinished, false);
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await direct.disconnect();
|
||||||
|
t.is(onStoreDocumentFinished, true);
|
||||||
|
|
||||||
|
t.is(server.getConnectionsCount(), 0);
|
||||||
|
t.is(server.getDocumentsCount(), 0);
|
||||||
|
t.is(onStoreDocumentFinished, true);
|
||||||
|
resolve("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct connection transact awaits until onStoreDocument has finished, even if unloadImmediately=false", async (t) => {
|
||||||
|
let onStoreDocumentFinished = false;
|
||||||
|
let directConnDisconnecting = false;
|
||||||
|
let storedAfterDisconnect = false;
|
||||||
|
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus({
|
||||||
|
unloadImmediately: false,
|
||||||
|
onStoreDocument: async () => {
|
||||||
|
onStoreDocumentFinished = false;
|
||||||
|
await sleep(200);
|
||||||
|
onStoreDocumentFinished = true;
|
||||||
|
|
||||||
|
if (directConnDisconnecting) {
|
||||||
|
storedAfterDisconnect = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterUnloadDocument: async (data) => {
|
||||||
|
if (!storedAfterDisconnect) {
|
||||||
|
t.fail("this shouldnt be called");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
|
||||||
|
t.is(onStoreDocumentFinished, false);
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = newHocuspocusProvider(server);
|
||||||
|
provider.document.getMap("aaa").set("bb", "b");
|
||||||
|
provider.disconnect();
|
||||||
|
provider.configuration.websocketProvider.disconnect();
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
directConnDisconnecting = true;
|
||||||
|
await direct.disconnect();
|
||||||
|
t.is(onStoreDocumentFinished, true);
|
||||||
|
|
||||||
|
t.is(server.getConnectionsCount(), 0);
|
||||||
|
|
||||||
|
t.is(storedAfterDisconnect, true);
|
||||||
|
|
||||||
|
resolve("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not unload document if an earlierly started onStoreDocument is still running", async (t) => {
|
||||||
|
let onStoreDocumentStarted = 0;
|
||||||
|
let onStoreDocumentFinished = 0;
|
||||||
|
|
||||||
|
const server = await newHocuspocus({
|
||||||
|
unloadImmediately: false,
|
||||||
|
debounce: 100,
|
||||||
|
onStoreDocument: async () => {
|
||||||
|
onStoreDocumentStarted++;
|
||||||
|
if (onStoreDocumentStarted === 1) {
|
||||||
|
// Simulate a long running onStoreDocument for the first debounced save
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
onStoreDocumentFinished++;
|
||||||
|
},
|
||||||
|
afterUnloadDocument: async (data) => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a change, which will start a debounced onStoreDocument after 100ms
|
||||||
|
const provider = newHocuspocusProvider(server);
|
||||||
|
provider.document.getMap("aaa").set("bb", "b");
|
||||||
|
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
provider.on("synced", resolve);
|
||||||
|
|
||||||
|
if (!provider.unsyncedChanges) resolve("");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
|
||||||
|
// Wait for the debounced onStoreDocument to start
|
||||||
|
await sleep(110);
|
||||||
|
t.is(onStoreDocumentStarted, 1);
|
||||||
|
t.is(onStoreDocumentFinished, 0);
|
||||||
|
|
||||||
|
// Open direct connection to prevent document from being unloaded
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 2);
|
||||||
|
|
||||||
|
// Close the websocket client
|
||||||
|
provider.disconnect();
|
||||||
|
provider.configuration.websocketProvider.disconnect();
|
||||||
|
await sleep(50);
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
t.is(onStoreDocumentStarted, 1);
|
||||||
|
t.is(onStoreDocumentFinished, 0);
|
||||||
|
|
||||||
|
direct.disconnect();
|
||||||
|
await sleep(50);
|
||||||
|
// Another save must not start before the first one has finished
|
||||||
|
t.is(onStoreDocumentStarted, 1);
|
||||||
|
t.is(onStoreDocumentFinished, 0);
|
||||||
|
// Document must not be unloaded yet, because the first onStoreDocument is still running
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 0);
|
||||||
|
|
||||||
|
// Wait enough time to be sure the onStoreDocument has finished and ensure that the document was eventually unloaded
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// The second onStoreDocument triggered by direct.disconnect must have started and finished now
|
||||||
|
t.is(onStoreDocumentStarted, 2);
|
||||||
|
t.is(onStoreDocumentFinished, 2);
|
||||||
|
// The document must have been unloaded now as well
|
||||||
|
t.is(server.getDocumentsCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creating a websocket connection after transact but before debounce interval doesnt create different docs", async (t) => {
|
||||||
|
let onStoreDocumentFinished = false;
|
||||||
|
let disconnected = false;
|
||||||
|
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
const server = await newHocuspocus({
|
||||||
|
onStoreDocument: async () => {
|
||||||
|
onStoreDocumentFinished = false;
|
||||||
|
await sleep(200);
|
||||||
|
onStoreDocumentFinished = true;
|
||||||
|
},
|
||||||
|
async afterUnloadDocument(data) {
|
||||||
|
console.log("called");
|
||||||
|
if (disconnected) {
|
||||||
|
t.fail("must not be called");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||||
|
t.is(server.getDocumentsCount(), 1);
|
||||||
|
t.is(server.getConnectionsCount(), 1);
|
||||||
|
|
||||||
|
t.is(onStoreDocumentFinished, false);
|
||||||
|
await direct.transact((document) => {
|
||||||
|
document.transact(() => {
|
||||||
|
document.getArray("test").insert(0, ["value"]);
|
||||||
|
}, "testOrigin");
|
||||||
|
});
|
||||||
|
|
||||||
|
await direct.disconnect();
|
||||||
|
t.is(onStoreDocumentFinished, true);
|
||||||
|
disconnected = true;
|
||||||
|
|
||||||
|
t.is(server.getConnectionsCount(), 0);
|
||||||
|
t.is(server.getDocumentsCount(), 0);
|
||||||
|
t.is(onStoreDocumentFinished, true);
|
||||||
|
|
||||||
|
const provider = newHocuspocusProvider(server);
|
||||||
|
|
||||||
|
await sleep(server.configuration.debounce * 2);
|
||||||
|
|
||||||
|
resolve("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
@@ -62,6 +63,28 @@ export class CommentController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('create-readonly')
|
||||||
|
async createReadOnly(
|
||||||
|
@Body() createCommentDto: CreateReadOnlyCommentDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
||||||
|
if (!page || page.deletedAt) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentService.createReadOnlyComment(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
page,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
createCommentDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async findPageComments(
|
async findPageComments(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CommentController } from './comment.controller';
|
import { CommentController } from './comment.controller';
|
||||||
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [CollaborationModule],
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [CommentService],
|
providers: [CommentService],
|
||||||
exports: [CommentService],
|
exports: [CommentService],
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -12,13 +14,19 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
||||||
|
import { setYjsMark } from '../../collaboration/collaboration.util';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
|
private readonly logger = new Logger(CommentService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private collaborationGateway: CollaborationGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -105,4 +113,49 @@ export class CommentService {
|
|||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createReadOnlyComment(
|
||||||
|
opts: { userId: string; page: Page; workspaceId: string },
|
||||||
|
createCommentDto: CreateReadOnlyCommentDto,
|
||||||
|
): Promise<Comment> {
|
||||||
|
const { userId, page, workspaceId } = opts;
|
||||||
|
const commentContent = JSON.parse(createCommentDto.content);
|
||||||
|
|
||||||
|
const comment = await this.commentRepo.insertComment({
|
||||||
|
pageId: page.id,
|
||||||
|
content: commentContent,
|
||||||
|
selection: createCommentDto?.selection?.substring(0, 250),
|
||||||
|
type: 'inline',
|
||||||
|
creatorId: userId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentName = `page.${page.id}`;
|
||||||
|
const directConnection =
|
||||||
|
await this.collaborationGateway.openDirectConnection(documentName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await directConnection.transact((doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
setYjsMark(doc, fragment, createCommentDto.yjsSelection, 'comment', {
|
||||||
|
commentId: comment.id,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to apply comment mark for comment ${comment.id}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Failed to apply comment mark. Selection may have changed.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await directConnection.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsJSON, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateReadOnlyCommentDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsJSON()
|
||||||
|
content: any;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
selection: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
async createThread(options: {
|
||||||
|
initialComment: { body: CommentBody; metadata?: any };
|
||||||
|
metadata?: any;
|
||||||
|
}) {
|
||||||
|
const thread = await threadStore.createThread(options);
|
||||||
|
if (threadStore.addThreadToDocument) {
|
||||||
|
const view = editor.prosemirrorView!;
|
||||||
|
const pmSelection = view.state.selection;
|
||||||
|
const ystate = ySyncPluginKey.getState(view.state);
|
||||||
|
const selection = {
|
||||||
|
prosemirror: {
|
||||||
|
head: pmSelection.head,
|
||||||
|
anchor: pmSelection.anchor,
|
||||||
|
},
|
||||||
|
yjs: ystate
|
||||||
|
? getRelativeSelection(ystate.binding, view.state)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
await threadStore.addThreadToDocument({
|
||||||
|
threadId: thread.id,
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(editor as any)._tiptapEditor.commands.setMark(markType, {
|
||||||
|
orphan: false,
|
||||||
|
threadId: thread.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userStore,
|
||||||
|
commentEditorSchema,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
public addThreadToDocument = async (options: {
|
||||||
|
threadId: string;
|
||||||
|
selection: {
|
||||||
|
prosemirror: {
|
||||||
|
head: number;
|
||||||
|
anchor: number;
|
||||||
|
};
|
||||||
|
yjs: {
|
||||||
|
head: any;
|
||||||
|
anchor: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const { threadId, ...rest } = options;
|
||||||
|
return this.doRequest(`/${threadId}/addToDocument`, "POST", rest);
|
||||||
|
};
|
||||||
|
-----
|
||||||
|
// addToDocument
|
||||||
|
router.post("/:threadId/addToDocument", async (c) => {
|
||||||
|
const json = await c.req.json();
|
||||||
|
// TODO: you'd probably validate the request json here
|
||||||
|
|
||||||
|
const doc = c.get("document");
|
||||||
|
const fragment = doc.getXmlFragment("doc");
|
||||||
|
|
||||||
|
setMark(doc, fragment, json.selection.yjs, "comment", {
|
||||||
|
orphan: false,
|
||||||
|
threadId: c.req.param("threadId"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ message: "Thread added to document" });
|
||||||
|
});
|
||||||
|
----
|
||||||
|
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
||||||
|
import { Document } from "@hocuspocus/server";
|
||||||
|
import { EditorState, TextSelection } from "prosemirror-state";
|
||||||
|
import {
|
||||||
|
initProseMirrorDoc,
|
||||||
|
relativePositionToAbsolutePosition,
|
||||||
|
updateYFragment,
|
||||||
|
} from "y-prosemirror";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a mark in the yjs document based on a yjs selection
|
||||||
|
*/
|
||||||
|
export function setMark(
|
||||||
|
doc: Document,
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
},
|
||||||
|
markName: string,
|
||||||
|
markAttributes: any
|
||||||
|
) {
|
||||||
|
// needed to get the pmSchema
|
||||||
|
// if you use a BlockNote custom schema, make sure to pass it to the create options
|
||||||
|
const editor = ServerBlockNoteEditor.create();
|
||||||
|
|
||||||
|
// get the prosemirror document
|
||||||
|
const { doc: pNode, mapping } = initProseMirrorDoc(
|
||||||
|
fragment,
|
||||||
|
editor.editor.pmSchema as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// get the prosemirror positions based on the yjs positions
|
||||||
|
// we need to get this from yjs because other users might have made changes in between
|
||||||
|
const anchor = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
yjsSelection.anchor,
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
const head = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
yjsSelection.head,
|
||||||
|
mapping
|
||||||
|
);
|
||||||
|
|
||||||
|
// now, let's create the mark in the prosemirror document
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: pNode,
|
||||||
|
schema: editor.editor.pmSchema as any,
|
||||||
|
selection: TextSelection.create(pNode, anchor!, head!),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tr = setMarkInProsemirror(
|
||||||
|
editor.editor.pmSchema.marks[markName],
|
||||||
|
markAttributes,
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
// finally, update the yjs document with the new prosemirror document
|
||||||
|
updateYFragment(doc, fragment, tr.doc, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
// based on https://github.com/ueberdosis/tiptap/blob/f3258d9ee5fb7979102fe63434f6ea4120507311/packages/core/src/commands/setMark.ts#L66
|
||||||
|
export const setMarkInProsemirror = (
|
||||||
|
type: any,
|
||||||
|
attributes = {},
|
||||||
|
state: EditorState
|
||||||
|
) => {
|
||||||
|
let tr = state.tr;
|
||||||
|
const { selection } = state;
|
||||||
|
const { ranges } = selection;
|
||||||
|
|
||||||
|
ranges.forEach((range) => {
|
||||||
|
const from = range.$from.pos;
|
||||||
|
const to = range.$to.pos;
|
||||||
|
|
||||||
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
const trimmedFrom = Math.max(pos, from);
|
||||||
|
const trimmedTo = Math.min(pos + node.nodeSize, to);
|
||||||
|
const someHasMark = node.marks.find((mark) => mark.type === type);
|
||||||
|
|
||||||
|
// if there is already a mark of this type
|
||||||
|
// we know that we have to merge its attributes
|
||||||
|
// otherwise we add a fresh new mark
|
||||||
|
if (someHasMark) {
|
||||||
|
node.marks.forEach((mark) => {
|
||||||
|
if (type === mark.type) {
|
||||||
|
tr = tr.addMark(
|
||||||
|
trimmedFrom,
|
||||||
|
trimmedTo,
|
||||||
|
type.create({
|
||||||
|
...mark.attrs,
|
||||||
|
...attributes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tr = tr.addMark(trimmedFrom, trimmedTo, type.create(attributes));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tr;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user