Merge pull request #1 from Philipinho/collaboration

collaborative editor
This commit is contained in:
Philip 2023-09-15 01:23:34 +01:00 committed by GitHub
commit 04dea6c253
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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;

View File

@ -4,7 +4,7 @@
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"license": "",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@ -27,24 +27,27 @@
"migration:show": "npm run typeorm migration:show"
},
"dependencies": {
"@hocuspocus/server": "^2.5.0",
"@hocuspocus/transformer": "^2.5.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.1.0",
"@nestjs/mapped-types": "^2.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-fastify": "^10.1.3",
"@nestjs/platform-fastify": "^10.2.4",
"@nestjs/platform-ws": "^10.2.4",
"@nestjs/typeorm": "^10.0.0",
"@types/uuid": "^9.0.2",
"bcrypt": "^5.1.0",
"@nestjs/websockets": "^10.2.4",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"fastify": "^4.21.0",
"pg": "^8.11.2",
"fastify": "^4.22.2",
"pg": "^8.11.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"ws": "^8.14.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@ -55,6 +58,8 @@
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^2.0.12",
"@types/uuid": "^9.0.3",
"@types/ws": "^8.5.5",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"eslint": "^8.42.0",

View File

@ -5,6 +5,7 @@ import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './environment/environment.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppDataSource } from './database/typeorm.config';
import { CollaborationModule } from './collaboration/collaboration.module';
@Module({
imports: [
@ -16,6 +17,7 @@ import { AppDataSource } from './database/typeorm.config';
migrations: ['dist/src/**/migrations/*.{ts,js}'],
autoLoadEntities: true,
}),
CollaborationModule,
],
controllers: [AppController],
providers: [AppService],

View File

@ -0,0 +1,31 @@
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server as HocuspocusServer } from '@hocuspocus/server';
import { IncomingMessage } from 'http';
import WebSocket, { Server } from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
@WebSocketGateway({ path: '/collaboration' })
export class CollaborationGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension,
) {}
private hocuspocus = HocuspocusServer.configure({
debounce: 5000,
maxDebounce: 10000,
extensions: [this.authenticationExtension, this.persistenceExtension],
});
handleConnection(client: WebSocket, request: IncomingMessage): any {
this.hocuspocus.handleConnection(client, request);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { UserModule } from '../core/user/user.module';
import { AuthModule } from '../core/auth/auth.module';
import { CollaborationGateway } from './collaboration.gateway';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { PageModule } from '../core/page/page.module';
@Module({
providers: [
CollaborationGateway,
AuthenticationExtension,
PersistenceExtension,
],
imports: [UserModule, AuthModule, PageModule],
})
export class CollaborationModule {}

View File

@ -0,0 +1,34 @@
import { Extension, onAuthenticatePayload } from '@hocuspocus/server';
import { UserService } from '../../core/user/user.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { TokenService } from '../../core/auth/services/token.service';
@Injectable()
export class AuthenticationExtension implements Extension {
constructor(
private tokenService: TokenService,
private userService: UserService,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
const { documentName, token } = data;
let jwtPayload = null;
try {
jwtPayload = await this.tokenService.verifyJwt(token);
} catch (error) {
throw new UnauthorizedException('Could not verify jwt token');
}
const userId = jwtPayload.sub;
const user = await this.userService.findById(userId);
//TODO: Check if the page exists and verify user permissions for page.
// if all fails, abort connection
return {
user,
};
}
}

View File

@ -0,0 +1,59 @@
import { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server';
import * as Y from 'yjs';
import { PageService } from '../../core/page/page.service';
import { Injectable } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
@Injectable()
export class PersistenceExtension implements Extension {
constructor(private readonly pageService: PageService) {}
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName, document } = data;
if (!document.isEmpty('default')) {
return;
}
const page = await this.pageService.findById(documentName);
if (!page) {
console.log('page does not exist.');
//TODO: terminate connection if the page does not exist?
return;
}
if (page.ydoc) {
const doc = new Y.Doc();
const dbState = new Uint8Array(page.ydoc);
Y.applyUpdate(doc, dbState);
return doc;
}
// if no ydoc state in db convert json in page.content to Ydoc.
const ydoc = TiptapTransformer.toYdoc(page.content, 'default');
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
const pageId = documentName;
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
try {
await this.pageService.updateState(
pageId,
tiptapJson,
ydocState,
);
} catch (err) {
console.error(`Failed to update page ${documentName}`);
}
}
}

View File

@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../environment/environment.service';
import { User } from '../../user/entities/user.entity';
import { FastifyRequest } from 'fastify';
import { TokensDto } from "../dto/tokens.dto";
import { TokensDto } from '../dto/tokens.dto';
@Injectable()
export class TokenService {

View File

@ -20,14 +20,14 @@ export class Page {
@Column({ length: 500, nullable: true })
title: string;
@Column({ type: 'text', nullable: true })
@Column({ type: 'jsonb', nullable: true })
content: string;
@Column({ type: 'text', nullable: true })
html: string;
@Column({ type: 'jsonb', nullable: true })
json: any;
@Column({ type: 'bytea', nullable: true })
ydoc: any;
@Column({ nullable: true })
slug: string;

View File

@ -11,5 +11,6 @@ import { WorkspaceModule } from '../workspace/workspace.module';
imports: [TypeOrmModule.forFeature([Page]), AuthModule, WorkspaceModule],
controllers: [PageController],
providers: [PageService, PageRepository],
exports: [PageService, PageRepository],
})
export class PageModule {}

View File

@ -22,19 +22,29 @@ export class PageService {
page.creatorId = userId;
page.workspaceId = workspaceId;
console.log(page);
return await this.pageRepository.save(page);
}
async update(pageId: string, updatePageDto: UpdatePageDto): Promise<Page> {
const existingPage = await this.pageRepository.findById(pageId);
if (!existingPage) {
throw new Error(`Page with ID ${pageId} not found`);
}
const page = await this.pageRepository.preload({
id: pageId,
...updatePageDto,
} as Page);
return await this.pageRepository.save(page);
}
async updateState(pageId: string, content: any, ydoc: any): Promise<void> {
await this.pageRepository.update(pageId, {
content: content,
ydoc: ydoc,
});
}
async delete(pageId: string): Promise<void> {
await this.pageRepository.softDelete(pageId);
}

View File

@ -6,6 +6,7 @@ import {
} from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor';
import { WsAdapter } from '@nestjs/platform-ws';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@ -25,6 +26,7 @@ async function bootstrap() {
app.enableCors();
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(process.env.PORT || 3001);
}