Skip to content

Commit

Permalink
feat: colaborative editoring
Browse files Browse the repository at this point in the history
  • Loading branch information
rob-at-airwalk committed Jul 6, 2024
1 parent d437de6 commit 8323cd8
Show file tree
Hide file tree
Showing 10 changed files with 945 additions and 269 deletions.
922 changes: 684 additions & 238 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev:spotlight": "spotlight-sidecar",
"dev:next": "next dev",
"dev": "run-p dev:*",
"dev-colab": "node server.js",
"build": "next build",
"build-with-migrate": "npm run db:migrate && next build",
"start": "next start",
Expand Down Expand Up @@ -38,11 +39,11 @@
"@langchain/community": "^0.0.44",
"@langchain/core": "^0.1.54",
"@langchain/openai": "^0.0.26",
"@lexical/react": "^0.16.1",
"@logtail/pino": "^0.4.21",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/mdx": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@mdxeditor/editor": "^3.0.7",
"@mui/icons-material": "^5.15.15",
"@mui/material": "^5.15.15",
"@mui/material-nextjs": "^5.15.11",
Expand All @@ -55,9 +56,11 @@
"@stefanprobst/rehype-extract-toc": "^2.2.0",
"@t3-oss/env-nextjs": "^0.9.2",
"@typescript-eslint/typescript-estree": "^7.13.0",
"@webtech0321/mdx-editor-collab": "^3.7.6",
"gray-matter": "^4.0.3",
"ioredis": "^5.3.2",
"langchain": "^0.1.31",
"lexical": "^0.16.1",
"mime-types": "^2.1.35",
"next": "^14.2.4",
"next-intl": "^3.10.0",
Expand All @@ -78,6 +81,10 @@
"sharp": "^0.33.3",
"ts-node": "^10.9.2",
"uuid": "^10.0.0",
"ws": "^8.18.0",
"y-redis": "^1.0.3",
"y-websocket": "^2.0.3",
"yjs": "^13.6.18",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
56 changes: 56 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* eslint-disable no-console */
const { createServer } = require('http');
const next = require('next');
const { parse } = require('url');
const WebSocket = require('ws');

const wss = new WebSocket.Server({ noServer: true });
const {
setupWSConnection,
// setPersistence,
// docs,
} = require('y-websocket/bin/utils');
// const persisitance = require('./store').persistence;

// setPersistence(persisitance);
wss.on('connection', setupWSConnection);

const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOST || 'localhost';
const port = process.env.PORT || 3000;

// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
const nextUpgradeHandler = app.getUpgradeHandler();

