mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 23:21:09 +10:00
collaborative editor - wip
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_BACKEND_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_COLLABORATION_URL=
|
||||
@ -10,6 +10,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "^2.5.0",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
@ -24,6 +25,14 @@
|
||||
"@tabler/icons-react": "^2.32.0",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@tiptap/extension-collaboration": "^2.1.8",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.1.8",
|
||||
"@tiptap/extension-document": "^2.1.8",
|
||||
"@tiptap/extension-heading": "^2.1.8",
|
||||
"@tiptap/extension-placeholder": "^2.1.8",
|
||||
"@tiptap/pm": "^2.1.8",
|
||||
"@tiptap/react": "^2.1.8",
|
||||
"@tiptap/starter-kit": "^2.1.8",
|
||||
"@types/node": "20.4.8",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
@ -48,6 +57,7 @@
|
||||
"tailwindcss": "3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"typescript": "5.1.6",
|
||||
"yjs": "^13.6.7",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
18
frontend/src/app/(dashboard)/(page)/p/[pageId]/page.tsx
Normal file
18
frontend/src/app/(dashboard)/(page)/p/[pageId]/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const Editor = dynamic(() => import("@/features/editor/Editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { pageId } = useParams();
|
||||
|
||||
return (
|
||||
<div className="w-full h-[500px]">
|
||||
<Editor pageId={pageId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,10 +7,8 @@ export default function Home() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center z-10 flex-shrink-0">
|
||||
<div className={`w-[900px]`}>
|
||||
Hello {currentUser && currentUser.user.name}!
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
Hello {currentUser && currentUser.user.name}!
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,7 +14,11 @@ export default function DashboardLayout({ children }: {
|
||||
return (
|
||||
<UserProvider>
|
||||
<Shell>
|
||||
{children}
|
||||
<div className="w-full flex justify-center z-10 flex-shrink-0">
|
||||
<div className={`w-[900px]`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@ -2,8 +2,8 @@ import { ReactNode } from 'react';
|
||||
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="w-full flex justify-center z-10 flex-shrink-0">
|
||||
<div className={`w-[800px]`}>{children}</div>
|
||||
</div>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ export const renderMenuItem = (menu, index) => {
|
||||
if (menu.target) {
|
||||
return (
|
||||
<NavigationLink
|
||||
key={index}
|
||||
href={menu.target}
|
||||
icon={menu.icon}
|
||||
className="w-full flex flex-1 justify-start items-center"
|
||||
|
||||
115
frontend/src/features/editor/Editor.tsx
Normal file
115
frontend/src/features/editor/Editor.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
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, useLayoutEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai/index';
|
||||
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 '@/features/editor/css/editor.css';
|
||||
|
||||
interface EditorProps{
|
||||
pageId: string,
|
||||
token: string,
|
||||
}
|
||||
|
||||
const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D']
|
||||
const getRandomElement = list => list[Math.floor(Math.random() * list.length)]
|
||||
const getRandomColor = () => getRandomElement(colors)
|
||||
|
||||
export default function Editor({ pageId }: EditorProps ) {
|
||||
const [token] = useAtom(authTokensAtom);
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
|
||||
const [provider, setProvider] = useState<any>();
|
||||
const [doc, setDoc] = useState<Y.Doc>()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (token) {
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
url: collaborationURL,
|
||||
name: pageId,
|
||||
document: ydoc,
|
||||
token: token.accessToken,
|
||||
});
|
||||
|
||||
setDoc(ydoc);
|
||||
setProvider(provider);
|
||||
|
||||
return () => {
|
||||
ydoc.destroy();
|
||||
provider.destroy();
|
||||
};
|
||||
}
|
||||
}, [collaborationURL, pageId, token]);
|
||||
|
||||
if(!doc || !provider){
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TiptapEditor ydoc={doc} provider={provider} />
|
||||
);
|
||||
}
|
||||
|
||||
interface TiptapEditorProps {
|
||||
ydoc: Y.Doc,
|
||||
provider: HocuspocusProvider
|
||||
}
|
||||
|
||||
function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Write here',
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider
|
||||
}),
|
||||
];
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"min-h-[500px] flex-1 p-4",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && currentUser.user){
|
||||
editor.chain().focus().updateUser({...currentUser.user, color: getRandomColor()}).run()
|
||||
}
|
||||
}, [editor, currentUser.user])
|
||||
|
||||
useEffect(() => {
|
||||
provider.on('status', event => {
|
||||
console.log(event)
|
||||
})
|
||||
|
||||
}, [provider])
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorContent editor={editor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
frontend/src/features/editor/css/editor.css
Normal file
26
frontend/src/features/editor/css/editor.css
Normal file
@ -0,0 +1,26 @@
|
||||
/* Give a remote user a caret */
|
||||
.collaboration-cursor__caret {
|
||||
border-left: 1px solid #0d0d0d;
|
||||
border-right: 1px solid #0d0d0d;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Render the username above the caret */
|
||||
.collaboration-cursor__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #0d0d0d;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
left: -1px;
|
||||
line-height: normal;
|
||||
padding: 0.1rem 0.3rem;
|
||||
position: absolute;
|
||||
top: -1.4em;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
17
frontend/src/features/editor/hooks/use-collaboration-url.ts
Normal file
17
frontend/src/features/editor/hooks/use-collaboration-url.ts
Normal file
@ -0,0 +1,17 @@
|
||||
const useCollaborationURL = (): string => {
|
||||
const PATH = "/collaboration";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_COLLABORATION_URL) {
|
||||
return process.env.NEXT_PUBLIC_COLLABORATION_URL + PATH;
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL;
|
||||
if (!API_URL) {
|
||||
throw new Error("Backend API URL is not defined");
|
||||
}
|
||||
|
||||
const wsProtocol = API_URL.startsWith('https') ? 'wss' : 'ws';
|
||||
return `${wsProtocol}://${API_URL.split('://')[1]}${PATH}`;
|
||||
};
|
||||
|
||||
export default useCollaborationURL;
|
||||
Reference in New Issue
Block a user