├── .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 |
--------------------------------------------------------------------------------