Version: 1.0
Author: Timan Zheng
Date: 6/17/2025 (Completed from Jan - Jun 2025 part-time while working full-time)
Description: This is a full-stack, real-time Collaborative Markdown Editor built with React, Lexical, Yjs, Socket.IO, and PostgreSQL among various other technologies. It is a web application that enables multiple users to edit markdown documents simultaneously with live cursor rendering, real-time interaction such as messaging and notifications, and robust session and user management. As the name suggets, this project was heavily inspired by and aims to capture the capabilities and modern UI of the existing CMDE HackMD but also similar products like Google Docs.
- Key Features
- Tech Stack
- Project Structure
- Architecture Design
- How SQL Queries Flow
- Setup & Run
- Database Schema (PostgreSQL)
- Testing Suggestions
- Some Limitations
- Future Improvements
- Site Demonstrations (GIF)
- For a recorded video demo walking through the site
- For a more thorough breakdown of the main components of this project
- Real-time collaborative text editing using Yjs CRDT
- Markdown preview rendering via
markdown-it - Persistent document state saved to PostgreSQL and loaded on start if applicable
- Custom remote cursor tracking and rendering within Text Editor.
- Login and Register forms with validation and visual error popups
- Role-based room access and permissions (
king/member) - Invite links with expiration logic
- (
king) Kick users, transfer ownership, and manage members in any room
- Socket.IO-powered live user presence & messaging
- Room-wide and private notifications
- Real-time DM system integrated into the editor
- Live preview of Markdown content
- Task list support via
markdown-it-task-lists
- JWT-based login + registration
- User state stored in
localStorage - Protected routes via custom
<PrivateRoute>wrappers
- ReactJS (Vite)
- Lexical Editor (by Meta)
- Socket.IO Client
- Yjs +
y-websocket - Markdown-it + Task List Plugin
- React Router DOM
- FileReader API (for markdown uploads & Blob-based downloads)
- CSS styling with a custom monospace/Matrix-style theme.
- Express.js REST API (expresServer.js)
- Socket.IO server (socketIOServer.js)
- PostgreSQL for persistence
- pg Node.js driver
- dotenv, cors, uuid, bcrypt, jsonwebtoken
- Yjs CRDT framework for collaborative editing
- Custom
saveRoomData()&getRoomData()handlers for syncing state to DB
mde-part2
├─ .env
├─ eslint.config.js
├─ index.html
├─ package-lock.json
├─ package.json
├─ public
│ ├─ gifs
│ │ ├─ create-room-fwwm.gif
│ │ └─ join-room-fwwm.gif
│ ├─ guide_gifs
│ │ ├─ 1.Register&Sign-In.gif
│ │ ├─ 2.Creating-new-room-and-inviting-another.gif
│ │ ├─ 3.Notifs-and-Messaging.gif
│ │ ├─ 4.Playing-w-Toolbar.gif
│ │ ├─ 5.Playing-w-Toggle-View.gif
│ │ ├─ 6.Kicked-in-Real-Time.gif
│ │ ├─ 7.Upload-Image.gif
│ │ ├─ 8.Download-and-Upload-md.gif
│ │ └─ 9.Protected-Routes.gif
│ ├─ images
│ │ ├─ house-icon.png
│ │ ├─ notif-icon.png
│ │ └─ users-icon.png
│ └─ vite.svg
├─ README.md
├─ README2.md
├─ src
│ ├─ components
│ │ ├─ App.jsx
│ │ ├─ backend
│ │ │ ├─ controllers
│ │ │ │ └─ authController.js
│ │ │ ├─ expressServer.js
│ │ │ ├─ routes
│ │ │ │ └─ auth.js
│ │ │ ├─ schema.sql
│ │ │ ├─ schemaSetup.js
│ │ │ └─ socketIOServer.js
│ │ ├─ core-features
│ │ │ ├─ MDParser.jsx
│ │ │ ├─ PrivateRoute.jsx
│ │ │ ├─ PrivateRouteEditor.jsx
│ │ │ ├─ PrivateRouteMisc.jsx
│ │ │ ├─ RemoteCursorOverlay.jsx
│ │ │ └─ Toolbar.jsx
│ │ ├─ misc-features
│ │ │ ├─ ChatBox.jsx
│ │ │ ├─ ManageUsersListEntry.jsx
│ │ │ ├─ ManageUsersListSection.jsx
│ │ │ ├─ ManageUsersSection.jsx
│ │ │ ├─ NotificationBar.jsx
│ │ │ ├─ NotificationBarHeader.jsx
│ │ │ ├─ UsersListContainer.jsx
│ │ │ ├─ UsersListEntry.jsx
│ │ │ ├─ UsersListHeader.jsx
│ │ │ └─ UsersListSection.jsx
│ │ ├─ pages
│ │ │ ├─ Dashboard.jsx
│ │ │ ├─ Editor.jsx
│ │ │ ├─ Login.jsx
│ │ │ └─ Register.jsx
│ │ └─ utility
│ │ ├─ api.js
│ │ ├─ utilityFuncs.js
│ │ └─ utilityFuncsBE.js
│ ├─ main.jsx
│ └─ styles
│ └─ index.css
└─ vite.config.js
- Note: "mde-part2" was the directory I worked with. This was the second phase of my project (adding user and session management, and so on) after completing the majority of work related to the Editor page itself. (This is expanded on more in the Future Improvements area, but I would like to implement version control and RUST into this project at some point. Those would presumably constitute a Part 3 and possibly a Part 4 respectively).
graph TD
A[Login/Register Pages] --> B[JWT Auth via Express API]
B --> C[PostgreSQL Users Table]
D[Editor Page] --> E[Yjs WebSocket Server]
E --> F[Collaborative Document Sync]
D --> G[Socket.IO Server]
G --> H[Active Users, Cursors, Messaging]
D --> M[On Mount: Fetch Existing Doc]
M --> N[GET Express API /ydocs]
D --> I[Document Autosave]
I --> J[PUT Express API /ydocs]
D --> K[Markdown Parser]
K --> L[Live Preview]
- A->B->C - Login and registration requests from the client hit the Express API, which authenticates them with JWT against the
userstable in my PostgreSQL backend. - D->E->F - The Editor component connects to a
y-websocketserver which handles real-time collaborative editing via Yjs (the two sync). - D->G->H - The Editor connects to a Socket.IO server to manage real-time interaction (presence, chat, cursor sharing, etc).
- D->I->N - On initial render, the editor checks if a saved version of the doc exists. Calls a
GET /api/ydocs/:roomIdroute on the Express API, which pulls the Yjs document (as binary or string) from the ydocs table. - D->I->J - During editing or on disconnect, autosaves are sent via a
PUT /api/ydocsrequest. Content is saved into theydocstable usingsaveRoomData()in your backend. - D->K->L - The raw content is parsed using
markdown-itwith task list support (MDParser.jsx) and rendered as a live preview beside the editor.
- My React frontend code (e.g., a file like
Dashboard.jsx) invokes a function that was invoked from my/utility/api.jsfile (where I have reusable functions to interact with my Express API e.g.,getAllRooms()).
export const getAllRooms = async(token) => {
const result = await fetch(`${API_BASE}/auth/rooms`, {
method:"GET",
headers: { Authorization: `Bearer ${token}` },
});
return await result.json();
};
- Then, on the backend Express server, the request sent by
api.jsis received and all necessary information is extracted inauth.jswithrouter.get('/rooms', verifyToken, getAllEdRooms);(where those two functions passed are executed from left to right, the former just being some JWT verification function that also passes some arguments off togetAllEdRooms). - Then, in
authController.js,getAllEdRoomsruns, and that's what sends the information I want back to the frontend:
export const getAllEdRooms = async (req, res) => {
const userID = req.user.id; // Will get obtained by func verifyToken...
try {
const roomsRes = await pool.query(`
SELECT
ur.id AS user_room_id,
ur.role,
r.id AS room_id,
r.name AS room_name,
r.created_at,
u.username AS creator_name
FROM user_rooms ur
JOIN rooms r ON ur.room_id = r.id
JOIN users u ON r.created_by = u.id
WHERE ur.user_id = $1
ORDER BY r.created_at DESC
`, [userID]);
res.json(roomsRes.rows);
} catch (err) {
console.error("DEBUG: FAILED TO RETRIEVE ROOMS => [", err, "]");
res.status(500).json({error: "COULD NOT RETRIEVE EDITOR ROOMS."});
}
};
- PostgreSQL running with schema from
schema.sql - Node.js v18+
- Cloudinary account (if you want to use the Upload
Imagefunctionality of the Editor).
Create .env file (you can obviously adjust the values seen here like the PORT value chosen etc, this is just what I had in my setup, but be weary of what you would need to tweak in the code. Cloudinary values and an account are needed for the Upload Image functionality of the Editor (found in the Toolbar)):
PORT=5000
DATABASE_URL=postgres://youruser:yourpassword@localhost:5433/yourdb
VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name
VITE_CLOUDINARY_API_KEY=your_cloudinary_api_key
VITE_CLOUDINARY_UPLOAD_PRESET=your_upload_preset
JWT_SECRET=your_jwt_secret
I have written the file schemaSetup.js for the purpose of initializing a database with the tables relevant for this project. The reason this file is necessary rather than just running schema.sql alone is that permissions must be set for the user that will be interacting with the tables; this information can be found in DATABASE_URL within the .env file. (Can't be done in a .sql file since it's purely declarative).
- You can run
schemaSetup.jswith the command:
node src/components/backend/schemaSetup.js
npm run dev # 1. Frontend (Vite)
npx y-websocket --port 1234 # 2. Yjs WebSocket Provider
node src/components/backend/socketIOServer.js # 3. Socket.IO Server
node src/components/backend/expressServer.js # 4. Express API Server
(See full schema in schema.sql)
Includes the following tables:
usersroomsuser_roomsinvite_linksydocs(Admittedly, the name of this table should be changed -- I will elaborate more in the limitations section of the README).messages(private chat messages)
- Sign up and register two different accounts (you can do this in two separate browsers, maybe one browser and an incognito tab).
- From the Dashboard, create a new Editor Room and create an invite link, invite the other account you made.
- Have one user join said room and then the other user join that room, watch the Notifications symbol animate in response to their entry (click it and view the Notification, optionally you can clear it).
- Begin typing in the Text Editor, then open the other user's tab and notice the custom foreign cursor (with username) render that appears on the screen. You can type there as well and check vice-versa. You will notice edits are synced in real-time via Yjs and that Markdown syntax is parsed on-the-fly in a live Preview Panel.
- Play around with the Editor UI, toggle the view modes, drag the "draggable" slider that lets you resize the Editor and Preview Panel. Play around with the Toolbar icons.
- Click on the Users List icon and then target the other user in the room for the Chat button, send them messages and see how they are received and how they persist.
- On the Owner User client, return to the Dashboard and kick the other User from the room you were both in, go to the latter user's tab and notice the pop-up that appears.
- No email verification system is implemented (for simplicity, it's really not the point of this project and I didn't want to dwell any longer on miscellaneous features).
- The current implementation assumes all servers (Express, Socket.IO, y-websocket) run locally and are not load-balanced or clustered.
- Repeatedly generating invite links can fill the
invite_linkstable without cleanup unless a background job is added.
- I have basic markdown export available via download, but full PDF-style or print styling has not been implemented here.
- Namely the additional configurations when hovering your cursor over a Table structure in the Text Editor contenteditable. There is litterally no way I can do that by myself without introducing new frameworks that just aren't worth it.
My method of saving and loading Editor Doc state could be better (this doubles as a future improvement):
- So, if you really look at my code, you'll notice that in Editor.jsx I'm querying the PostgreSQL backend for a pre-existing document state and what I'm retrieving is a JSON string representation of the Editor state from a table called "ydocs". That's strange. And that's because my original plan was to serialize the state of the ydocs document and store that in my backend. And while I originally did that successfully so, and was able to retrieve it at start-up and verify that the data was correct, I just couldn't initialize/bootstrap the Lexical Text Editor with its values and after two full days of trying to figure out what was wrong, I gave up and opted for this alternative instead (you can look at old commits and find my original set-up which was much more elaborate). At some point, I might dive back in and try to figure this out.
- This is by far the most glaring omission from my project. I got way too hung up on miscellaneous features such as the Chat and Notification system when I definitely should have focused on more integral and technically impressive features like implementing Version Control. I let the scope of this project get far too wide and overcomplicated way too much.
- Considering replacing my JavaScript Markdown parser and instead using a modular Rust-based Markdown parser, compiled to WebAssembly (WASM) and integrated to my web app. This is pretty much just to expand my list of technologies. I would probably do this first and then aim to implement version control.
- I currently have it so that the browser will cache userData with a token in localStorage. At the moment, I have it so that as long as that token is valid, the site authentication will treat the client as a logged in user (w/ the userData associated with said token). This could be better (e.g., although I removed the "validity expiration" part of the Login Tokens generated, I don't really have anything in place to double-check that the token is still valid or the userData associated with the token is still valid. For example, I might clear out the
userstable from the backend but I would still be able to log-in as a now non-existent user if the token is still cached in the browser).
- This was by far my lowest priority. (But I should definitely make it look a little nicer than it does).
- For now, I'm going to be making a short demo video (in addition to the gifs viewable in the section below) where I personally walk through the site functionality and explore all of its features. Sometime soon, I would like to host this project somewhere and make it fully interactable for anyone who'd care to visit and explore its functionalities. I am currently considering hosting it on Fly.io since it supports to a reasonable extent all of the technologies needed for my project to function.








