├── tsconfig.prod.json ├── types └── react-quill.d.ts ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── index.css ├── automerge.d.ts ├── service │ ├── UserVersion.ts │ ├── Cursor.ts │ ├── Socket.ts │ └── History.ts ├── App.test.tsx ├── App.tsx ├── index.tsx ├── App.css ├── components │ ├── ActiveUsers.tsx │ ├── Document.css │ ├── ActiveUsers.css │ └── Document.tsx ├── crdt │ ├── Char.ts │ └── Sequence.ts ├── test │ └── sequence.test.js ├── logo.svg └── registerServiceWorker.ts ├── tsconfig.test.json ├── README.md ├── tslint.json ├── .gitignore ├── tsconfig.json └── package.json /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /types/react-quill.d.ts: -------------------------------------------------------------------------------- 1 | export = index; 2 | declare const index: any; 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phedkvist/crdt-sequence/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /src/automerge.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'automerge' { 2 | export function init(options?: string): object; 3 | export function change(doc: any, message: string, callback: void): any; 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CRDT Sequence is a very basic collaborative text editing implementation. I would take a look at other implementations such CRDT WOOT here: https://github.com/phedkvist/crdt-woot or find implementations of CRDT LSEQ. 2 | -------------------------------------------------------------------------------- /src/service/UserVersion.ts: -------------------------------------------------------------------------------- 1 | export default class UserVersion { 2 | constructor(userID: string) { 3 | this.userID = userID; 4 | this.clock = 1; //when its first created it starts at one. 5 | } 6 | userID: string; 7 | clock: number; 8 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [], 3 | "defaultSeverity": "warning", 4 | "linterOptions": { 5 | "exclude": [ 6 | "config/**/*.js", 7 | "node_modules/**/*.ts", 8 | "coverage/lcov-report/*.js" 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import './App.css'; 3 | import Document from './components/Document'; 4 | 5 | 6 | class App extends React.Component { 7 | public render() { 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render( 8 | , 9 | document.getElementById('root') as HTMLElement 10 | ); 11 | registerServiceWorker(); 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | /server/node_modules 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | margin-top: 100px; 3 | margin-right: auto; 4 | margin-left: auto; 5 | } 6 | 7 | .App { 8 | text-align: center; 9 | } 10 | 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | height: 80px; 14 | } 15 | 16 | .App-header { 17 | background-color: #222; 18 | height: 150px; 19 | padding: 20px; 20 | color: white; 21 | } 22 | 23 | .App-title { 24 | font-size: 1.5em; 25 | } 26 | 27 | .App-intro { 28 | font-size: large; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { transform: rotate(0deg); } 33 | to { transform: rotate(360deg); } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ActiveUsers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Cursor from 'src/service/Cursor'; 3 | import './ActiveUsers.css'; 4 | 5 | interface IState { 6 | 7 | } 8 | 9 | interface IProps { 10 | users: Array; 11 | } 12 | 13 | class ActiveUsers extends React.Component { 14 | 15 | public render() { 16 | let userAvatars = this.props.users.map(user => { 17 | return ( 18 |
19 | {user.name[0]} 20 |
{user.name}
21 |
22 | ); 23 | }) 24 | 25 | return ( 26 | userAvatars 27 | ); 28 | } 29 | } 30 | 31 | export default ActiveUsers; -------------------------------------------------------------------------------- /src/service/Cursor.ts: -------------------------------------------------------------------------------- 1 | const USER_COLORS = ['blue', 'green', 'red', 'orange']; 2 | const USER_NAMES = ['Barack Obama', 'Donald Trump', 'Angela Merkel', 'Vladimir Puttin'] 3 | 4 | export default class Cursor { 5 | constructor(userID: string, index: number, length: number, userCount: number) { 6 | this.userID = userID; 7 | this.index = 1; 8 | this.length = 0; 9 | this.color = userCount <= 3 ? USER_COLORS[userCount] : USER_COLORS[0]; 10 | this.name = userCount <= 3 ? USER_NAMES[userCount] : USER_NAMES[0]; 11 | } 12 | userID: string; 13 | index: number; 14 | length: number; 15 | color: string; 16 | name: string; 17 | 18 | updateRange(index: number, length: number) { 19 | this.index = index; 20 | this.length = length; 21 | } 22 | } -------------------------------------------------------------------------------- /src/components/Document.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | } 5 | 6 | .ql-container { 7 | min-height:300px; 8 | } 9 | 10 | .docForm { 11 | font-size: 22px; 12 | margin-top:50px; 13 | border-radius: 5px; 14 | border: 2px solid lightblue; 15 | width: 200px; 16 | height:400px; 17 | margin:0 auto; 18 | text-align: left; 19 | } 20 | 21 | .sequenceTable { 22 | margin:20px auto; 23 | border: 1px solid #add8e6; 24 | border-radius: 5px; 25 | } 26 | 27 | .sequenceTable th { 28 | background-color: lightcyan; 29 | } 30 | 31 | .sequenceTable tr { 32 | border: 1px solid #add8e6; 33 | } 34 | 35 | .activeUsers { 36 | display:flex; 37 | flex-direction: row-reverse; 38 | justify-content: flex-start; 39 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build/dist", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["es6", "dom"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "rootDir": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": false, 17 | "importHelpers": true, 18 | "strictNullChecks": true, 19 | "suppressImplicitAnyIndexErrors": true, 20 | "noUnusedLocals": true 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "build", 25 | "scripts", 26 | "acceptance-tests", 27 | "webpack", 28 | "jest", 29 | "src/setupTests.ts", 30 | "server.js", 31 | ] 32 | } -------------------------------------------------------------------------------- /src/components/ActiveUsers.css: -------------------------------------------------------------------------------- 1 | .circle { 2 | border-radius: 50%; 3 | width: 30px; 4 | height: 30px; 5 | line-height: 30px; 6 | margin: 4px; 7 | font-size:16px; 8 | position: relative; 9 | display: inline-block; 10 | color: rgba(100, 100, 100, 1); 11 | } 12 | 13 | .circle .tooltip { 14 | visibility: hidden; 15 | width: 120px; 16 | color: rgba(100, 100, 100, 1); 17 | text-align: center; 18 | padding: 5px 0; 19 | border-radius: 6px; 20 | font-size:16px; 21 | /* Position the tooltip text - see examples below! */ 22 | position: absolute; 23 | z-index: 1; 24 | } 25 | 26 | /* Show the tooltip text when you mouse over the tooltip container */ 27 | .circle:hover .tooltip { 28 | visibility: visible; 29 | } 30 | 31 | .red { 32 | background-color: red; 33 | } 34 | 35 | .orange { 36 | background-color: orange; 37 | } 38 | 39 | .green { 40 | background-color: rgba(0, 255, 0, 0.7); 41 | } 42 | 43 | .blue { 44 | background-color: blue; 45 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textsync", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "express": "^4.16.4", 7 | "express-favicon": "^2.0.1", 8 | "path": "^0.12.7", 9 | "quill-cursors": "^2.1.0", 10 | "react": "^16.6.3", 11 | "react-dom": "^16.6.3", 12 | "react-quill": "^1.3.3", 13 | "react-scripts-ts": "^4.0.8", 14 | "uuid": "^3.3.2", 15 | "ws": "^6.2.0" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts-ts start", 19 | "build": "react-scripts-ts build", 20 | "test": "react-scripts-ts test --env=jsdom --watchAll", 21 | "eject": "react-scripts-ts eject" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^24.0.11", 25 | "@types/node": "^10.12.10", 26 | "@types/react": "^16.7.7", 27 | "@types/react-dom": "^16.0.11", 28 | "typescript": "^3.3.4000", 29 | "typescript-eslint-parser": "^22.0.0" 30 | }, 31 | "browserslist": [ 32 | ">0.2%", 33 | "not dead", 34 | "not ie <= 11", 35 | "not op_mini all" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/crdt/Char.ts: -------------------------------------------------------------------------------- 1 | const uuidv1 = require('uuid/v1'); 2 | 3 | export default class Char { 4 | index: number; 5 | char: string; 6 | tombstone: boolean; 7 | siteID: number; 8 | bold: boolean; 9 | italic: boolean; 10 | underline: boolean; 11 | header: string; 12 | link: string; 13 | id: string; 14 | 15 | constructor(index: number, char: string, siteID: number, attributes: object, id: string = uuidv1()) { 16 | this.index = index; 17 | this.char = char; 18 | this.siteID = siteID; 19 | this.tombstone = false; 20 | this.bold = attributes !== undefined && "bold" in attributes ? attributes["bold"] : false; 21 | this.italic = attributes !== undefined && "italic" in attributes ? attributes["italic"] : false; 22 | this.underline = attributes !== undefined && "underline" in attributes ? attributes["underline"] : false; 23 | this.header = attributes !== undefined && "header" in attributes ? attributes["header"] : null; 24 | this.id = id; 25 | } 26 | 27 | update(attributes: object) { 28 | console.log('should update', attributes) 29 | this.bold = attributes !== undefined && "bold" in attributes ? attributes["bold"] : this.bold; 30 | this.italic = attributes !== undefined && "italic" in attributes ? attributes["italic"] : this.italic; 31 | this.underline = attributes !== undefined && "underline" in attributes ? attributes["underline"] : this.underline; 32 | //this.header = attributes !== undefined && "header" in attributes ? attributes["header"] : ; 33 | this.link = attributes !== undefined && "link" in attributes ? attributes["link"] : this.link; 34 | } 35 | } -------------------------------------------------------------------------------- /src/service/Socket.ts: -------------------------------------------------------------------------------- 1 | export default class Socket { 2 | ws: WebSocket; 3 | connected: boolean; 4 | remoteChange: (json: string) => void; 5 | updateConnectionState: () => void; 6 | 7 | constructor(remoteChange: (json: string) => void, updateConnectionState: () => void) { 8 | this.onMessage = this.onMessage.bind(this); 9 | this.onClose = this.onClose.bind(this); 10 | this.onOpen = this.onOpen.bind(this); 11 | this.remoteChange = remoteChange; 12 | this.updateConnectionState = updateConnectionState; 13 | 14 | this.connect(); 15 | } 16 | 17 | connect() { 18 | if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { 19 | this.ws = new WebSocket('ws://localhost:5000'); 20 | } else { 21 | this.ws = new WebSocket('wss://boiling-castle-92688.herokuapp.com'); 22 | } 23 | //this.ws = new WebSocket('wss://boiling-castle-92688.herokuapp.com'); 24 | 25 | this.ws.addEventListener('message', this.onMessage, false); 26 | this.ws.addEventListener('close', this.onClose, false); 27 | this.ws.addEventListener('open', this.onOpen, false); 28 | } 29 | 30 | disconnect() { 31 | this.ws.close(); 32 | this.connected = false; 33 | this.updateConnectionState(); 34 | } 35 | 36 | send(jsonMessage: string) { 37 | this.ws.send(jsonMessage); 38 | } 39 | 40 | onMessage(e: any) { 41 | let jsonMessage = e.data; 42 | this.remoteChange(jsonMessage); 43 | } 44 | 45 | onClose(e: any) { 46 | this.connected = false; 47 | this.updateConnectionState(); 48 | } 49 | 50 | onOpen(e: any) { 51 | this.connected = true; 52 | this.updateConnectionState(); 53 | console.log('connected'); 54 | this.send(JSON.stringify({type: 'request_init_load'})); 55 | } 56 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | CRDT Sequence 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/test/sequence.test.js: -------------------------------------------------------------------------------- 1 | import { Sequence } from '../crdt/Sequence'; 2 | import { Char } from '../crdt/Char'; 3 | 4 | describe('Sequence', () => { 5 | let sequence; 6 | let bof; 7 | let eof; 8 | beforeEach(() => { 9 | sequence = new Sequence(); 10 | bof = sequence.chars[0]; 11 | eof = sequence.chars[1]; 12 | }); 13 | 14 | test('can add abc', () => { 15 | sequence.insert(bof.index, eof.index,'a'); 16 | let relIndex = sequence.getRelativeIndex(1); 17 | sequence.insert(relIndex[0].index,relIndex[1].index, 'b', {}); 18 | relIndex = sequence.getRelativeIndex(2); 19 | sequence.insert(relIndex[0].index,relIndex[1].index, 'c', {}); 20 | 21 | expect(sequence.getSequence()).toEqual('abc'); 22 | }) 23 | 24 | test('can remove char', () => { 25 | sequence.insert(bof.index, eof.index,'a'); 26 | let relIndex = sequence.getRelativeIndex(1); 27 | sequence.delete(relIndex[0].id); 28 | 29 | expect(sequence.getSequence()).toEqual(''); 30 | }) 31 | 32 | test('can get start and end char', () => { 33 | let relIndex = sequence.getRelativeIndex(0); 34 | 35 | //sequence.pretty(); 36 | expect(relIndex[0]).toEqual(bof); 37 | expect(relIndex[1]).toEqual(eof); 38 | }) 39 | 40 | test('can add remote inserts', () => { 41 | sequence.insert(bof.index, eof.index,'a'); 42 | let relIndex = sequence.getRelativeIndex(1); 43 | sequence.insert(relIndex[0].index, relIndex[1].index, 'b', {}); 44 | relIndex = sequence.getRelativeIndex(2); 45 | sequence.insert(relIndex[0].index, relIndex[1].index, 'c', {}); 46 | 47 | let char = new Char(10, "1", 2, {}); 48 | sequence.remoteInsert(char); 49 | char = new Char(15, "2", 3, {}); 50 | sequence.remoteInsert(char); 51 | 52 | //sequence.pretty(); 53 | expect(sequence.getSequence()).toEqual('a12bc'); 54 | }) 55 | 56 | /* 57 | insert: 0 a 58 | delete: 0 59 | insert: 0 a 60 | insert: 0 61 | insert: 0 x 62 | */ 63 | test('remote inserts / deletes at the beginning', () => { 64 | let secondSequence = new Sequence(); 65 | let aChar = sequence.insert(bof.index, eof.index,'a'); 66 | secondSequence.remoteInsert(aChar); 67 | expect(sequence.getSequence()).toEqual(secondSequence.getSequence()); 68 | 69 | sequence.delete(aChar.id); 70 | secondSequence.delete(aChar.id); 71 | expect(sequence.getSequence()).toEqual(secondSequence.getSequence()); 72 | }) 73 | 74 | test.only('can generate index between integer 1 and 2', () => { 75 | let index = sequence.generateIndex(1,2); 76 | expect() 77 | }) 78 | }); -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | // In production, we register a service worker to serve assets from local cache. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on the 'N+1' visit to a page, since previously 7 | // cached resources are updated in the background. 8 | 9 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 10 | // This link also includes instructions on opting out of this behavior. 11 | 12 | const isLocalhost = Boolean( 13 | window.location.hostname === 'localhost' || 14 | // [::1] is the IPv6 localhost address. 15 | window.location.hostname === '[::1]' || 16 | // 127.0.0.1/8 is considered localhost for IPv4. 17 | window.location.hostname.match( 18 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 19 | ) 20 | ); 21 | 22 | export default function register() { 23 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 24 | // The URL constructor is available in all browsers that support SW. 25 | const publicUrl = new URL( 26 | process.env.PUBLIC_URL!, 27 | window.location.toString() 28 | ); 29 | if (publicUrl.origin !== window.location.origin) { 30 | // Our service worker won't work if PUBLIC_URL is on a different origin 31 | // from what our page is served on. This might happen if a CDN is used to 32 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 33 | return; 34 | } 35 | 36 | window.addEventListener('load', () => { 37 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 38 | 39 | if (isLocalhost) { 40 | // This is running on localhost. Lets check if a service worker still exists or not. 41 | checkValidServiceWorker(swUrl); 42 | 43 | // Add some additional logging to localhost, pointing developers to the 44 | // service worker/PWA documentation. 45 | navigator.serviceWorker.ready.then(() => { 46 | console.log( 47 | 'This web app is being served cache-first by a service ' + 48 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 49 | ); 50 | }); 51 | } else { 52 | // Is not local host. Just register service worker 53 | registerValidSW(swUrl); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | function registerValidSW(swUrl: string) { 60 | navigator.serviceWorker 61 | .register(swUrl) 62 | .then(registration => { 63 | registration.onupdatefound = () => { 64 | const installingWorker = registration.installing; 65 | if (installingWorker) { 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the old content will have been purged and 70 | // the fresh content will have been added to the cache. 71 | // It's the perfect time to display a 'New content is 72 | // available; please refresh.' message in your web app. 73 | console.log('New content is available; please refresh.'); 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // 'Content is cached for offline use.' message. 78 | console.log('Content is cached for offline use.'); 79 | } 80 | } 81 | }; 82 | } 83 | }; 84 | }) 85 | .catch(error => { 86 | console.error('Error during service worker registration:', error); 87 | }); 88 | } 89 | 90 | function checkValidServiceWorker(swUrl: string) { 91 | // Check if the service worker can be found. If it can't reload the page. 92 | fetch(swUrl) 93 | .then(response => { 94 | // Ensure service worker exists, and that we really are getting a JS file. 95 | if ( 96 | response.status === 404 || 97 | response.headers.get('content-type')!.indexOf('javascript') === -1 98 | ) { 99 | // No service worker found. Probably a different app. Reload the page. 100 | navigator.serviceWorker.ready.then(registration => { 101 | registration.unregister().then(() => { 102 | window.location.reload(); 103 | }); 104 | }); 105 | } else { 106 | // Service worker found. Proceed as normal. 107 | registerValidSW(swUrl); 108 | } 109 | }) 110 | .catch(() => { 111 | console.log( 112 | 'No internet connection found. App is running in offline mode.' 113 | ); 114 | }); 115 | } 116 | 117 | export function unregister() { 118 | if ('serviceWorker' in navigator) { 119 | navigator.serviceWorker.ready.then(registration => { 120 | registration.unregister(); 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/crdt/Sequence.ts: -------------------------------------------------------------------------------- 1 | import Char from './Char'; 2 | const uuidv1 = require('uuid/v1'); 3 | 4 | export default class Sequence { 5 | chars: Array; 6 | siteID: number; 7 | count: number; 8 | 9 | constructor() { 10 | this.chars = [new Char(0, "bof", this.siteID, {}), new Char(10000, "eof", this.siteID, {})]; 11 | this.siteID = uuidv1(); 12 | this.count = 100; 13 | } 14 | 15 | generateIndex(indexStart: number, indexEnd: number) : number { 16 | let diff = (indexEnd - indexStart); 17 | let index; 18 | if (diff <= 10) { 19 | index = indexStart + diff/100; 20 | } else if (diff <= 1000) { 21 | index = Math.round(indexStart + diff/10); 22 | } else if (diff <= 5000) { 23 | index = Math.round(indexStart + diff/100); 24 | } else { 25 | index = Math.round(indexStart + diff/1000); 26 | } 27 | return index; 28 | } 29 | 30 | compareIdentifier(c1: Char, c2: Char) { 31 | if (c1.index < c2.index) { 32 | return -1; 33 | } else if (c1.index > c2.index) { 34 | return 1; 35 | } else { 36 | if (c1.siteID < c2.siteID) { 37 | return -1; 38 | } else if (c1.siteID > c2.siteID) { 39 | return 1; 40 | } else { 41 | return 0; 42 | } 43 | } 44 | } 45 | 46 | insert(indexStart: number, indexEnd: number, char: string, attributes: object, id?: string) : Char { 47 | //TODO: Must find better way here 48 | 49 | let index = this.generateIndex(indexStart, indexEnd); 50 | console.log('Insert index:', index); 51 | //let index = this.randomIntFromInterval(indexStart, indexEnd); 52 | let charObj = (id !== undefined) ? new Char(index, char, this.siteID, attributes, id) : new Char(index, char, this.siteID, attributes); 53 | /* 54 | for (let i = 0; i < this.chars.length; i++) { 55 | let c = this.chars[i]; 56 | const compareIdentifier = this.compareIdentifier(charObj, c); 57 | console.log('comparing: ', charObj, c, ' outcome: ', compareIdentifier); 58 | if (compareIdentifier === -1 || compareIdentifier === 0) { 59 | console.log('inserting ', char, 'at: ', i); 60 | this.chars.splice(i, 0, charObj); 61 | break; 62 | } 63 | } 64 | */ 65 | this.chars.splice(index, 0, charObj); 66 | this.chars.sort(function(a,b) { 67 | return a.index - b.index; 68 | }) 69 | return charObj; 70 | } 71 | 72 | remoteInsert(char: Char) { 73 | const charCopy = new Char(char.index, char.char, char.siteID, {bold: char.bold, italic: char.italic, underline: char.underline}, char.id); 74 | this.chars.push(charCopy); 75 | this.chars.sort(function(a,b) { 76 | if(a.index == b.index) { 77 | return a.siteID - b.siteID; 78 | } else { 79 | return a.index - b.index; 80 | 81 | } 82 | }) 83 | } 84 | 85 | delete(id: string) { 86 | //console.log(id); 87 | let char = this.chars.find(e => e.id === id); 88 | if (char !== undefined) { 89 | char.tombstone = true; 90 | //console.log("removed: ", char) 91 | } else { 92 | //console.log("did not found char") 93 | } 94 | } 95 | 96 | remoteRetain(charCopy: Char) { 97 | let char = this.chars.find(c => c.id === charCopy.id); 98 | if (char !== undefined) { 99 | char.update({ 100 | bold: charCopy.bold, italic: charCopy.italic, 101 | underline: charCopy.underline, link: charCopy.link 102 | }); 103 | } 104 | } 105 | 106 | getRelativeIndex(index: number): Array { 107 | let i = 0; 108 | let aliveIndex = 0; 109 | let itemsFound = false; 110 | let charStart; let charEnd; let char; 111 | while(!itemsFound && (i < this.chars.length)) { 112 | char = this.chars[i]; 113 | if(!char.tombstone) { 114 | if(aliveIndex>index) { 115 | charEnd = char; 116 | itemsFound = true; 117 | } else { 118 | charStart = char; 119 | } 120 | aliveIndex++; 121 | } 122 | i++; 123 | } 124 | if(aliveIndex>=index) { 125 | charEnd = char; 126 | itemsFound = true; 127 | } else { 128 | charStart = char; 129 | } 130 | if (charStart && charEnd) 131 | return [charStart, charEnd ]; 132 | else 133 | throw Error("failedToFindRelativeIndex"); 134 | } 135 | 136 | getCharRelativeIndex(char: Char) : number { 137 | let i = 0; 138 | let aliveIndex = 0; 139 | let charFound = false; 140 | let c; 141 | while(!charFound && (i < this.chars.length)) { 142 | c = this.chars[i]; 143 | if(!c.tombstone && c.char !== "bof" && c.char !== "eof") 144 | aliveIndex++; 145 | if (c.id === char.id) { 146 | if (c.tombstone) { 147 | aliveIndex++; 148 | } 149 | charFound = true; 150 | } 151 | i++; 152 | } 153 | if (charFound) 154 | return aliveIndex-1; 155 | else 156 | throw Error("failedToFindRelativeIndex"); 157 | } 158 | 159 | getSequence(): string { 160 | let seq = ""; 161 | for (let char of this.chars) { 162 | if (!char.tombstone && char.char !== "bof" && char.char !== "eof") 163 | seq += (char.char) 164 | } 165 | return seq; 166 | } 167 | 168 | pretty() { 169 | for (let char of this.chars) { 170 | console.log(char.index, char.char, char.siteID, char.tombstone); 171 | } 172 | 173 | } 174 | } -------------------------------------------------------------------------------- /src/service/History.ts: -------------------------------------------------------------------------------- 1 | import UserVersion from './UserVersion'; 2 | import Socket from './Socket'; 3 | import Sequence from '../../src/crdt/Sequence'; 4 | import Char from '../../src/crdt/Char'; 5 | import Cursor from './Cursor'; 6 | 7 | const CURSOR = 'cursor'; 8 | const INSERT = 'insert'; 9 | const DELETE = 'delete'; 10 | const RETAIN = 'retain'; 11 | const SILENT = 'silent'; 12 | const INIT_LOAD = 'init_load'; 13 | 14 | export default class History { 15 | sequence: Sequence; 16 | versionVector: Array; 17 | cursors: Array; 18 | localCursor: Cursor; 19 | currentUserID: string; 20 | socket: Socket; 21 | remoteInsert: (index: number, char: Char) => void; 22 | remoteDelete: (index: number) => void; 23 | remoteRetain: (index: number, char: Char) => void; 24 | updateRemoteCursors: (cursor: Cursor) => void; 25 | constructor(currentUserID: string, remoteInsert: (index: number, char: Char) => void, 26 | remoteDelete: (index: number) => void, remoteRetain: (index: number, char: Char) => void, 27 | updateRemoteCursors: (cursor: Cursor) => void) { 28 | this.currentUserID = currentUserID; 29 | this.socket = new Socket(this.remoteChange.bind(this), this.updateConnectionState.bind(this)) 30 | this.sequence = new Sequence(); 31 | this.cursors = []; 32 | this.localCursor = new Cursor(currentUserID, 1, 0, this.cursors.length); 33 | this.versionVector = []; 34 | this.remoteInsert = remoteInsert; 35 | this.remoteDelete = remoteDelete; 36 | this.remoteRetain = remoteRetain; 37 | this.updateRemoteCursors = updateRemoteCursors; 38 | } 39 | 40 | updateConnectionState() { 41 | 42 | } 43 | 44 | incrementVersionVector(userID: string) { 45 | const localVersionVector = this.versionVector.find(e => e.userID === userID); 46 | if (localVersionVector) { 47 | localVersionVector.clock+=1; 48 | //console.log('local vector: ', localVersionVector); 49 | } else { 50 | let newVector = new UserVersion(userID); 51 | this.versionVector.push(newVector); 52 | //console.log('new vector: ', newVector); 53 | } 54 | } 55 | 56 | insert(indexStart: number, indexEnd: number, char: string, attributes: object, source: string) { 57 | if (source !== SILENT) { 58 | //console.log('history insert: ', indexStart, indexEnd, char); 59 | let charObj: Char = this.sequence.insert(indexStart, indexEnd, char, attributes); 60 | this.socket.send(JSON.stringify({type: INSERT, data: charObj, userID: this.currentUserID})); 61 | this.incrementVersionVector(this.currentUserID); 62 | } 63 | } 64 | 65 | delete(char: Char, source: string) { 66 | if (source !== SILENT) { 67 | this.sequence.delete(char.id); 68 | this.socket.send(JSON.stringify({type: DELETE, data: char, userID: this.currentUserID})); 69 | this.incrementVersionVector(this.currentUserID); 70 | } 71 | } 72 | 73 | retain(char: Char, attributes: object, source: string) { 74 | if (source !== SILENT) { 75 | char.update(attributes); 76 | this.socket.send(JSON.stringify({type: RETAIN, data: char, userID: this.currentUserID})); 77 | this.incrementVersionVector(this.currentUserID); 78 | } 79 | } 80 | 81 | getRelativeIndex(index: number): Array { 82 | return this.sequence.getRelativeIndex(index); 83 | } 84 | 85 | updateRemoteCursor(remoteCursor: Cursor) { 86 | let cursor = this.cursors.find(c => c.userID === remoteCursor.userID); 87 | if (cursor) { 88 | cursor.updateRange(remoteCursor.index, remoteCursor.length); 89 | } else { 90 | cursor = new Cursor(remoteCursor.userID, remoteCursor.index, 91 | remoteCursor.length, this.cursors.length); 92 | this.cursors.push(cursor); 93 | } 94 | this.updateRemoteCursors(cursor); 95 | } 96 | 97 | updateCursor(index: number, length: number) { 98 | this.localCursor.updateRange(index, length); 99 | this.socket.send(JSON.stringify({type: CURSOR, data: this.localCursor, userID: this.currentUserID})) 100 | } 101 | 102 | getCursors() : Array { 103 | return this.cursors; 104 | } 105 | 106 | remoteChange(jsonMessage: any) { 107 | //TODO: Validate data in jsonMessage 108 | let change = JSON.parse(jsonMessage); 109 | if (change.type === INSERT) { 110 | let char : Char = change.data; 111 | this.sequence.remoteInsert(char); 112 | let index = this.sequence.getCharRelativeIndex(char); 113 | this.remoteInsert(index, char); 114 | this.incrementVersionVector(change.userID); 115 | } else if (change.type === DELETE) { 116 | let char : Char = change.data; 117 | let id : string = char.id; 118 | this.sequence.delete(id); 119 | try { 120 | let index = this.sequence.getCharRelativeIndex(char); 121 | this.remoteDelete(index); 122 | this.incrementVersionVector(change.userID); 123 | } catch (e) { 124 | } 125 | } else if (change.type === RETAIN) { 126 | let char : Char = change.data; 127 | this.sequence.remoteRetain(char); 128 | try { 129 | let index = this.sequence.getCharRelativeIndex(char); 130 | this.remoteRetain(index, char); 131 | this.incrementVersionVector(change.userID); 132 | } catch (e) { 133 | } 134 | } else if (change.type === CURSOR) { 135 | let remoteCursor : Cursor = change.data; 136 | this.updateRemoteCursor(remoteCursor); 137 | } else if (change.type === INIT_LOAD) { 138 | //console.log('should recieve init_load :'); 139 | for (let i = 0; i < change.data.length; i++) { 140 | let prevChange = JSON.parse(change.data[i]); 141 | //console.log(prevChange); 142 | this.handleRemoteChange(prevChange); 143 | } 144 | } 145 | } 146 | 147 | handleRemoteChange(change) { 148 | if (change.type === INSERT) { 149 | let char : Char = change.data; 150 | this.sequence.remoteInsert(char); 151 | let index = this.sequence.getCharRelativeIndex(char); 152 | this.remoteInsert(index, char); 153 | } else if (change.type === DELETE) { 154 | let char : Char = change.data; 155 | let id : string = char.id; 156 | this.sequence.delete(id); 157 | try { 158 | let index = this.sequence.getCharRelativeIndex(char); 159 | this.remoteDelete(index); 160 | } catch (e) { 161 | } 162 | } else if (change.type === RETAIN) { 163 | let char : Char = change.data; 164 | this.sequence.remoteRetain(char); 165 | try { 166 | let index = this.sequence.getCharRelativeIndex(char); 167 | this.remoteRetain(index, char); 168 | } catch (e) { 169 | } 170 | } 171 | } 172 | } -------------------------------------------------------------------------------- /src/components/Document.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import QuillCursors from 'quill-cursors'; 3 | import * as React from 'react'; 4 | // @ts-ignore 5 | import ReactQuill, { Quill, Range } from 'react-quill'; 6 | import 'react-quill/dist/quill.snow.css'; 7 | import Char from '../crdt/Char'; 8 | import History from '../service/History'; 9 | import './Document.css'; 10 | import Cursor from 'src/service/Cursor'; 11 | import ActiveUsers from './ActiveUsers'; 12 | 13 | // tslint:disable-next-line: no-var-requires 14 | const uuidv1 = require('uuid/v1'); 15 | 16 | Quill.register('modules/cursors', QuillCursors) 17 | const modules = { 18 | cursors: true, 19 | toolbar: ['bold', 'italic', 'underline'], 20 | }; 21 | 22 | // tslint:disable-next-line: no-empty-interface 23 | export interface IProps { 24 | } 25 | 26 | interface IState { 27 | text: string, 28 | doc: any, 29 | history: History, 30 | selectedRange: [], 31 | } 32 | 33 | class Document extends React.Component { 34 | private reactQuillRef = React.createRef(); 35 | 36 | constructor(props: IProps) { 37 | super(props) 38 | 39 | this.state = { 40 | doc: "", 41 | history: new History(uuidv1(), this.remoteInsert.bind(this), this.remoteDelete.bind(this), 42 | this.remoteRetain.bind(this), this.updateRemoteCursors.bind(this)), 43 | text: "", 44 | selectedRange: [], 45 | } 46 | 47 | this.handleChange = this.handleChange.bind(this); 48 | this.handleChangeSelection = this.handleChangeSelection.bind(this); 49 | this.onFocus = this.onFocus.bind(this); 50 | this.onBlur = this.onBlur.bind(this); 51 | 52 | } 53 | 54 | componentDidMount() { 55 | if (this.reactQuillRef.current) { 56 | this.reactQuillRef.current.getEditor().enable; 57 | } 58 | } 59 | 60 | public remoteInsert(index: number, char: Char) { 61 | if (this.reactQuillRef.current) { 62 | this.reactQuillRef.current.getEditor().insertText(index, char.char, { 63 | 'italic': char.italic, 64 | 'bold': char.bold, 65 | 'underline': char.underline, 66 | }, "silent"); 67 | } 68 | this.forceUpdate(); 69 | } 70 | 71 | public remoteDelete(index: number) { 72 | if (this.reactQuillRef.current) { 73 | this.reactQuillRef.current.getEditor().deleteText(index, 1, "silent"); 74 | } 75 | } 76 | 77 | public remoteRetain(index: number, char: Char) { 78 | if (this.reactQuillRef.current) { 79 | this.reactQuillRef.current.getEditor().formatText(index,1, 80 | { 81 | 'italic': char.italic, 82 | 'bold': char.bold, 83 | 'underline': char.underline, 84 | 'link': char.link, 85 | }, 'silent' 86 | ); 87 | } 88 | } 89 | 90 | private insert(chars: String, startIndex: number, attributes: object, source: string) { 91 | //console.log('insert: ', startIndex, ' ', chars) 92 | let index = startIndex; 93 | for (let i in chars) { 94 | let char = chars[i]; 95 | let crdtIndex = this.state.history.getRelativeIndex(index); 96 | this.state.history.insert(crdtIndex[0].index, crdtIndex[1].index, char, attributes, source); 97 | index += 1; 98 | } 99 | } 100 | 101 | private delete(startIndex: number, length: number, source: string) { 102 | //console.log('delete: ', startIndex, length) 103 | let index = startIndex; 104 | for (let i = 0; i < length; i++) { 105 | try { 106 | let chars = this.state.history.getRelativeIndex(index); 107 | this.state.history.delete(chars[1], source); 108 | } catch { 109 | alert("failed to find relative index"); 110 | } 111 | } 112 | } 113 | 114 | private retain(index: number, length: number, attribute: object, source: string) { 115 | for (let i = 0; i < length; i++) { 116 | try { 117 | let chars = this.state.history.getRelativeIndex(index); 118 | this.state.history.retain(chars[1], attribute, source); 119 | } catch { 120 | alert("failed to find relative index"); 121 | } 122 | index += 1; 123 | } 124 | } 125 | 126 | private inspectDelta(ops: any, index: number, source: string) { 127 | if (ops["insert"] != null) { 128 | //console.log('INSERT', ' RANGE: ', this.state.selectedRange); 129 | let chars = ops["insert"]; 130 | let attributes = ops["attributes"]; 131 | this.insert(chars, index, attributes, source); 132 | } else if (ops["delete"] != null) { 133 | let len = ops["delete"]; 134 | this.delete(index, len, source); 135 | } else if (ops["retain"] != null) { 136 | let len = ops["retain"]; 137 | let attributes = ops["attributes"]; 138 | this.retain(index, len, attributes, source); 139 | } 140 | } 141 | 142 | //TODO: Write a better implementation that follows the Quill Delta way 143 | private handleChange(value: any, delta: any, source: any) { 144 | //console.log('handleChange: ', value, delta, source); 145 | let index = delta.ops[0]["retain"] || 0; 146 | if (delta.ops.length === 4) { 147 | const deleteOps_1 = delta.ops[1]; 148 | this.inspectDelta(deleteOps_1, index, source); 149 | index += delta.ops[2]["retain"]; 150 | const deleteOps_2 = delta.ops[3]; 151 | this.inspectDelta(deleteOps_2, index, source); 152 | } 153 | else if (delta.ops.length === 3) { 154 | const deleteOps = delta.ops[2]; 155 | this.inspectDelta(deleteOps, index, source); 156 | const insert = delta.ops[1]; 157 | this.inspectDelta(insert, index, source); 158 | } 159 | else if (delta.ops.length === 2) { 160 | this.inspectDelta(delta.ops[1], index, source); 161 | } else { 162 | this.inspectDelta(delta.ops[0], index, source); 163 | } 164 | this.setState({ text: value }) 165 | } 166 | 167 | public updateRemoteCursors(cursor: Cursor) { 168 | if (this.reactQuillRef.current) { 169 | const quillCursors = this.reactQuillRef.current.getEditor().getModule('cursors'); 170 | const qC = quillCursors.cursors().find((c: { id: string; }) => c.id === cursor.userID); 171 | if (qC) { 172 | quillCursors.moveCursor(qC.id, {index: cursor.index, length: cursor.length}) 173 | } else { 174 | quillCursors.createCursor(cursor.userID, cursor.name, cursor.color); 175 | quillCursors.moveCursor(cursor.userID, {index: cursor.index, length: cursor.length}) 176 | } 177 | } 178 | } 179 | 180 | private handleChangeSelection(range: any, source: string, editor: any) { 181 | //console.log('changeSelection ', source); 182 | if (range && range.index !== null) { 183 | this.state.history.updateCursor(range.index, range.length); 184 | this.setState({selectedRange: range}); 185 | } else { 186 | this.setState({selectedRange: []}); 187 | } 188 | //console.log(this.state.selectedRange); 189 | } 190 | 191 | private onFocus(range: Range, source: string, editor: any) { 192 | //console.log("onFocus: ", range) 193 | } 194 | 195 | private onBlur(previousRange: Range, source: string, editor: any) { 196 | //console.log("onBlur: ", previousRange) 197 | } 198 | 199 | public render() { 200 | let table_content = this.state.history.sequence.chars.map(char => { 201 | return ( 202 | 203 | {char.char} 204 | {char.index} 205 | {char.tombstone.toString()} 206 | 207 | b: {char.bold !== null ? char.bold.toString() : ''}, 208 | u: {char.underline !== null ? char.underline.toString() : ''}, 209 | i: {char.italic !== null ? char.italic.toString() : ''} 210 | 211 | 212 | {char.link} 213 | 214 | 215 | ); 216 | }); 217 | 218 | return ( 219 |
220 |

CRDT Sequence

221 |
222 | 223 |
224 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | {table_content} 239 | 240 |
#IndexTombstoneAttributeslink
241 |
242 | ); 243 | } 244 | } 245 | 246 | export default Document; --------------------------------------------------------------------------------