collaborative editor - wip

This commit is contained in:
Philipinho
2023-09-15 01:22:47 +01:00
parent 0d648c17fa
commit 4382c5a1d0
21 changed files with 375 additions and 23 deletions

2
frontend/.env.example Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_BACKEND_API_URL=http://localhost:3001
NEXT_PUBLIC_COLLABORATION_URL=

View File

@ -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": {

View 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>
);
}

View File

@ -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}!
</>
);
}

View File

@ -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>
);

View File

@ -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}
</>
);
}

View File

@ -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"

View 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} />
</>
);
}

View 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;
}

View 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;