├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .stylelintrc.js ├── .vscode └── settings.json ├── README.md ├── package.json ├── packages ├── config │ ├── index.d.ts │ ├── index.js │ └── package.json ├── crdt-counter │ ├── babel.config.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ ├── rollup.config.js │ ├── rollup.server.js │ ├── server │ │ └── index.ts │ ├── src │ │ ├── client.ts │ │ ├── counter.tsx │ │ └── index.tsx │ └── tsconfig.json ├── crdt-quill │ ├── .gitignore │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── rollup.config.js │ ├── rollup.server.js │ ├── server │ │ └── index.ts │ ├── src │ │ ├── client.ts │ │ ├── index.css │ │ ├── index.ts │ │ └── quill.ts │ ├── tsconfig.json │ └── vercel │ │ ├── deploy.sh │ │ ├── rollup.config.js │ │ ├── server.js │ │ └── vercel.json ├── ot-counter │ ├── babel.config.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ ├── rollup.config.js │ ├── rollup.server.js │ ├── server │ │ ├── index.ts │ │ └── types.d.ts │ ├── src │ │ ├── client.ts │ │ ├── counter.tsx │ │ └── index.tsx │ └── tsconfig.json └── ot-quill │ ├── package.json │ ├── public │ └── favicon.ico │ ├── rollup.config.js │ ├── rollup.server.js │ ├── server │ ├── index.ts │ └── types.d.ts │ ├── src │ ├── client.ts │ ├── index.css │ ├── index.ts │ ├── quill.ts │ └── types.d.ts │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | sourceType: "module", 4 | }, 5 | extends: ["eslint:recommended", "plugin:prettier/recommended"], 6 | overrides: [ 7 | { 8 | files: ["*.ts"], 9 | parser: "@typescript-eslint/parser", 10 | plugins: ["@typescript-eslint"], 11 | extends: ["plugin:@typescript-eslint/recommended"], 12 | }, 13 | { 14 | files: ["*.tsx"], 15 | parser: "@typescript-eslint/parser", 16 | plugins: ["react", "react-hooks", "@typescript-eslint/eslint-plugin"], 17 | extends: ["plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], 18 | }, 19 | ], 20 | env: { 21 | browser: true, 22 | node: true, 23 | commonjs: true, 24 | es2021: true, 25 | }, 26 | ignorePatterns: ["node_modules", "build", "dist", "coverage", "public"], 27 | rules: { 28 | // 分号 29 | "semi": "error", 30 | // 对象键值引号样式保持一致 31 | "quote-props": ["error", "consistent-as-needed"], 32 | // 箭头函数允许单参数不带括号 33 | "arrow-parens": ["error", "as-needed"], 34 | // no var 35 | "no-var": "error", 36 | // const 37 | "prefer-const": "error", 38 | // 允许console 39 | "no-console": "off", 40 | // 关闭每个函数都要显式声明返回值 41 | // "@typescript-eslint/explicit-module-boundary-types": "off", 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # log 4 | *.log 5 | 6 | # dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # production 15 | build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com/ 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "printWidth": 100, // 指定代码长度,超出换行 3 | "tabWidth": 2, // tab 键的宽度 4 | "useTabs": false, // 不使用tab 5 | "semi": true, // 结尾加上分号 6 | "singleQuote": false, // 使用单引号 7 | "quoteProps": "preserve", // 不要求对象字面量属性是否使用引号包裹 8 | "jsxSingleQuote": false, // jsx 语法中使用单引号 9 | "trailingComma": "es5", // 确保对象的最后一个属性后有逗号 10 | "bracketSpacing": true, // 大括号有空格 { name: 'rose' } 11 | "arrowParens": "avoid", // 箭头函数,单个参数不强制添加括号 12 | "requirePragma": false, // 是否严格按照文件顶部的特殊注释格式化代码 13 | "insertPragma": false, // 是否在格式化的文件顶部插入Pragma标记,以表明该文件被prettier格式化过了 14 | "proseWrap": "preserve", // 按照文件原样折行 15 | "htmlWhitespaceSensitivity": "ignore", // html文件的空格敏感度,控制空格是否影响布局 16 | "endOfLine": "lf" // 结尾是 \n \r \n\r auto 17 | } -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["stylelint-config-standard", "stylelint-config-sass-guidelines"], 3 | ignoreFiles: [ 4 | "**/node_modules/**/*.*", 5 | "**/dist/**/*.*", 6 | "**/build/**/*.*", 7 | "**/coverage/**/*.*", 8 | "**/public/**/*.*", 9 | ], 10 | rules: { 11 | "no-descending-specificity": null, 12 | "color-function-notation": null, 13 | "alpha-value-notation": null, 14 | "no-empty-source": null, 15 | "max-nesting-depth": 6, 16 | "selector-max-compound-selectors": 6, 17 | "selector-class-pattern": "^[a-z][a-zA-Z0-9_-]+$", 18 | "selector-id-pattern": "^[a-z][a-zA-Z0-9_-]+$" 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "collab", 4 | "sharedb", 5 | "vercel", 6 | "Webrtc" 7 | ] 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collab 2 | 3 | Algorithms for OT/CRDT collaboration implementation. 4 | 5 | ## Docs 6 | 7 | * [初探富文本之OT协同算法](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之OT协同算法.md) 8 | * [初探富文本之OT协同实例](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之OT协同实例.md) 9 | * [初探富文本之CRDT协同算法](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之CRDT协同算法.md) 10 | * [初探富文本之CRDT协同实例](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之CRDT协同实例.md) 11 | * [初探富文本之划词评论能力](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之划词评论能力.md) 12 | 13 | 14 | ## Start 15 | 16 | ```bash 17 | npm i -g pnpm@8.11.0 18 | pnpm i --frozen-lockfile 19 | npm run dev:ot-counter 20 | npm run dev:ot-quill 21 | npm run dev:crdt-counter 22 | npm run dev:crdt-quill 23 | ``` 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@collab/workspace", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev:ot-counter": "pnpm --filter ot-counter run dev", 8 | "dev:ot-quill": "pnpm --filter ot-quill run dev ", 9 | "dev:crdt-counter": "pnpm --filter crdt-counter run dev", 10 | "dev:crdt-quill": "pnpm --filter crdt-quill run dev", 11 | "lint:ts": "pnpm --filter '*' run lint:ts" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/WindrunnerMax/Collab.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/WindrunnerMax/Collab/issues" 22 | }, 23 | "homepage": "https://github.com/WindrunnerMax/Collab", 24 | "devDependencies": { 25 | "@typescript-eslint/eslint-plugin": "^5.4.0", 26 | "@typescript-eslint/parser": "^5.4.0", 27 | "eslint": "7.11.0", 28 | "eslint-config-prettier": "^8.3.0", 29 | "eslint-plugin-import": "^2.25.3", 30 | "eslint-plugin-prettier": "3.3.1", 31 | "eslint-plugin-react": "^7.27.0", 32 | "eslint-plugin-react-hooks": "^4.3.0", 33 | "stylelint": "^14.8.5", 34 | "stylelint-config-sass-guidelines": "^9.0.1", 35 | "stylelint-config-standard": "^25.0.0", 36 | "typescript": "^4.9.4", 37 | "postcss": "^8.3.3", 38 | "prettier": "^2.4.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/config/index.d.ts: -------------------------------------------------------------------------------- 1 | export type CDN = { 2 | REACT: string; 3 | REACT_DOM: string; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/config/index.js: -------------------------------------------------------------------------------- 1 | const CDN_HOST = "https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/"; 2 | 3 | module.exports = { 4 | CDN: { 5 | REACT: CDN_HOST + "react/17.0.2/umd/react.production.min.js", 6 | REACT_DOM: CDN_HOST + "react-dom/17.0.2/umd/react-dom.production.min.js", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@collab/config", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": {}, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /packages/crdt-counter/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /packages/crdt-counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crdt-counter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "concurrently 'npm run fe' 'npm run server'", 8 | "fe": "mkdir -p build && cp -R public/ ./build/ && rollup -wc", 9 | "server": "rollup --config rollup.server.js && node build/server.js", 10 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@collab/config": "workspace:*", 17 | "express": "^4.18.2", 18 | "react": "^17.0.0", 19 | "react-dom": "^17.0.0", 20 | "y-webrtc": "^10.2.4", 21 | "yjs": "^13.5.48" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.20.12", 25 | "@babel/preset-env": "^7.20.2", 26 | "@babel/preset-react": "^7.18.6", 27 | "@rollup/plugin-commonjs": "^24.0.1", 28 | "@rollup/plugin-html": "^1.0.2", 29 | "@rollup/plugin-node-resolve": "^13.3.0", 30 | "@types/express": "^4.17.16", 31 | "@types/react": "^17.0.0", 32 | "@types/react-dom": "^17.0.0", 33 | "@types/ws": "^8.5.4", 34 | "concurrently": "^7.6.0", 35 | "esbuild": "^0.17.4", 36 | "rollup": "^2.75.7", 37 | "rollup-plugin-esbuild": "^5.0.0", 38 | "rollup-plugin-module-replacement": "^1.2.1", 39 | "rollup-plugin-postcss": "^4.0.2", 40 | "typescript": "^4.9.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/crdt-counter/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/Collab/29668d2d70a37d0370f24ac0e0d0225ca8219eb2/packages/crdt-counter/public/favicon.ico -------------------------------------------------------------------------------- /packages/crdt-counter/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CRDT Counter 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/crdt-counter/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import path from "path"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import esbuild from "rollup-plugin-esbuild"; 6 | import replacement from "rollup-plugin-module-replacement"; 7 | import html from "@rollup/plugin-html"; 8 | import config from "@collab/config"; 9 | 10 | process.env.NODE_ENV === "production"; 11 | const banner = `var process = { 12 | env: { NODE_ENV: 'production' } 13 | };`; 14 | 15 | const htmlTemplate = ` 16 | 17 | 18 | 19 | 20 | 21 | 22 | CRDT Counter 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | `; 31 | 32 | const root = path.resolve(__dirname); 33 | export default async () => { 34 | return { 35 | input: "src/index.tsx", 36 | output: { 37 | dir: "./build", 38 | format: "iife", 39 | banner, 40 | globals: { 41 | "react": "React", 42 | "react-dom": "ReactDOM", 43 | }, 44 | }, 45 | external: ["react", "react-dom"], 46 | plugins: [ 47 | replacement({ 48 | entries: [ 49 | { 50 | find: "react/jsx-runtime", 51 | replacement: () => { 52 | return path.resolve( 53 | root, 54 | "node_modules/react/cjs/react-jsx-runtime.production.min.js" 55 | ); 56 | }, // production react/jsx-runtime 57 | }, 58 | ], 59 | }), 60 | resolve({ browser: true, preferBuiltins: false }), 61 | commonjs({ 62 | include: /node_modules/, // `monorepo regexp` 63 | }), 64 | postcss({ minimize: true, extensions: [".css", ".scss"] }), 65 | esbuild({ 66 | exclude: [/node_modules/], 67 | target: "esnext", 68 | minify: true, 69 | charset: "utf8", 70 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 71 | }), 72 | html({ 73 | template: () => htmlTemplate, 74 | }), 75 | ], 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /packages/crdt-counter/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | process.env.NODE_ENV === "production"; 5 | export default async () => { 6 | return { 7 | input: "server/index.ts", 8 | output: { 9 | file: "./build/server.js", 10 | format: "cjs", 11 | }, 12 | external: [/.*/], 13 | plugins: [ 14 | esbuild({ 15 | exclude: [/node_modules/], 16 | target: "esnext", 17 | minify: true, 18 | charset: "utf8", 19 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 20 | }), 21 | ], 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/crdt-counter/server/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import express from "express"; 3 | 4 | // https://github.com/yjs/y-webrtc/blob/master/bin/server.js 5 | exec("PORT=3001 npx y-webrtc-signaling", (err, stdout, stderr) => { 6 | console.log(stdout, stderr); 7 | }); 8 | 9 | const app = express(); 10 | app.use(express.static("build")); 11 | app.listen(3000); 12 | console.log("Listening on http://localhost:3000"); 13 | -------------------------------------------------------------------------------- /packages/crdt-counter/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Doc, Map as YMap } from "yjs"; 2 | import { WebrtcProvider } from "y-webrtc"; 3 | 4 | const getRandomId = () => Math.floor(Math.random() * 10000).toString(); 5 | export type ClientCallback = (record: Record) => void; 6 | 7 | class Connection { 8 | private doc: Doc; 9 | private map: YMap; 10 | // Local unique id 11 | public id: string = getRandomId(); 12 | // Local counter 13 | public counter = 0; 14 | 15 | constructor() { 16 | // Open WebSocket connection to Signaling server 17 | const doc = new Doc(); 18 | new WebrtcProvider("crdt-example", doc, { 19 | password: "room-password", 20 | signaling: ["ws://localhost:3001"], 21 | }); 22 | // Get Doc Map instance 23 | const yMapDoc = doc.getMap("counter"); 24 | this.doc = doc; 25 | this.map = yMapDoc; 26 | } 27 | 28 | bind(cb: ClientCallback) { 29 | this.map.observe(event => { 30 | // event.keysChanged -> the keys that have changed -> this.map.get(key) 31 | // event.changes.keys for-each -> change.action, change.oldValue, key 32 | // ... 33 | if (event.transaction.origin !== this) { 34 | const record = [...this.map.entries()].reduce( 35 | (cur, [key, value]) => ({ ...cur, [key]: value }), 36 | {} as Record 37 | ); 38 | cb(record); 39 | } 40 | }); 41 | } 42 | 43 | public increase() { 44 | this.doc.transact(() => { 45 | this.map.set(this.id, ++this.counter); 46 | }, this); 47 | } 48 | } 49 | 50 | export default new Connection(); 51 | -------------------------------------------------------------------------------- /packages/crdt-counter/src/counter.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import connection from "./client"; 3 | 4 | export const Counter: FC = () => { 5 | const [count, setCount] = useState(0); 6 | 7 | useEffect(() => { 8 | connection.bind(e => { 9 | const value = Object.values(e).reduce((acc, v) => acc + v, 0); 10 | setCount(value); 11 | }); 12 | }, []); 13 | 14 | const onClick = () => { 15 | setCount(count + 1); 16 | connection.increase(); 17 | }; 18 | 19 | return ( 20 | <> 21 | 22 |
Count: {count}
23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/crdt-counter/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { Counter } from "./counter"; 3 | 4 | ReactDOM.render(, document.getElementById("root")); 5 | -------------------------------------------------------------------------------- /packages/crdt-counter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "newLine": "lf", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "downlevelIteration": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "baseUrl":".", 25 | }, 26 | "include": [ 27 | "src/**/*", 28 | "server/**/*", 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/crdt-quill/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | vercel.json 3 | -------------------------------------------------------------------------------- /packages/crdt-quill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crdt-quill", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "concurrently 'npm run fe' 'npm run server'", 8 | "fe": "mkdir -p build/static && cp -R public/ ./build/static && rollup -wc", 9 | "server": "rollup --config rollup.server.js && node build/server.js", 10 | "build:vercel": "chmod 755 vercel/deploy.sh && vercel/deploy.sh", 11 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "express": "^4.18.2", 18 | "quill-cursors": "^4.0.2", 19 | "quill-delta": "^4.2.2", 20 | "tinycolor2": "^1.5.2", 21 | "y-protocols": "^1.0.5", 22 | "y-websocket": "^1.4.6", 23 | "yjs": "^13.5.48" 24 | }, 25 | "devDependencies": { 26 | "@rollup/plugin-commonjs": "^24.0.1", 27 | "@rollup/plugin-html": "^1.0.2", 28 | "@rollup/plugin-node-resolve": "^13.3.0", 29 | "@rollup/plugin-replace": "^5.0.2", 30 | "@types/express": "^4.17.16", 31 | "@types/node": "^20.14.11", 32 | "@types/quill": "^2.0.10", 33 | "@types/tinycolor2": "^1.4.3", 34 | "@vercel/node": "^2.15.8", 35 | "concurrently": "^7.6.0", 36 | "esbuild": "^0.17.4", 37 | "postcss": "^8.3.3", 38 | "rollup": "^2.75.7", 39 | "rollup-plugin-esbuild": "^5.0.0", 40 | "rollup-plugin-postcss": "^4.0.2", 41 | "typescript": "^4.9.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/crdt-quill/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/Collab/29668d2d70a37d0370f24ac0e0d0225ca8219eb2/packages/crdt-quill/public/favicon.ico -------------------------------------------------------------------------------- /packages/crdt-quill/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import path from "path"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import esbuild from "rollup-plugin-esbuild"; 6 | import html from "@rollup/plugin-html"; 7 | import replace from "@rollup/plugin-replace"; 8 | 9 | const template = ` 10 | 11 | 12 | 13 | 14 | 15 | 16 | CRDT Quill 17 | 18 | 19 | 20 |
21 | 22 | 23 | `; 24 | 25 | export default async () => { 26 | return { 27 | input: "src/index.ts", 28 | output: { dir: "./build/static", format: "iife" }, 29 | plugins: [ 30 | resolve({ browser: true, preferBuiltins: false }), 31 | commonjs({ 32 | include: /node_modules/, // `monorepo regexp` 33 | }), 34 | replace({ 35 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 36 | "preventAssignment": true, 37 | }), 38 | postcss({ minimize: true, extensions: [".css", ".scss"] }), 39 | esbuild({ 40 | exclude: [/node_modules/], 41 | target: "esnext", 42 | minify: true, 43 | charset: "utf8", 44 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 45 | }), 46 | html({ 47 | template: () => template, 48 | }), 49 | ], 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/crdt-quill/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | export default async () => { 5 | return { 6 | input: { 7 | server: "server/index.ts", 8 | }, 9 | output: { 10 | dir: "./build/", 11 | format: "cjs", 12 | exports: "auto", 13 | }, 14 | external: [/.*/], 15 | plugins: [ 16 | esbuild({ 17 | exclude: [/node_modules/], 18 | target: "esnext", 19 | minify: true, 20 | charset: "utf8", 21 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 22 | }), 23 | ], 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/crdt-quill/server/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import express from "express"; 3 | import path from "path"; 4 | 5 | // https://github.com/yjs/y-websocket/blob/master/bin/server.js 6 | exec("PORT=3001 npx y-websocket", (err, stdout, stderr) => { 7 | console.log(stdout, stderr); 8 | }); 9 | 10 | const app = express(); 11 | console.log("first", path.join(__dirname, "static")); 12 | app.use("/", express.static(path.join(__dirname, "static"))); 13 | 14 | app.listen(3000); 15 | console.log("Listening on http://localhost:3000"); 16 | -------------------------------------------------------------------------------- /packages/crdt-quill/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Doc, Text as YText } from "yjs"; 2 | import { WebsocketProvider } from "y-websocket"; 3 | 4 | class Connection { 5 | public doc: Doc; 6 | public type: YText; 7 | private connection: WebsocketProvider; 8 | public awareness: WebsocketProvider["awareness"]; 9 | 10 | constructor() { 11 | const doc = new Doc(); 12 | const provider = new WebsocketProvider("ws://localhost:3001", "crdt-quill", doc); 13 | provider.on("status", (e: { status: string }) => { 14 | console.log("WebSocket Status", e.status); 15 | }); 16 | this.doc = doc; 17 | this.type = doc.getText("quill"); 18 | this.connection = provider; 19 | this.awareness = provider.awareness; 20 | } 21 | 22 | reconnect() { 23 | this.connection.connect(); 24 | } 25 | 26 | disconnect() { 27 | this.connection.disconnect(); 28 | } 29 | } 30 | 31 | export default new Connection(); 32 | -------------------------------------------------------------------------------- /packages/crdt-quill/src/index.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable-next-line selector-max-id */ 2 | #user { 3 | margin-bottom: 10px; 4 | } 5 | -------------------------------------------------------------------------------- /packages/crdt-quill/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import quill, { getRandomId, updateCursor, Sources, getCursorColor } from "./quill"; 3 | import client from "./client"; 4 | import Delta from "quill-delta"; 5 | import QuillCursors from "quill-cursors"; 6 | import { compareRelativePositions, createRelativePositionFromTypeIndex } from "yjs"; 7 | 8 | const userId = getRandomId(); // Current useId // Or using awareness.clientID 9 | const doc = client.doc; 10 | const type = client.type; 11 | const cursors = quill.getModule("cursors") as QuillCursors; 12 | const awareness = client.awareness; 13 | 14 | // Local awareness user information 15 | awareness.setLocalStateField("user", { 16 | name: "User: " + userId, 17 | color: getCursorColor(userId), 18 | }); 19 | 20 | // Page user information 21 | const userNode = document.getElementById("user") as HTMLInputElement; 22 | userNode && (userNode.value = "User: " + userId); 23 | 24 | type.observe(event => { 25 | // Source Tag // Remote `UpdateContents` should not trigger 'ApplyDelta' 26 | if (event.transaction.origin !== userId) { 27 | const delta = event.delta; 28 | // May be required to explicitly set attributes 29 | quill.updateContents(new Delta(delta), "api"); 30 | } 31 | }); 32 | 33 | quill.on("editor-change", (_: string, delta: Delta, state: Delta, origin: Sources) => { 34 | if (delta && delta.ops) { 35 | // Source Tag // Local `ApplyDelta` should not trigger `UpdateContents` 36 | if (origin !== "api") { 37 | doc.transact(() => { 38 | type.applyDelta(delta.ops); 39 | }, userId); 40 | } 41 | } 42 | 43 | const sel = quill.getSelection(); 44 | const aw = awareness.getLocalState(); 45 | // Lose focus 46 | if (sel === null) { 47 | if (awareness.getLocalState() !== null) { 48 | awareness.setLocalStateField("cursor", null); 49 | } 50 | } else { 51 | // AbsolutePosition to RelativePosition 52 | const focus = createRelativePositionFromTypeIndex(type, sel.index); 53 | const anchor = createRelativePositionFromTypeIndex(type, sel.index + sel.length); 54 | if ( 55 | !aw || 56 | !aw.cursor || 57 | !compareRelativePositions(focus, aw.cursor.focus) || 58 | !compareRelativePositions(anchor, aw.cursor.anchor) 59 | ) { 60 | awareness.setLocalStateField("cursor", { focus, anchor }); 61 | } 62 | } 63 | // update all remote cursor locations 64 | awareness.getStates().forEach((aw, clientId) => { 65 | updateCursor(cursors, aw, clientId, doc, type); 66 | }); 67 | }); 68 | 69 | // Initialize all cursor values 70 | awareness.getStates().forEach((state, clientId) => { 71 | updateCursor(cursors, state, clientId, doc, type); 72 | }); 73 | // Listen to remote and local state changes 74 | awareness.on( 75 | "change", 76 | ({ added, removed, updated }: { added: number[]; removed: number[]; updated: number[] }) => { 77 | const states = awareness.getStates(); 78 | added.forEach(id => { 79 | const state = states.get(id); 80 | state && updateCursor(cursors, state, id, doc, type); 81 | }); 82 | updated.forEach(id => { 83 | const state = states.get(id); 84 | state && updateCursor(cursors, state, id, doc, type); 85 | }); 86 | removed.forEach(id => { 87 | cursors.removeCursor(id.toString()); 88 | }); 89 | } 90 | ); 91 | -------------------------------------------------------------------------------- /packages/crdt-quill/src/quill.ts: -------------------------------------------------------------------------------- 1 | import "quill/dist/quill.snow.css"; 2 | import Quill from "quill"; 3 | import QuillCursors from "quill-cursors"; 4 | import tinyColor from "tinycolor2"; 5 | import { Awareness } from "y-protocols/awareness.js"; 6 | import { 7 | Doc, 8 | Text as YText, 9 | createAbsolutePositionFromRelativePosition, 10 | createRelativePositionFromJSON, 11 | } from "yjs"; 12 | export type { Sources } from "quill"; 13 | 14 | Quill.register("modules/cursors", QuillCursors); 15 | 16 | export default new Quill("#editor", { 17 | theme: "snow", 18 | modules: { cursors: true }, 19 | }); 20 | 21 | const COLOR_MAP: Record = {}; 22 | 23 | export const getRandomId = () => Math.floor(Math.random() * 10000).toString(); 24 | 25 | export const getCursorColor = (id: string) => { 26 | COLOR_MAP[id] = COLOR_MAP[id] || tinyColor.random().toHexString(); 27 | return COLOR_MAP[id]; 28 | }; 29 | 30 | export const updateCursor = ( 31 | cursor: QuillCursors, 32 | state: Awareness["states"] extends Map ? I : never, 33 | clientId: number, 34 | doc: Doc, 35 | type: YText 36 | ) => { 37 | try { 38 | if (state && state.cursor && clientId !== doc.clientID) { 39 | const user = state.user || {}; 40 | const color = user.color || "#aaa"; 41 | const name = user.name || `User: ${clientId}`; 42 | cursor.createCursor(clientId.toString(), name, color); 43 | // RelativePosition to AbsolutePosition 44 | const focus = createAbsolutePositionFromRelativePosition( 45 | createRelativePositionFromJSON(state.cursor.focus), 46 | doc 47 | ); 48 | const anchor = createAbsolutePositionFromRelativePosition( 49 | createRelativePositionFromJSON(state.cursor.anchor), 50 | doc 51 | ); 52 | if (focus && anchor && focus.type === type) { 53 | cursor.moveCursor(clientId.toString(), { 54 | index: focus.index, 55 | length: anchor.index - focus.index, 56 | }); 57 | } 58 | } else { 59 | cursor.removeCursor(clientId.toString()); 60 | } 61 | } catch (err) { 62 | console.error(err); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /packages/crdt-quill/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "newLine": "lf", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "downlevelIteration": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "baseUrl":".", 25 | }, 26 | "include": [ 27 | "./src/**/*", 28 | "./server/**/*" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/crdt-quill/vercel/deploy.sh: -------------------------------------------------------------------------------- 1 | set -ex 2 | 3 | mkdir -p build/static 4 | cp ./vercel/vercel.json ./ 5 | cp ./vercel/server.js build/ 6 | cp -R public/ ./build/static 7 | 8 | npx rollup --config ./vercel/rollup.config.js 9 | 10 | -------------------------------------------------------------------------------- /packages/crdt-quill/vercel/rollup.config.js: -------------------------------------------------------------------------------- 1 | import getConfig from "../rollup.config.js"; 2 | import replace from "@rollup/plugin-replace"; 3 | 4 | export default async () => { 5 | const config = await getConfig(); 6 | return { 7 | ...config, 8 | plugins: [ 9 | ...config.plugins, 10 | replace({ 11 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 12 | "ws://localhost:3001": 'wss://" + location.host + "/api/ws', 13 | "preventAssignment": true, 14 | }), 15 | ], 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/crdt-quill/vercel/server.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws"); 2 | const http = require("http"); 3 | const wss = new WebSocket.Server({ noServer: true }); 4 | const setupWSConnection = require("y-websocket/bin/utils").setupWSConnection; 5 | 6 | const server = http.createServer((request, response) => { 7 | response.writeHead(200, { "Content-Type": "text/plain" }); 8 | response.end("okay"); 9 | }); 10 | 11 | wss.on("connection", setupWSConnection); 12 | 13 | server.on("upgrade", (request, socket, head) => { 14 | // You may check auth of request here.. 15 | // See https://github.com/websockets/ws#client-authentication 16 | /** 17 | * @param {any} ws 18 | */ 19 | const handleAuth = ws => { 20 | wss.emit("connection", ws, request); 21 | }; 22 | wss.handleUpgrade(request, socket, head, handleAuth); 23 | }); 24 | 25 | module.exports = server; 26 | // server.listen(3001); 27 | -------------------------------------------------------------------------------- /packages/crdt-quill/vercel/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "installCommand": "pnpm install", 3 | "outputDirectory": "build", 4 | "buildCommand": "npm run build:vercel", 5 | "builds": [ 6 | { "src": "build/server.js", "use": "@vercel/node" }, 7 | { "src": "build/static/**", "use": "@vercel/static" } 8 | ], 9 | "routes": [ 10 | { "src": "/api/(.*)", "dest": "build/server.js" }, 11 | { "src": "/", "dest": "build/static/index.html" }, 12 | { "src": "/(.+)", "dest": "build/static/$1" } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/ot-counter/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /packages/ot-counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ot-counter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "concurrently 'npm run fe' 'npm run server'", 8 | "fe": "mkdir -p build && cp -R public/ ./build/ && rollup -wc", 9 | "server": "rollup --config rollup.server.js && node build/server.js", 10 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@collab/config": "workspace:*", 17 | "@teamwork/websocket-json-stream": "^2.0.0", 18 | "events": "^3.3.0", 19 | "express": "^4.18.2", 20 | "react": "^17.0.0", 21 | "react-dom": "^17.0.0", 22 | "reconnecting-websocket": "^4.4.0", 23 | "sharedb": "^3.2.1", 24 | "ws": "^8.12.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.20.12", 28 | "@babel/preset-env": "^7.20.2", 29 | "@babel/preset-react": "^7.18.6", 30 | "@rollup/plugin-commonjs": "^24.0.1", 31 | "@rollup/plugin-html": "^1.0.2", 32 | "@rollup/plugin-node-resolve": "^13.3.0", 33 | "@types/express": "^4.17.16", 34 | "@types/react": "^17.0.0", 35 | "@types/react-dom": "^17.0.0", 36 | "@types/sharedb": "^3.2.1", 37 | "@types/ws": "^8.5.4", 38 | "concurrently": "^7.6.0", 39 | "esbuild": "^0.17.4", 40 | "rollup": "^2.75.7", 41 | "rollup-plugin-esbuild": "^5.0.0", 42 | "rollup-plugin-module-replacement": "^1.2.1", 43 | "rollup-plugin-postcss": "^4.0.2", 44 | "typescript": "^4.9.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/ot-counter/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/Collab/29668d2d70a37d0370f24ac0e0d0225ca8219eb2/packages/ot-counter/public/favicon.ico -------------------------------------------------------------------------------- /packages/ot-counter/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | OT Counter 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/ot-counter/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import path from "path"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import esbuild from "rollup-plugin-esbuild"; 6 | import replacement from "rollup-plugin-module-replacement"; 7 | import html from "@rollup/plugin-html"; 8 | import config from "@collab/config"; 9 | 10 | process.env.NODE_ENV === "production"; 11 | const banner = `var process = { 12 | env: { NODE_ENV: 'production' } 13 | };`; 14 | 15 | const htmlTemplate = ` 16 | 17 | 18 | 19 | 20 | 21 | 22 | OT Counter 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | `; 31 | 32 | const root = path.resolve(__dirname); 33 | export default async () => { 34 | return { 35 | input: "src/index.tsx", 36 | output: { 37 | dir: "./build", 38 | format: "iife", 39 | banner, 40 | globals: { 41 | "react": "React", 42 | "react-dom": "ReactDOM", 43 | }, 44 | }, 45 | external: ["react", "react-dom"], 46 | plugins: [ 47 | replacement({ 48 | entries: [ 49 | { 50 | find: "react/jsx-runtime", 51 | replacement: () => { 52 | return path.resolve( 53 | root, 54 | "node_modules/react/cjs/react-jsx-runtime.production.min.js" 55 | ); 56 | }, // production react/jsx-runtime 57 | }, 58 | ], 59 | }), 60 | resolve({ browser: true, preferBuiltins: false }), 61 | commonjs({ 62 | include: /node_modules/, // `monorepo regexp` 63 | }), 64 | postcss({ minimize: true, extensions: [".css", ".scss"] }), 65 | esbuild({ 66 | exclude: [/node_modules/], 67 | target: "esnext", 68 | minify: true, 69 | charset: "utf8", 70 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 71 | }), 72 | html({ 73 | template: () => htmlTemplate, 74 | }), 75 | ], 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /packages/ot-counter/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | process.env.NODE_ENV === "production"; 5 | export default async () => { 6 | return { 7 | input: "server/index.ts", 8 | output: { 9 | file: "./build/server.js", 10 | format: "cjs", 11 | }, 12 | external: [/.*/], 13 | plugins: [ 14 | esbuild({ 15 | exclude: [/node_modules/], 16 | target: "esnext", 17 | minify: true, 18 | charset: "utf8", 19 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 20 | }), 21 | ], 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/ot-counter/server/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import express from "express"; 3 | import ShareDB from "sharedb"; 4 | import WebSocket from "ws"; 5 | import WebSocketJSONStream from "@teamwork/websocket-json-stream"; 6 | 7 | const backend = new ShareDB(); 8 | 9 | // Create initial document then fire callback 10 | function start(callback: () => void) { 11 | const connection = backend.connect(); 12 | const doc = connection.get("ot-example", "counter"); 13 | doc.fetch(err => { 14 | if (err) throw err; 15 | if (doc.type === null) { 16 | doc.create({ num: 0 }, callback); 17 | return; 18 | } 19 | callback(); 20 | }); 21 | } 22 | 23 | function server() { 24 | const app = express(); 25 | app.use(express.static("build")); 26 | const server = http.createServer(app); 27 | 28 | // Connect any incoming WebSocket connection to ShareDB 29 | const wss = new WebSocket.Server({ server: server }); 30 | wss.on("connection", ws => { 31 | const stream = new WebSocketJSONStream(ws); 32 | backend.listen(stream); 33 | }); 34 | 35 | server.listen(3000); 36 | console.log("Listening on http://localhost:3000"); 37 | } 38 | 39 | start(server); 40 | -------------------------------------------------------------------------------- /packages/ot-counter/server/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@teamwork/websocket-json-stream" { 2 | import { Duplex } from "stream"; 3 | import WebSocket from "ws"; 4 | class WebSocketJSONStream extends Duplex { 5 | constructor(ws: WebSocket); 6 | } 7 | export default WebSocketJSONStream; 8 | } 9 | -------------------------------------------------------------------------------- /packages/ot-counter/src/client.ts: -------------------------------------------------------------------------------- 1 | import ReconnectingWebSocket from "reconnecting-websocket"; 2 | import sharedb from "sharedb/lib/client"; 3 | import { Socket } from "sharedb/lib/sharedb"; 4 | 5 | export type ClientCallback = (num: number) => void; 6 | 7 | class Connection { 8 | private connection: sharedb.Connection; 9 | 10 | constructor() { 11 | // Open WebSocket connection to ShareDB server 12 | const socket = new ReconnectingWebSocket("ws://localhost:3000"); 13 | this.connection = new sharedb.Connection(socket as Socket); 14 | } 15 | 16 | bind(cb: ClientCallback) { 17 | // Create local Doc instance mapped to 'ot-example' collection document with id 'counter' 18 | // collectionName, documentID 19 | const doc = this.connection.get("ot-example", "counter"); 20 | // Get initial value of document and subscribe to changes 21 | const onSubscribe = () => cb(doc.data.num); 22 | doc.subscribe(onSubscribe); 23 | // When document changes (by this client or any other, or the server), 24 | // update the number on the page 25 | const onOpExec = () => cb(doc.data.num); 26 | doc.on("op", onOpExec); 27 | return { 28 | // When clicking on the '+1' button, change the number in the local 29 | // document and sync the change to the server and other connected 30 | // clients 31 | increase: () => { 32 | // Increment `doc.data.num`. See 33 | // https://github.com/ottypes/json0 for list of valid operations. 34 | doc.submitOp([{ p: ["num"], na: 1 }]); 35 | }, 36 | unbind: () => { 37 | doc.off("op", onOpExec); 38 | doc.unsubscribe(onSubscribe); 39 | doc.destroy(); 40 | }, 41 | }; 42 | } 43 | 44 | destroy() { 45 | this.connection.close(); 46 | } 47 | } 48 | 49 | export default new Connection(); 50 | -------------------------------------------------------------------------------- /packages/ot-counter/src/counter.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useRef, useState } from "react"; 2 | import connection from "./client"; 3 | 4 | export const Counter: FC = () => { 5 | const [count, setCount] = useState(0); 6 | const op = useRef<(() => void) | null>(null); 7 | 8 | useEffect(() => { 9 | const { unbind, increase } = connection.bind(setCount); 10 | op.current = increase; 11 | return () => unbind(); 12 | }, []); 13 | 14 | return ( 15 | <> 16 |
Count: {count}
17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/ot-counter/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { Counter } from "./counter"; 3 | 4 | ReactDOM.render(, document.getElementById("root")); 5 | -------------------------------------------------------------------------------- /packages/ot-counter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "newLine": "lf", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "downlevelIteration": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "baseUrl":".", 25 | }, 26 | "include": [ 27 | "src/**/*", 28 | "server/**/*", 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/ot-quill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ot-quill", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "concurrently 'npm run fe' 'npm run server'", 8 | "fe": "mkdir -p build && cp -R public/ ./build/ && rollup -wc", 9 | "server": "rollup --config rollup.server.js && node build/server.js", 10 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@teamwork/websocket-json-stream": "^2.0.0", 17 | "express": "^4.18.2", 18 | "quill": "^1.3.7", 19 | "quill-cursors": "^4.0.2", 20 | "quill-delta": "^4.2.2", 21 | "reconnecting-websocket": "^4.4.0", 22 | "rich-text": "^4.1.0", 23 | "sharedb": "^3.2.1", 24 | "tinycolor2": "^1.5.2", 25 | "ws": "^8.12.0" 26 | }, 27 | "devDependencies": { 28 | "@rollup/plugin-commonjs": "^24.0.1", 29 | "@rollup/plugin-html": "^1.0.2", 30 | "@rollup/plugin-node-resolve": "^13.3.0", 31 | "@types/express": "^4.17.16", 32 | "@types/node": "^20.14.11", 33 | "@types/quill": "^2.0.10", 34 | "@types/sharedb": "^3.2.1", 35 | "@types/tinycolor2": "^1.4.3", 36 | "@types/ws": "^8.5.4", 37 | "concurrently": "^7.6.0", 38 | "esbuild": "^0.17.4", 39 | "rollup": "^2.75.7", 40 | "rollup-plugin-esbuild": "^5.0.0", 41 | "rollup-plugin-module-replacement": "^1.2.1", 42 | "rollup-plugin-postcss": "^4.0.2", 43 | "typescript": "^4.9.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/ot-quill/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/Collab/29668d2d70a37d0370f24ac0e0d0225ca8219eb2/packages/ot-quill/public/favicon.ico -------------------------------------------------------------------------------- /packages/ot-quill/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import path from "path"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import esbuild from "rollup-plugin-esbuild"; 6 | import html from "@rollup/plugin-html"; 7 | 8 | process.env.NODE_ENV === "production"; 9 | const banner = `var process = { 10 | env: { NODE_ENV: 'production' } 11 | };`; 12 | 13 | const template = ` 14 | 15 | 16 | 17 | 18 | 19 | 20 | OT Quill 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | `; 29 | 30 | export default async () => { 31 | return { 32 | input: "src/index.ts", 33 | output: { 34 | dir: "./build", 35 | format: "iife", 36 | banner, 37 | }, 38 | plugins: [ 39 | resolve({ browser: true, preferBuiltins: false }), 40 | commonjs({ 41 | include: /node_modules/, // `monorepo regexp` 42 | }), 43 | postcss({ minimize: true, extensions: [".css", ".scss"] }), 44 | esbuild({ 45 | exclude: [/node_modules/], 46 | target: "esnext", 47 | minify: true, 48 | charset: "utf8", 49 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 50 | }), 51 | html({ 52 | template: () => template, 53 | }), 54 | ], 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/ot-quill/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | process.env.NODE_ENV === "production"; 5 | export default async () => { 6 | return { 7 | input: "server/index.ts", 8 | output: { 9 | file: "./build/server.js", 10 | format: "cjs", 11 | }, 12 | external: [/.*/], 13 | plugins: [ 14 | esbuild({ 15 | exclude: [/node_modules/], 16 | target: "esnext", 17 | minify: true, 18 | charset: "utf8", 19 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 20 | }), 21 | ], 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/ot-quill/server/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import express from "express"; 3 | import ShareDB from "sharedb"; 4 | import WebSocket from "ws"; 5 | import WebSocketJSONStream from "@teamwork/websocket-json-stream"; 6 | import richText from "rich-text"; 7 | 8 | ShareDB.types.register(richText.type); 9 | const backend = new ShareDB({ presence: true, doNotForwardSendPresenceErrorsToClient: true }); 10 | 11 | // Create initial document then fire callback 12 | function start(callback: () => void) { 13 | const connection = backend.connect(); 14 | const doc = connection.get("ot-example", "quill"); 15 | doc.fetch(err => { 16 | if (err) throw err; 17 | if (doc.type === null) { 18 | doc.create([{ insert: "OT Quill" }], "rich-text", callback); 19 | return; 20 | } 21 | callback(); 22 | }); 23 | } 24 | 25 | function server() { 26 | // Create a web server to serve files and listen to WebSocket connections 27 | const app = express(); 28 | app.use(express.static("build")); 29 | app.use(express.static("node_modules/quill/dist")); 30 | const server = http.createServer(app); 31 | 32 | // Connect any incoming WebSocket connection to ShareDB 33 | const wss = new WebSocket.Server({ server: server }); 34 | wss.on("connection", function (ws) { 35 | const stream = new WebSocketJSONStream(ws); 36 | backend.listen(stream); 37 | }); 38 | 39 | server.listen(3000); 40 | console.log("Listening on http://localhost:3000"); 41 | } 42 | 43 | start(server); 44 | -------------------------------------------------------------------------------- /packages/ot-quill/server/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@teamwork/websocket-json-stream" { 2 | import { Duplex } from "stream"; 3 | import WebSocket from "ws"; 4 | class WebSocketJSONStream extends Duplex { 5 | constructor(ws: WebSocket); 6 | } 7 | export default WebSocketJSONStream; 8 | } 9 | -------------------------------------------------------------------------------- /packages/ot-quill/src/client.ts: -------------------------------------------------------------------------------- 1 | import ReconnectingWebSocket from "reconnecting-websocket"; 2 | import sharedb from "sharedb/lib/client"; 3 | import { Socket } from "sharedb/lib/sharedb"; 4 | import richText from "rich-text"; 5 | import type Delta from "quill-delta"; 6 | 7 | const collection = "ot-example"; 8 | const id = "quill"; 9 | 10 | class Connection { 11 | public doc: sharedb.Doc; 12 | private connection: sharedb.Connection; 13 | 14 | constructor() { 15 | sharedb.types.register(richText.type); 16 | // Open WebSocket connection to ShareDB server 17 | const socket = new ReconnectingWebSocket("ws://localhost:3000"); 18 | this.connection = new sharedb.Connection(socket as Socket); 19 | this.doc = this.connection.get(collection, id); 20 | } 21 | 22 | getDocPresence() { 23 | return this.connection.getDocPresence(collection, id); 24 | } 25 | 26 | destroy() { 27 | this.doc.destroy(); 28 | this.connection.close(); 29 | } 30 | } 31 | 32 | export default new Connection(); 33 | -------------------------------------------------------------------------------- /packages/ot-quill/src/index.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable-next-line selector-max-id */ 2 | #user { 3 | margin-bottom: 10px; 4 | } 5 | -------------------------------------------------------------------------------- /packages/ot-quill/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import quill, { getCursorColor, getRandomId } from "./quill"; 3 | import client from "./client"; 4 | import type Delta from "quill-delta"; 5 | 6 | const presenceId = getRandomId(); 7 | const doc = client.doc; 8 | 9 | const userNode = document.getElementById("user") as HTMLInputElement; 10 | userNode && (userNode.value = "User: " + presenceId); 11 | 12 | doc.subscribe(err => { 13 | if (err) { 14 | console.log("DOC SUBSCRIBE ERROR", err); 15 | return; 16 | } 17 | const cursors = quill.getModule("cursors"); 18 | quill.setContents(doc.data); 19 | 20 | quill.on("text-change", (delta, oldDelta, source) => { 21 | if (source !== "user") return; 22 | doc.submitOp(delta); 23 | }); 24 | 25 | doc.on("op", (op, source) => { 26 | if (source) return; 27 | quill.updateContents(op as unknown as Delta); 28 | }); 29 | 30 | const presence = client.getDocPresence(); 31 | presence.subscribe(error => { 32 | if (error) console.log("PRESENCE SUBSCRIBE ERROR", err); 33 | }); 34 | const localPresence = presence.create(presenceId); 35 | 36 | quill.on("selection-change", (range, oldRange, source) => { 37 | // We only need to send updates if the user moves the cursor 38 | // themselves. Cursor updates as a result of text changes will 39 | // automatically be handled by the remote client. 40 | if (source !== "user") return; 41 | // Ignore blurring, so that we can see lots of users in the 42 | // same window. In real use, you may want to clear the cursor. 43 | if (!range) return; 44 | localPresence.submit(range, error => { 45 | if (error) console.log("LOCAL PRESENCE SUBSCRIBE ERROR", err); 46 | }); 47 | }); 48 | 49 | presence.on("receive", (id, range) => { 50 | const color = getCursorColor(id); 51 | const name = "User: " + id; 52 | cursors.createCursor(id, name, color); 53 | cursors.moveCursor(id, range); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/ot-quill/src/quill.ts: -------------------------------------------------------------------------------- 1 | import Quill from "quill"; 2 | import QuillCursors from "quill-cursors"; 3 | import tinyColor from "tinycolor2"; 4 | 5 | Quill.register("modules/cursors", QuillCursors); 6 | 7 | export default new Quill("#editor", { 8 | theme: "snow", 9 | modules: { cursors: true }, 10 | }); 11 | 12 | const COLOR_MAP: Record = {}; 13 | 14 | export const getRandomId = () => Math.floor(Math.random() * 10000).toString(); 15 | 16 | export const getCursorColor = (id: string) => { 17 | COLOR_MAP[id] = COLOR_MAP[id] || tinyColor.random().toHexString(); 18 | return COLOR_MAP[id]; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/ot-quill/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "rich-text" { 2 | import Delta, { Op } from "quill-delta"; 3 | 4 | const name: string; 5 | const uri: string; 6 | 7 | function create(initial?: Op[] | { ops: Op[] }): Delta; 8 | function compose(delta1: Op[] | { ops: Op[] }, delta2: Op[] | { ops: Op[] }): Delta; 9 | function diff(delta1: Op[] | { ops: Op[] }, delta2: Op[] | { ops: Op[] }): Delta; 10 | 11 | function transform( 12 | delta1: Op[] | { ops: Op[] }, 13 | delta2: Op[] | { ops: Op[] }, 14 | side: string 15 | ): Delta | number; 16 | 17 | function transformCursor(cursor: number, delta: Delta, isOwnOp: boolean): number; 18 | 19 | function normalize(delta: Delta): Delta; 20 | function serialize(delta: Delta): Op[]; 21 | function deserialize(ops: Op[]): Delta; 22 | 23 | interface IRange { 24 | index: number; 25 | length: number; 26 | } 27 | 28 | function transformPresence(range: IRange, op: Op, isOwnOp: boolean): IRange; 29 | 30 | const type = { 31 | name, 32 | uri, 33 | create, 34 | apply, 35 | compose, 36 | diff, 37 | transform, 38 | transformCursor, 39 | normalize, 40 | serialize, 41 | deserialize, 42 | transformPresence, 43 | }; 44 | 45 | module.exports = { 46 | Delta, 47 | type, 48 | }; 49 | 50 | export default { 51 | Delta, 52 | type, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /packages/ot-quill/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "newLine": "lf", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "downlevelIteration": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "baseUrl":".", 25 | }, 26 | "include": [ 27 | "./src/**/*", 28 | "./server/**/*" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | --------------------------------------------------------------------------------