app.prepare().then(() => {
const httpServer = createServer(handler);

httpServer
.once('error', (err) => {
console.error(err);
process.exit(1);
})
// eslint-disable-next-line consistent-return
.on('upgrade', (request, socket, head) => {
// You may check auth of request here..
// See https://github.com/websockets/ws#client-authentication
/**
* @param {any} ws
*/
const { pathname } = parse(request.url || '/', true);
// Make sure we all for hot module reloading
if (pathname === '/_next/webpack-hmr') {
return nextUpgradeHandler(request, socket, head);
}

const handleAuth = (ws) => {
wss.emit('connection', ws, request);
};
wss.handleUpgrade(request, socket, head, handleAuth);
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
161 changes: 135 additions & 26 deletions src/_components/Editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
/* eslint-disable react/no-unstable-nested-components */
import '@mdxeditor/editor/style.css';
import '@webtech0321/mdx-editor-collab/style.css';

import SaveIcon from '@mui/icons-material/Save';
import { Alert, css, Fab } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import Paper from '@mui/material/Paper';
import Snackbar from '@mui/material/Snackbar';
import type { Theme } from '@mui/material/styles';
import { styled } from '@mui/material/styles';
import {
activeEditor$,
addComposerChild$,
BlockTypeSelect,
BoldItalicUnderlineToggles,
CodeBlockNode,
codeBlockPlugin,
CodeMirrorEditor,
CreateLink,
diffSourcePlugin,
DiffSourceToggleWrapper,
DirectiveNode,
frontmatterPlugin,
headingsPlugin,
imagePlugin,
Expand All @@ -24,29 +35,46 @@ import {
MDXEditor,
type MDXEditorMethods,
quotePlugin,
realmPlugin,
rootEditor$,
TableNode,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo,
} from '@mdxeditor/editor';
import SaveIcon from '@mui/icons-material/Save';
import { Alert, css, Fab } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import Paper from '@mui/material/Paper';
import Snackbar from '@mui/material/Snackbar';
import type { Theme } from '@mui/material/styles';
import { styled } from '@mui/material/styles';
usedLexicalNodes$,
} from '@webtech0321/mdx-editor-collab';
import type { LexicalEditor } from 'lexical';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
createEditor,
} from 'lexical';
import dynamic from 'next/dynamic';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

import type { ContentItem } from '@/lib/Types';
import { baseTheme } from '@/styles/baseTheme';

const CollaborationPlugin = dynamic(
() =>
import('@lexical/react/LexicalCollaborationPlugin').then(
(mod) => mod.CollaborationPlugin
),
{
ssr: false,
}
);

const toKebabCase = (str: string) => {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
};
Expand Down Expand Up @@ -80,6 +108,7 @@ interface EditorProps {
enabled?: boolean;
top: number;
editorRef?: React.MutableRefObject<MDXEditorMethods | null>;
colabID: string;
}

const Editor = React.memo(function EditorC({
Expand All @@ -92,14 +121,15 @@ const Editor = React.memo(function EditorC({
enabled = true,
top,
editorRef,
colabID,
}: EditorProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const changedRef = useRef(false);
const typographyCopy = { ...baseTheme.typography } as Theme['typography'];
const importedCss = convertStyleObjectToCSS(typographyCopy);

const containerRef = useRef<HTMLDivElement | null>(null);
const StyledMDXEditor = styled(MDXEditor)`
font-family: 'Heebo';
font-weight: 200;
Expand All @@ -108,8 +138,6 @@ const Editor = React.memo(function EditorC({
}
[class*='_contentEditable_'] {
height: calc(100vh - ${top}px);
padding-left: 2% !important;
padding-right: 2% !important;
overflow-y: auto;
overflow-x: hidden;
}
Expand Down Expand Up @@ -146,7 +174,7 @@ const Editor = React.memo(function EditorC({
}
}
img {
max-width: 70%;
max-width: 50%;
height: auto;
}
`;
Expand All @@ -173,6 +201,79 @@ const Editor = React.memo(function EditorC({
[initialMarkdown, defaultContext, context.branch]
);

const initialEditorState = (_editor: LexicalEditor): void => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode();
paragraph.append(text);
root.append(paragraph);
};

const collaborationPlugin = useMemo(
() =>
realmPlugin({
postInit(realm) {
// const rootEditor = realm.getValue(rootEditor$);
const newEditor = createEditor({
editable: true,
namespace: 'MDXEditor',
nodes: realm.getValue(usedLexicalNodes$),
onError: (err: any) => {
throw err;
},
// theme: rootEditor?._config.theme,
});
realm.pub(rootEditor$, newEditor);
realm.pub(activeEditor$, newEditor);

const excludedProperties = new Map();
excludedProperties.set(TableNode, new Set(['focusEmitter']));
excludedProperties.set(
CodeBlockNode,
new Set([
'__focusEmitter',
'setCode',
'setMeta',
'setLanguage',
'select',
])
);
excludedProperties.set(DirectiveNode, new Set(['__focusEmitter']));

realm.pub(addComposerChild$, () => (
<CollaborationPlugin
id={colabID}
// @ts-ignore
providerFactory={(id, yjsDocMap) => {
const protocol =
window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let doc = yjsDocMap.get(id);
if (doc === undefined) {
doc = new Y.Doc();
yjsDocMap.set(id, doc);
} else {
doc.load();
}
const provider = new WebsocketProvider(
`${protocol}//${window.location.host}`,
id,
doc
);

return provider;
}}
initialEditorState={initialEditorState}
shouldBootstrap={false}
excludedProperties={excludedProperties}
username={`ABC-${Math.floor(Math.random() * 100)}`}
cursorsContainerRef={containerRef}
/>
));
},
}),
[colabID]
);

const editorPlugins = useMemo(
() => [
diffSourcePlugin({
Expand Down Expand Up @@ -224,8 +325,14 @@ const Editor = React.memo(function EditorC({
</>
),
}),
collaborationPlugin(),
],
[initialMarkdown, imageUploadHandler, imagePreviewHandler]
[
initialMarkdown,
imageUploadHandler,
imagePreviewHandler,
collaborationPlugin,
]
);

const SaveButton = React.memo(function SaveButton() {
Expand Down Expand Up @@ -268,10 +375,10 @@ const Editor = React.memo(function EditorC({
return (
<Paper
sx={{
px: 0,
px: '1%',
maxHeight: 'calc(100vh - 65px)',
pt: 0,
pb: 0,
pt: '2%',
pb: '2%',
overflow: 'auto',
}}
elevation={0}
Expand All @@ -281,15 +388,17 @@ const Editor = React.memo(function EditorC({
The editor is in read-only mode until you change branch
</Alert>
)}
<StyledMDXEditor
ref={editorRef}
onChange={editorCallback}
onError={(msg) => setError(`Error in markdown: ${msg}`)}
markdown={initialMarkdown || ''}
plugins={editorPlugins}
readOnly={defaultContext && context.branch === defaultContext.branch}
autoFocus
/>
<div ref={containerRef}>
<StyledMDXEditor
ref={editorRef}
onChange={editorCallback}
onError={(msg) => setError(`Error in markdown: ${msg}`)}
markdown={initialMarkdown || ''}
plugins={editorPlugins}
readOnly={defaultContext && context.branch === defaultContext.branch}
autoFocus
/>
</div>
<SaveButton />
<Snackbar
open={!!error}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// components/Editor.stories.tsx
import { type MDXEditorMethods } from '@mdxeditor/editor';
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { type MDXEditorMethods } from '@webtech0321/mdx-editor-collab';
import React, { useRef } from 'react';

import { Editor } from '@/components/Editor';
Expand Down Expand Up @@ -35,6 +35,7 @@ const meta: Meta<typeof Editor> = {
context: dummyContext,
defaultContext: dummyDefaultContext,
enabled: true,
colabID: '12345',
top: 0,
editorSaveHandler: fn(async () => {
return Promise.resolve('successfully saved file');
Expand Down Expand Up @@ -70,6 +71,7 @@ const Template: Story = {
imagePreviewHandler={args.imagePreviewHandler}
enabled={args.enabled}
top={args.top}
colabID={args.colabID}
// {...args}
// other props you might need to pass
/>
Expand Down
Loading

0 comments on commit 8323cd8

Please sign in to comment.