├── 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 |
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 |
224 |
227 |
228 |
229 |
230 | | # |
231 | Index |
232 | Tombstone |
233 | Attributes |
234 | link |
235 |
236 |
237 |
238 | {table_content}
239 |
240 |
241 |
242 | );
243 | }
244 | }
245 |
246 | export default Document;
--------------------------------------------------------------------------------