mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 22:51:16 +10:00
feat: comments
* create comment * reply to comment thread * edit comment * delete comment * resolve comment
This commit is contained in:
@ -10,13 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "^2.7.1",
|
||||
"@mantine/core": "^7.1.5",
|
||||
"@mantine/form": "^7.1.5",
|
||||
"@mantine/hooks": "^7.1.5",
|
||||
"@mantine/spotlight": "^7.1.5",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@mantine/core": "^7.2.1",
|
||||
"@mantine/form": "^7.2.1",
|
||||
"@mantine/hooks": "^7.2.1",
|
||||
"@mantine/modals": "^7.2.1",
|
||||
"@mantine/spotlight": "^7.2.1",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@tiptap/extension-code-block": "^2.1.12",
|
||||
"@tiptap/extension-collaboration": "^2.1.12",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||
@ -42,20 +42,21 @@
|
||||
"@tiptap/react": "^2.1.12",
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@tiptap/suggestion": "^2.1.12",
|
||||
"axios": "^1.5.1",
|
||||
"axios": "^1.6.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jotai": "^2.4.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"jotai": "^2.5.1",
|
||||
"jotai-optics": "^0.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.17.0",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^9.0.1",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.8",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
@ -64,7 +65,7 @@
|
||||
"@types/node": "20.8.6",
|
||||
"@types/react": "^18.2.29",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
|
||||
11
client/src/components/aside/aside.tsx
Normal file
11
client/src/components/aside/aside.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
const Comments = React.lazy(() => import('@/features/comment/comments'));
|
||||
|
||||
export default function Aside() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading comments...</div>}>
|
||||
<Comments />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
Group,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Button,
|
||||
@ -13,6 +12,7 @@ import {
|
||||
IconLock,
|
||||
IconShare,
|
||||
IconTrash,
|
||||
IconMessage,
|
||||
} from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
@ -23,6 +23,10 @@ export default function Header() {
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
<PageActionMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
}
|
||||
|
||||
.aside {
|
||||
background: var(--mantine-color-gray-light);
|
||||
|
||||
[data-layout='alt'] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { desktopAsideAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
|
||||
import { Navbar } from '@/components/navbar/navbar';
|
||||
import { ActionIcon, UnstyledButton, ActionIconGroup, AppShell, Avatar, Burger, Group } from '@mantine/core';
|
||||
@ -8,11 +8,13 @@ import { useAtom } from 'jotai';
|
||||
import classes from './shell.module.css';
|
||||
import Header from '@/components/layouts/header';
|
||||
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
||||
import Aside from '@/components/aside/aside';
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const [desktopAsideOpened] = useAtom(desktopAsideAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@ -23,7 +25,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopOpened } }}
|
||||
aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopAsideOpened } }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header
|
||||
@ -61,13 +63,12 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside className={classes.aside}>
|
||||
TODO
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper";
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
|
||||
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
|
||||
|
||||
export const desktopAsideAtom = atom(false);
|
||||
|
||||
17
client/src/features/comment/atoms/comment-atom.ts
Normal file
17
client/src/features/comment/atoms/comment-atom.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { IComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export const commentsAtom = atomFamily((pageId: string) => atom<IComment[]>([]));
|
||||
export const showCommentPopupAtom = atom(false);
|
||||
export const activeCommentIdAtom = atom<string | null>(null);
|
||||
export const draftCommentIdAtom = atom<string | null>(null);
|
||||
|
||||
export const deleteCommentAtom = atomFamily((pageId: string) => atom(
|
||||
null,
|
||||
(get, set, idToDelete: string) => {
|
||||
const currentPageComments = get(commentsAtom(pageId));
|
||||
const updatedComments = currentPageComments.filter(comment => comment.id !== idToDelete);
|
||||
set(commentsAtom(pageId), updatedComments);
|
||||
}
|
||||
));
|
||||
34
client/src/features/comment/comments.tsx
Normal file
34
client/src/features/comment/comments.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { getPageComments } from '@/features/comment/services/comment-service';
|
||||
import classes from '@/features/comment/components/comment.module.css';
|
||||
import { Text } from '@mantine/core';
|
||||
import CommentList from '@/features/comment/components/comment-list';
|
||||
|
||||
export default function Comments() {
|
||||
const { pageId } = useParams();
|
||||
const [comments, setComments] = useAtom(commentsAtom(pageId));
|
||||
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const response = await getPageComments(pageId);
|
||||
setComments(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchComments();
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<Text mb="md" fw={500}>Comments</Text>
|
||||
|
||||
<CommentList comments={comments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
client/src/features/comment/components/comment-actions.tsx
Normal file
16
client/src/features/comment/components/comment-actions.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
|
||||
type CommentActionsProps = {
|
||||
onSave: () => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
||||
return (
|
||||
<Group justify="flex-end" pt={2} wrap="nowrap">
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentActions;
|
||||
99
client/src/features/comment/components/comment-dialog.tsx
Normal file
99
client/src/features/comment/components/comment-dialog.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Dialog, Group, Stack, Text } from '@mantine/core';
|
||||
import { useClickOutside } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
commentsAtom,
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
} from '@/features/comment/atoms/comment-atom';
|
||||
import { Editor } from '@tiptap/core';
|
||||
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 useComment from '@/features/comment/hooks/use-comment';
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
}
|
||||
|
||||
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
||||
const [, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
|
||||
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
|
||||
const [comments, setComments] = useAtom(commentsAtom(pageId));
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const useClickOutsideRef = useClickOutside(() => {
|
||||
handleDialogClose();
|
||||
});
|
||||
const { createCommentMutation } = useComment();
|
||||
const { isLoading } = createCommentMutation;
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
const selectedText = getSelectedText();
|
||||
const commentData = {
|
||||
id: draftCommentId,
|
||||
pageId: pageId,
|
||||
content: JSON.stringify(comment),
|
||||
selection: selectedText,
|
||||
};
|
||||
|
||||
try {
|
||||
const createdComment = await createCommentMutation.mutateAsync(commentData);
|
||||
setComments(prevComments => [...prevComments, createdComment]);
|
||||
editor.chain().setComment(createdComment.id).unsetCommentDecoration().run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
setDraftCommentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentEditorChange = (newContent) => {
|
||||
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>
|
||||
<Avatar size="sm" color="blue">{currentUser.user.name.charAt(0)}</Avatar>
|
||||
<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} placeholder="Write a comment"
|
||||
editable={true} autofocus={true}
|
||||
/>
|
||||
<CommentActions onSave={handleAddComment} isLoading={isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentDialog;
|
||||
52
client/src/features/comment/components/comment-editor.tsx
Normal file
52
client/src/features/comment/components/comment-editor.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import React from 'react';
|
||||
import classes from './comment.module.css';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
}
|
||||
|
||||
function CommentEditor({ defaultContent, onUpdate, editable, placeholder, autofocus }: CommentEditorProps) {
|
||||
const { ref: focusRef, focused } = useFocusWithin();
|
||||
|
||||
const commentEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || 'Reply...',
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
],
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
autofocus: (autofocus && 'end') || false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={focusRef} className={classes.commentEditor}>
|
||||
<EditorContent editor={commentEditor}
|
||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default CommentEditor;
|
||||
111
client/src/features/comment/components/comment-list-item.tsx
Normal file
111
client/src/features/comment/components/comment-list-item.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Group, Avatar, Text, Box } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
import classes from './comment.module.css';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { deleteCommentAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { timeAgo } from '@/lib/time-ago';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import { editorAtom } from '@/features/editor/atoms/editorAtom';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import CommentMenu from '@/features/comment/components/comment-menu';
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import ResolveComment from '@/features/comment/components/resolve-comment';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
|
||||
function CommentListItem({ comment }) {
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const editor = useAtomValue(editorAtom);
|
||||
const [content, setContent] = useState(comment.content);
|
||||
const { updateCommentMutation, deleteCommentMutation } = useComment();
|
||||
const { isLoading } = updateCommentMutation;
|
||||
const [, deleteComment] = useAtom(deleteCommentAtom(comment.pageId));
|
||||
|
||||
async function handleUpdateComment() {
|
||||
const commentToUpdate = {
|
||||
id: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update comment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
deleteComment(comment.id); // Todo: unify code
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
{comment.creator.avatarUrl ? (
|
||||
<Avatar
|
||||
src={comment.creator.avatarUrl}
|
||||
alt={comment.creator.name}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>) : (
|
||||
<Avatar size="sm" color="blue">{comment.creator.name.charAt(0)}</Avatar>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{comment.creator.name}</Text>
|
||||
|
||||
<div style={{ visibility: hovered ? 'visible' : 'hidden' }}>
|
||||
{!comment.parentCommentId && (
|
||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||
)}
|
||||
|
||||
<CommentMenu commentId={comment.id}
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment} />
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection &&
|
||||
<Box className={classes.textSelection}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
!isEditing ?
|
||||
(<CommentEditor defaultContent={content} editable={false} />)
|
||||
:
|
||||
(<>
|
||||
<CommentEditor defaultContent={content} editable={true} onUpdate={(newContent) => setContent(newContent)}
|
||||
autofocus={true} />
|
||||
|
||||
<CommentActions onSave={handleUpdateComment} isLoading={isLoading} />
|
||||
</>)
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
96
client/src/features/comment/components/comment-list.tsx
Normal file
96
client/src/features/comment/components/comment-list.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { Divider, Paper, ScrollArea } from '@mantine/core';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import React, { useState } from 'react';
|
||||
import CommentListItem from '@/features/comment/components/comment-list-item';
|
||||
import { IComment } from '@/features/comment/types/comment.types';
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
|
||||
function CommentList({ comments }: IComment[]) {
|
||||
const { createCommentMutation } = useComment();
|
||||
const { isLoading } = createCommentMutation;
|
||||
const { pageId } = useParams();
|
||||
const [, setCommentsAtom] = useAtom(commentsAtom(pageId));
|
||||
|
||||
const getChildComments = (parentId) => {
|
||||
return comments.filter(comment => comment.parentCommentId === parentId);
|
||||
};
|
||||
|
||||
const renderChildComments = (parentId) => {
|
||||
const children = getChildComments(parentId);
|
||||
return (
|
||||
<div>
|
||||
{children.map(childComment => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
{renderChildComments(childComment.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState('');
|
||||
const { ref, focused } = useFocusWithin();
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(commentId, content);
|
||||
setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor onUpdate={setContent} editable={true} />
|
||||
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderComments = (comment) => {
|
||||
const handleAddReply = async (commentId, content) => {
|
||||
const commentData = {
|
||||
pageId: comment.pageId,
|
||||
parentCommentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
const createdComment = await createCommentMutation.mutateAsync(commentData);
|
||||
setCommentsAtom(prevComments => [...prevComments, createdComment]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder
|
||||
key={comment.id} data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
{renderChildComments(comment.id)}
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions onSave={handleAddReply} isLoading={isLoading} />
|
||||
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: '85vh' }} scrollbarSize={4} type="scroll">
|
||||
<div style={{ paddingBottom: '200px' }}>
|
||||
{comments
|
||||
.filter(comment => comment.parentCommentId === null)
|
||||
.map(comment => renderComments(comment))
|
||||
}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentList;
|
||||
45
client/src/features/comment/components/comment-menu.tsx
Normal file
45
client/src/features/comment/components/comment-menu.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
type CommentMenuProps = {
|
||||
onEditComment: () => void;
|
||||
onDeleteComment: () => void;
|
||||
};
|
||||
|
||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
|
||||
//@ts-ignore
|
||||
const openDeleteModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to delete this comment?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: onDeleteComment,
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onEditComment}
|
||||
leftSection={<IconEdit size={14} />}>
|
||||
Edit comment
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />}
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
Delete comment
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentMenu;
|
||||
45
client/src/features/comment/components/comment.module.css
Normal file
45
client/src/features/comment/components/comment.module.css
Normal file
@ -0,0 +1,45 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
49
client/src/features/comment/components/resolve-comment.tsx
Normal file
49
client/src/features/comment/components/resolve-comment.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||
const [, setComments] = useAtom(commentsAtom(pageId));
|
||||
const { resolveCommentMutation } = useComment();
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? 'green' : 'gray';
|
||||
|
||||
//@ts-ignore
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to resolve this comment thread?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Confirm', cancel: 'Cancel' },
|
||||
onConfirm: handleResolveToggle,
|
||||
});
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
const resolvedComment = await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
|
||||
//TODO: remove comment mark
|
||||
// Remove comment thread from state on resolve
|
||||
|
||||
setComments((oldComments) =>
|
||||
oldComments.map((comment) =>
|
||||
comment.id === commentId
|
||||
? { ...comment, resolvedAt: resolvedComment.resolvedAt, resolvedById: resolvedComment.resolvedById }
|
||||
: comment,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle resolved state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
|
||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
64
client/src/features/comment/hooks/use-comment.ts
Normal file
64
client/src/features/comment/hooks/use-comment.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
} from '@/features/comment/services/comment-service';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export default function useComment() {
|
||||
|
||||
const createMutation = useMutation(
|
||||
(data: Partial<IComment>) => createComment(data),
|
||||
{
|
||||
onSuccess: (data: IComment) => {
|
||||
toast.success('Comment created successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error creating comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updateMutation = useMutation(
|
||||
(data: Partial<IComment>) => updateComment(data),
|
||||
{
|
||||
onSuccess: (data: IComment) => {
|
||||
toast.success('Comment updated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error updating comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const resolveMutation = useMutation(
|
||||
(data: IResolveComment) => resolveComment(data),
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to perform resolve action: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
(id: string) => deleteComment(id),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Comment deleted successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error deleting comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
createCommentMutation: createMutation,
|
||||
updateCommentMutation: updateMutation,
|
||||
resolveCommentMutation: resolveMutation,
|
||||
deleteCommentMutation: deleteMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
31
client/src/features/comment/services/comment-service.ts
Normal file
31
client/src/features/comment/services/comment-service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export async function createComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/create', data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/resolve`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function updateComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/update`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getCommentById(id: string): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/view', { id });
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getPageComments(pageId: string): Promise<IComment[]> {
|
||||
const req = await api.post<IComment[]>('/comments', { pageId });
|
||||
return req.data as IComment[];
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
await api.post('/comments/delete', { id });
|
||||
}
|
||||
31
client/src/features/comment/types/comment.types.ts
Normal file
31
client/src/features/comment/types/comment.types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IUser } from '@/features/user/types/user.types';
|
||||
|
||||
export interface IComment {
|
||||
id: string;
|
||||
content: string;
|
||||
selection?: string;
|
||||
type?: string;
|
||||
creatorId: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
resolvedById?: string;
|
||||
resolvedAt?: Date;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
creator: IUser
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
id: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
content: any;
|
||||
selection?: string;
|
||||
}
|
||||
|
||||
export interface IResolveComment {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
19
client/src/features/comment/utils.ts
Normal file
19
client/src/features/comment/utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function scrollToComment(commentId) {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
if (commentElement) {
|
||||
commentElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollToCommentInScrollArea = (commentId, scrollAreaRef) => {
|
||||
const commentElement = scrollAreaRef.current.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
|
||||
if (commentElement) {
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
const commentTop = commentElement.offsetTop;
|
||||
const scrollAreaTop = scrollArea.offsetTop;
|
||||
|
||||
scrollArea.scrollTop = commentTop - scrollAreaTop;
|
||||
}
|
||||
};
|
||||
4
client/src/features/editor/atoms/editorAtom.ts
Normal file
4
client/src/features/editor/atoms/editorAtom.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { atom } from 'jotai';
|
||||
import { Editor } from '@tiptap/core';
|
||||
|
||||
export const editorAtom = atom<Editor | null>(null);
|
||||
@ -1,11 +1,14 @@
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from '@tiptap/react';
|
||||
import { FC, useState } from 'react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline } from '@tabler/icons-react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline, IconMessage } from '@tabler/icons-react';
|
||||
import clsx from 'clsx';
|
||||
import classes from './bubble-menu.module.css';
|
||||
import { ActionIcon, rem, Tooltip } from '@mantine/core';
|
||||
import { ColorSelector } from './color-selector';
|
||||
import { NodeSelector } from './node-selector';
|
||||
import { draftCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@ -17,6 +20,9 @@ export interface BubbleMenuItem {
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
||||
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
|
||||
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
@ -50,6 +56,20 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
name: 'comment',
|
||||
isActive: () => props.editor.isActive('comment'),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
setShowCommentPopup(true);
|
||||
},
|
||||
icon: IconMessage,
|
||||
};
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
@ -92,7 +112,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={item.name}>
|
||||
<Tooltip key={index} label={item.name} withArrow>
|
||||
|
||||
<ActionIcon key={index} variant="default" size="lg" radius="0" aria-label={item.name}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
@ -115,6 +135,15 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip label={commentItem.name} withArrow>
|
||||
|
||||
<ActionIcon variant="default" size="lg" radius="0" aria-label={commentItem.name}
|
||||
style={{ border: 'none' }}
|
||||
onClick={commentItem.command}>
|
||||
<IconMessage style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,40 +1,29 @@
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import classes from '@/features/editor/styles/editor.module.css';
|
||||
import '@/features/editor/styles/index.css';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { Comment } from '@/features/editor/extensions/comment/comment';
|
||||
import { desktopAsideAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import CommentDialog from '@/features/comment/components/comment-dialog';
|
||||
import { editorAtom } from '@/features/editor/atoms/editorAtom';
|
||||
import { collabExtensions, mainExtensions } from '@/features/editor/extensions';
|
||||
|
||||
interface EditorProps {
|
||||
pageId: string,
|
||||
@ -100,10 +89,14 @@ interface TiptapEditorProps {
|
||||
|
||||
function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(editorAtom);
|
||||
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState('');
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const { updatePageMutation } = usePage();
|
||||
const [desktopAsideOpened, setDesktopAsideOpened] = useAtom<boolean>(desktopAsideAtom);
|
||||
const [activeCommentId, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -133,46 +126,20 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== "") {
|
||||
if (debouncedTitle !== '') {
|
||||
updatePageMutation({ id: pageId, title: debouncedTitle });
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
...mainExtensions,
|
||||
...collabExtensions(ydoc, provider),
|
||||
Comment.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'comment-mark',
|
||||
},
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
|
||||
];
|
||||
|
||||
const editor = useEditor({
|
||||
@ -190,6 +157,11 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
},
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
setEditor(editor);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
const { selection } = editor.state;
|
||||
if (!selection.empty) {
|
||||
@ -225,6 +197,29 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
setActiveCommentId(commentId);
|
||||
setDesktopAsideOpened(true);
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
setDesktopAsideOpened(false);
|
||||
setShowCommentPopup(false);
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.editor}>
|
||||
@ -232,6 +227,10 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
61
client/src/features/editor/extensions.ts
Normal file
61
client/src/features/editor/extensions.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
];
|
||||
|
||||
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];
|
||||
|
||||
export const collabExtensions: CollabExtensions = (ydoc, provider) => [
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
@ -0,0 +1,35 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { commentDecorationMetaKey, commentMarkClass } from '@/features/editor/extensions/comment/comment';
|
||||
|
||||
export function commentDecoration(): Plugin {
|
||||
const commentDecorationPlugin = new PluginKey('commentDecoration');
|
||||
|
||||
return new Plugin({
|
||||
key: commentDecorationPlugin,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, oldSet) {
|
||||
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
|
||||
|
||||
if (decorationMeta) {
|
||||
const { from, to } = tr.selection;
|
||||
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
|
||||
return DecorationSet.create(tr.doc, [decoration]);
|
||||
} else if (decorationMeta === false) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
return commentDecorationPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
136
client/src/features/editor/extensions/comment/comment.ts
Normal file
136
client/src/features/editor/extensions/comment/comment.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
import { commentDecoration } from '@/features/editor/extensions/comment/comment-decoration';
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>,
|
||||
}
|
||||
|
||||
export interface ICommentStorage {
|
||||
activeCommentId: string | null;
|
||||
}
|
||||
|
||||
export const commentMarkClass = 'comment-mark';
|
||||
export const commentDecorationMetaKey = 'decorateComment';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
comment: {
|
||||
setCommentDecoration: () => ReturnType,
|
||||
unsetCommentDecoration: () => ReturnType,
|
||||
setComment: (commentId: string) => ReturnType,
|
||||
unsetComment: (commentId: string) => ReturnType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
name: 'comment',
|
||||
exitable: true,
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
activeCommentId: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-comment-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.commentId) return;
|
||||
|
||||
return {
|
||||
'data-comment-id': attributes.commentId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-comment-id]',
|
||||
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, true);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
unsetCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, false);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
setComment: (commentId) => ({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
return commands.setMark(this.name, { commentId });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(mark =>
|
||||
mark.type.name === this.name && mark.attrs.commentId === commentId);
|
||||
|
||||
if (commentMark) {
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.['data-comment-id'] || null;
|
||||
const elem = document.createElement('span');
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
elem.addEventListener('click', (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === 'Range') return;
|
||||
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
|
||||
bubbles: true,
|
||||
detail: { commentId },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
});
|
||||
|
||||
return elem;
|
||||
},
|
||||
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
},
|
||||
|
||||
},
|
||||
);
|
||||
@ -94,3 +94,7 @@
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
background: rgba(0,203,15,0.2);
|
||||
border-bottom: 2px solid #0ca678;
|
||||
}
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { createPage, deletePage, getPageById, updatePage } from '@/features/page/services/page-service';
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { useAtom } from 'jotai/index';
|
||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
||||
|
||||
export default function usePage(pageId?: string) {
|
||||
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
|
||||
|
||||
const createMutation = useMutation(
|
||||
(data: Partial<IPage>) => createPage(data),
|
||||
);
|
||||
@ -21,11 +17,6 @@ export default function usePage(pageId?: string) {
|
||||
|
||||
const updateMutation = useMutation(
|
||||
(data: Partial<IPage>) => updatePage(data),
|
||||
{
|
||||
onSuccess: (updatedPageData) => {
|
||||
setPage(updatedPageData);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const removeMutation = useMutation(
|
||||
@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { IMovePage } from '@/features/page/types/page.types';
|
||||
import { useNavigate} from 'react-router-dom';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
import usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
|
||||
export function usePersistence<T>() {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
|
||||
@ -56,7 +56,7 @@ export default function PageTree() {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
}, 200);
|
||||
}, [tree]);
|
||||
}, [tree, pageId]);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
|
||||
5
client/src/lib/time-ago.ts
Normal file
5
client/src/lib/time-ago.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { formatDistanceStrict } from 'date-fns';
|
||||
|
||||
export function timeAgo(date: Date){
|
||||
return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true })
|
||||
}
|
||||
@ -9,16 +9,19 @@ import { MantineProvider } from '@mantine/core';
|
||||
import { TanstackProvider } from '@/components/providers/tanstack-provider';
|
||||
import CustomToaster from '@/components/ui/custom-toaster';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme}>
|
||||
<ModalsProvider>
|
||||
<TanstackProvider>
|
||||
<App />
|
||||
<CustomToaster />
|
||||
</TanstackProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai/index';
|
||||
import usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
import Editor from '@/features/editor/editor';
|
||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user