├── .editorconfig ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── app.js ├── bin └── www ├── controllers └── index.js ├── helpers ├── sharedb-server.js ├── wss-cursors.js └── wss-sharedb.js ├── package-lock.json ├── package.json ├── public ├── javascripts │ ├── cursors.js │ ├── main.js │ └── utils.js └── stylesheets │ ├── style.css │ ├── style.css.map │ └── style.scss ├── views ├── error.jade ├── index.jade └── layout.jade └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in 2 | # this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.jade] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | /public/dist 40 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/carbon 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Pedro Machado Santa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quill-ShareDB-Cursors 2 | An attempt at multi cursors sync in a collaborative editing scenario using [Quill](https://quilljs.com/), a [ShareDB](https://github.com/share/sharedb) backend, and the [reedsy/quill-cursors](https://github.com/reedsy/quill-cursors) Quill module. For more info on each component, check their pages/repositories. 3 | 4 | Built by [pedrosanta](https://github.com/pedrosanta) at [Reedsy](https://reedsy.com). 5 | 6 | A working demo is available at: https://quill-sharedb-cursors.herokuapp.com 7 | 8 | **Contents:** 9 | 10 | * [How to run](#how-to-run) 11 | * [Ongoing Quill cursor efforts and discussion](#ongoing-quill-cursor-efforts-and-discussion) 12 | * [About this project](#about-this-project) 13 | * [Known Issues](#known-issues) 14 | * [TODO](#todo) 15 | * [License](#license) 16 | 17 | ## How to run 18 | 19 | Before trying to run this example, make sure you have a fairly recent (6 LTS or earlier, 8 LTS recommended) version of **[Node](https://nodejs.org/)**. 20 | 21 | ```sh 22 | node -v 23 | ``` 24 | 25 | Opted to have **[MongoDB](https://www.mongodb.com)** storage for this particular example, so make sure it's installed and running. Alternatively, if you have [Docker](https://www.docker.com) installed you can spin an instance quickly by running the command: 26 | 27 | ``` Shell 28 | docker run -p 27017:27017 mongo 29 | ``` 30 | 31 | [Clone the repository](https://help.github.com/articles/cloning-a-repository) and install dependencies. It will also run the necessary [webpack](https://webpack.js.org) build task automatically for you. 32 | 33 | ```sh 34 | npm install 35 | ``` 36 | 37 | Run the project: 38 | 39 | ```shell 40 | DEBUG=quill-sharedb-cursors:* npm start 41 | ``` 42 | 43 | #### Development watch task 44 | 45 | For convenience while developing, a build task with watch is included: 46 | 47 | ```shell 48 | npm run watch 49 | ``` 50 | 51 | ## Ongoing Quill cursor efforts and discussion 52 | 53 | For a proper multi cursor feature the following is usually considered: 54 | 55 | * The final experience with Quill editor should display and sync both **caret** and any eventual **selections** each user/client has at the moment, and it also should display the user name to properly identify the cursor/user/client; 56 | * Quill is a library for a rich-text editor _focused solely on the **front-end** side_, so concerns related to keeping **multi-user document and cursor in sync** should be implemented with the help of additional middleware and backend technology/code (being [ShareDB](https://github.com/share/sharedb) the most obvious one, but there are others like [Meteor](https://www.meteor.com), or even your own, etc.); 57 | * Given that, and in what it concerns Quill, the best approach is through a **cursors module** (optional, extends Quill functionality) providing an API to set/update/remove cursors while also being responsible to automatically update cursors position when contents are changed/updated; 58 | 59 | For more info on how the various efforts/communities are coordinating and working towards this, keep reading below. 60 | 61 | ### Quill and cursor module 62 | 63 | Regarding Quill and multi cursors it is important to start on the older **v0.20** version of Quill, which already provided a multi cursors module - [check it out here](https://github.com/quilljs/quill/blob/0.20.1/src/modules/multi-cursor.coffee) - and which formed the basis of follow up work for this topic. It's important to note that this module only supported/had API for carets, but not selections. 64 | 65 | During the extensive changes from v0.20 to v1 this module has been since removed, but there is an active ongoing effort to re-implement this feature for Quill v1. To follow this effort and discussion check the issue: 66 | 67 | * **[quilljs/quill#918 – Multiple Cursors Module](https://github.com/quilljs/quill/issues/918)** 68 | 69 | This feature has since [been added to Backlog by jhchen](https://github.com/quilljs/quill/issues/918#event-1035830952) to be implemented soon. 70 | 71 | During the discussion [benbro](https://github.com/benbro) updated the old v0.20 multi cursors module to work with v1, that can be checked out here: 72 | 73 | * https://github.com/benbro/quill/tree/multi-cursor 74 | 75 | Additionally, and based on the work it has been done over at [Reedsy](https://reedsy.com) related to multi cursor sync (*both for carets and selections*) built on top of the v0.20 of Quill, the multi cursors module was published and made available as a way to contribute to the community and help this effort move forward: 76 | 77 | * **GitHub: https://github.com/reedsy/quill-cursors** 78 | * **NPM: https://www.npmjs.com/package/quill-cursors** 79 | 80 | After this functionality is included in the new v1 multi cursors module, this module is to be deprecated. 81 | 82 | To check where the efforts for multi cursor sync on middleware/backend stand at the moment, continue to read on below. 83 | 84 | ### ShareDB 85 | 86 | In what regards to ShareDB, the issue where the current discussion and effort is being handled is the following: 87 | 88 | * **[share/sharedb#128 - Add cursor synchronization to an editor](https://github.com/share/sharedb/issues/128)** 89 | 90 | [nateps](https://github.com/nateps), the main contributor to the project, [puts this high on the priority list and expands a bit on **ephemeral data** concept](https://github.com/share/sharedb/issues/128#issuecomment-252152894), that would benefit a cursor sync feature. 91 | 92 | Additionally, there seems to be a lot of history on the discussion of this feature both on [ShareJS/ShareDB mailing list](https://groups.google.com/forum/#!searchin/sharejs/cursor%7Csort:relevance), as well as [ShareJS GitHub issue tracker](https://github.com/josephg/ShareJS/issues?utf8=✓&q=cursor). 93 | 94 | ### Other backends (Meteor, Yjs) 95 | 96 | There are some cases of Quill working with other backends, namely [Meteor](https://www.meteor.com) and [Yjs](http://y-js.org). 97 | 98 | On Meteor thought, haven't found any information of an effort regarding multi cursors discussion or implementation. 99 | 100 | As for Yjs, [Joeao](https://github.com/Joeao) seems to be leading an effort to have multi cursors working ([y-js/yjs#65](https://github.com/y-js/yjs/issues/65), [y-js/y-richtext#112](https://github.com/y-js/y-richtext/issues/112)). 101 | 102 | ## About this project 103 | 104 | Overview and notes on some decisions taken to build the example project. 105 | 106 | ### Transport Layer (WebSockets, reconnecting) 107 | 108 | Although ShareDB [relatively bare documentation](https://github.com/share/sharedb#listening-to-websocket-connections) [on transport layer](https://github.com/share/sharedb#client-api) is shown to use WebSockets, looking into its [source code](https://github.com/share/sharedb/blob/master/lib/client/connection.js), one can see that any WebSocket API-compatible transport layer will work. 109 | 110 | In the past ShareJS and LiveDB (earlier ShareDB) recommended **[node-browserchannel](https://github.com/josephg/node-browserchannel)** - which was pretty good, as it reconnected seamlessly, but it resorted to *long-polling*. But as a note on its README says, WebSocket support is now reasonably universal, and strongly suggests using raw websockets for new projects. 111 | 112 | But by using bare WebSockets, we don't have any connection interruption/reconnection mechanism in place out-of-the-box. 113 | 114 | So for this project, **[reconnecting-websocket](https://github.com/joewalnes/reconnecting-websocket)** was used, along [some ping/keep-alive code](https://github.com/pedrosanta/quill-sharedb-cursors/blob/master/helpers/wss-sharedb.js#L29) to help manage the connection 'health'/availability - and keep about the same functionality of browserchannel on this aspect. 115 | 116 | Keep in mind that ShareDB Connection does not handle WebSocket/transport reconnections, but it will resubscribe any document and sync pending/offline updates upon socket reconnection. For more on that check **[share/sharedb#121](https://github.com/share/sharedb/issues/121)** and **[share/sharedb#138](https://github.com/share/sharedb/pull/138)**. 117 | 118 | #### Socket.io 119 | 120 | Socket.io isn't WebSocket API-compatible, so won't work without some sort of wrapper implementing it, it seems. Also there is a warning on ShareJS documentation regarding Socket.io that it sometimes [doesn't guarantee in-order message delivery](https://github.com/josephg/ShareJS/blob/master/README.md#client-server-communication). Checking [josephg/ShareJS#375](https://github.com/josephg/ShareJS/issues/375) gives some more insight into it, which points to some feasibility as long as **only** 'websocket' transport is used and probably configured **without transport upgrade** on Socket.io/Engine.io. 121 | 122 | Eitherway, ShareDB Socket.io support is a poor/unknown at best and without thorough testing. If you manage to get a good working example with ShareDB and Socket.io, please share away! 123 | 124 | ### Storage, MongoDB 125 | 126 | For this example, simple `ShareDB.MemoryDB` (or even [`ShareDBMingoMemory`](https://github.com/share/sharedb-mingo-memory)) adapter would suffice, but because I wanted this to keep close to a scenario I'm working at the moment, opted to test this in tandem with MongoDB and [`ShareDBMongo`](https://github.com/share/sharedb-mongo) adapter. 127 | 128 | ### Explicitly register rich-text OT type 129 | 130 | Another aspect to keep in mind is that `rich-text` doesn't come registered by default as an available OT type on ShareDB, so we need to: 131 | 132 | 1. Include **[ottypes/rich-text](https://github.com/ottypes/rich-text)** as dependency (as well as make it available/bundled for client); 133 | 2. **Register** `rich-text` type both **[on Server](https://github.com/pedrosanta/quill-sharedb-cursors/blob/master/helpers/sharedb-server.js#L3)** and **[on Client](https://github.com/pedrosanta/quill-sharedb-cursors/blob/master/public/javascripts/main.js#L7)**; 134 | 135 | ### Quill-ShareDB client listeners 136 | 137 | The basics about having ShareDB and Quill working together rely on listening to two events: 138 | 139 | * For *local changes/updates that must be transmitted to server*, [listen on Quill `text-change` event and, for user changes, submit the operation to ShareDB document](https://github.com/share/sharedb/blob/master/examples/rich-text/client.js#L25); 140 | * For *changes/updates sent by the server to be applied locally*, [listen on ShareDB document `op` event and, for non local update sources, call Quill `updateContents(…)`](https://github.com/share/sharedb/blob/master/examples/rich-text/client.js#L29); 141 | 142 | ### Cursor server and client middleware 143 | 144 | There are several ways one could implement cursor sync behaviour, but in this case this implementation has the following components: 145 | 146 | * **[`helpers/wss-cursors.js`](https://github.com/pedrosanta/quill-sharedb-cursors/blob/master/helpers/wss-cursors.js)** - A server part that: 147 | * Maintains a list of active connections; 148 | * Listens for update messages (through WebSockets); 149 | * And broadcasts an update as well as the list of active connections to all connected clients upon receiving an update from a client; 150 | * **[`public/javascripts/cursors.js`](https://github.com/pedrosanta/quill-sharedb-cursors/blob/master/public/javascripts/cursors.js)** - A client part that: 151 | * Inits and holds client own cursor information; 152 | * Maintains a list of the active cursors/clients, based off the updates received from the server; 153 | * Sends an update of own cursor information, fired when a Quill `selection-change` event fires; 154 | * Fires a 'cursors updated' event (when a cursors update message is received from server) so Quill instances can update their cursors using the cursors module API; 155 | 156 | The data each cursor is sending and syncing is: 157 | 158 | * **`id`**, the cursor/client id autogenerated by the server; 159 | * **`name`**, the name to display on the cursor; 160 | * **`color`**, CSS color of the cursor; 161 | * **`range`**, the current range from the client, obtained from `quill.getSelection()`; 162 | 163 | ## Known Issues 164 | 165 | For the most of the time the editor and cursor sync seem to behave as it should be expected. But on a few cases the cursor position gets misplaced - I have a strong suspicion the cause are racing conditions, as these issues are hard to replicate, occur sometimes, and usually involve two or more people typing at the same time. 166 | 167 | The main issues currently identified in this example, are: 168 | 169 | * [**Selection/cursor-change and edits racing condition #1**](https://github.com/pedrosanta/quill-sharedb-cursors/issues/1): When editing/inserting text immediatly after caret position moves with the arrow keys, can lead to cursor being misplaced (leading it to be stuck on the wrong position); 170 | * [**Cursor misplacement on concurrent editing with 'Enter'/new lines #2**](https://github.com/pedrosanta/quill-sharedb-cursors/issues/2): Sometimes, when two users are editing, and one of them adds a few new lines, its cursor gets shifted by a few positions (usually, 1-3 positions forward); 171 | 172 | ## TODO 173 | 174 | * Update server code to ES6 175 | 176 | ## License 177 | 178 | This code is available under the [MIT license](LICENSE-MIT.txt). 179 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var favicon = require('serve-favicon'); 4 | var logger = require('morgan'); 5 | var cookieParser = require('cookie-parser'); 6 | var bodyParser = require('body-parser'); 7 | var url = require('url'); 8 | 9 | var app = express(); 10 | var server = require('http').Server(app); 11 | 12 | // view engine setup 13 | app.set('views', path.join(__dirname, 'views')); 14 | app.set('view engine', 'jade'); 15 | 16 | // uncomment after placing your favicon in /public 17 | //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); 18 | app.use(logger('dev')); 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: false })); 21 | app.use(cookieParser()); 22 | app.use(require('node-sass-middleware')({ 23 | src: path.join(__dirname, 'public'), 24 | dest: path.join(__dirname, 'public'), 25 | sourceMap: true 26 | })); 27 | app.use(express.static(path.join(__dirname, 'public'))); 28 | app.use(express.static(path.join(__dirname, 'node_modules/quill/dist'))); 29 | app.use(express.static(path.join(__dirname, 'node_modules/quill-cursors/dist'))); 30 | 31 | app.use(require('./controllers')); 32 | 33 | // init websockets servers 34 | var wssShareDB = require('./helpers/wss-sharedb')(server); 35 | var wssCursors = require('./helpers/wss-cursors')(server); 36 | 37 | server.on('upgrade', (request, socket, head) => { 38 | const pathname = url.parse(request.url).pathname; 39 | 40 | if (pathname === '/sharedb') { 41 | wssShareDB.handleUpgrade(request, socket, head, (ws) => { 42 | wssShareDB.emit('connection', ws); 43 | }); 44 | } else if (pathname === '/cursors') { 45 | wssCursors.handleUpgrade(request, socket, head, (ws) => { 46 | wssCursors.emit('connection', ws); 47 | }); 48 | } else { 49 | socket.destroy(); 50 | } 51 | }); 52 | 53 | // catch 404 and forward to error handler 54 | app.use(function(req, res, next) { 55 | var err = new Error('Not Found'); 56 | err.status = 404; 57 | next(err); 58 | }); 59 | 60 | // error handler 61 | app.use(function(err, req, res, next) { 62 | // set locals, only providing error in development 63 | res.locals.message = err.message; 64 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 65 | 66 | // render the error page 67 | res.status(err.status || 500); 68 | res.render('error'); 69 | }); 70 | 71 | module.exports = { app: app, server: server }; 72 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app').app; 8 | var debug = require('debug')('quill-sharedb-cursors:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Get HTTP server. 20 | */ 21 | 22 | var server = require('../app').server; 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get('/', function(req, res, next) { 6 | res.render('index'); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /helpers/sharedb-server.js: -------------------------------------------------------------------------------- 1 | var ShareDB = require('sharedb'); 2 | 3 | ShareDB.types.register(require('rich-text').type); 4 | 5 | module.exports = new ShareDB({ 6 | db: require('sharedb-mongo')(process.env.MONGODB_URI || 'mongodb://localhost/quill-sharedb-cursors?auto_reconnect=true') 7 | }); 8 | -------------------------------------------------------------------------------- /helpers/wss-cursors.js: -------------------------------------------------------------------------------- 1 | var WebSocket = require('ws'); 2 | var _ = require('lodash'); 3 | var uuid = require('uuid'); 4 | var debug = require('debug')('quill-sharedb-cursors:cursors'); 5 | 6 | module.exports = function(server) { 7 | 8 | function notifyConnections(sourceId) { 9 | connections.forEach(function(connection) { 10 | sessions[connection.id].send(JSON.stringify({ 11 | id: connection.id, 12 | sourceId: sourceId, 13 | connections: connections 14 | })); 15 | }); 16 | } 17 | 18 | var sessions = {}; 19 | var connections = []; 20 | 21 | var wss = new WebSocket.Server({ 22 | noServer: true 23 | }); 24 | 25 | wss.on('connection', function(ws, req) { 26 | 27 | // generate an id for the socket 28 | ws.id = uuid(); 29 | ws.isAlive = true; 30 | 31 | debug('A new client (%s) connected.', ws.id); 32 | 33 | ws.on('message', function(data) { 34 | var connectionIndex; 35 | 36 | data = JSON.parse(data); 37 | 38 | // If a connection id isn't still set 39 | // we keep sending id along with an empty connections array 40 | if (!data.id) { 41 | ws.send(JSON.stringify({ 42 | id: ws.id, 43 | sourceId: ws.id, 44 | connections: [] 45 | })); 46 | 47 | return; 48 | } else { 49 | // If session/connection isn't registered yet, register it 50 | if (!sessions[ws.id]) { 51 | // Override/refresh connection id 52 | data.id = ws.id; 53 | 54 | // Push/add connection to connections hashtable 55 | connections.push(data); 56 | 57 | // Push/add session to sessions hashtable 58 | sessions[data.id] = ws; 59 | } 60 | // 61 | else { 62 | // If this connection can't be found, ignore 63 | if (!~(connectionIndex = _.findIndex(connections, { 64 | 'id': data.id 65 | }))) { 66 | 67 | return; 68 | } 69 | 70 | // Update connection data 71 | connections[connectionIndex] = data; 72 | } 73 | 74 | debug('Connection update received:\n%O', data); 75 | 76 | // Notify all sessions 77 | notifyConnections(data.id); 78 | } 79 | }); 80 | 81 | ws.on('close', function(code, reason) { 82 | 83 | debug('Client connection closed (%s). (Code: %s, Reason: %s)', ws.id, code, reason); 84 | 85 | // Find connection index and remove it from hashtable 86 | if (~(connectionIndex = _.findIndex(connections, { 87 | 'id': ws.id 88 | }))) { 89 | 90 | debug('Connection removed:\n%O', connections[connectionIndex]); 91 | 92 | connections.splice(connectionIndex, 1); 93 | } 94 | 95 | // Remove session from sessions hashtable 96 | delete sessions[ws.id]; 97 | 98 | // Notify all connections 99 | notifyConnections(ws.id); 100 | }); 101 | 102 | ws.on('error', function(error) { 103 | debug('Client connection errored (%s). (Error: %s)', ws.id, error); 104 | 105 | if (~(connectionIndex = _.findIndex(connections, { 106 | 'id': ws.id 107 | }))) { 108 | 109 | debug('Errored connection:\n%O', connections[connectionIndex]); 110 | } 111 | }); 112 | 113 | ws.on('pong', function(data) { 114 | debug('Pong received. (%s)', ws.id); 115 | ws.isAlive = true; 116 | }); 117 | 118 | }); 119 | 120 | // Sockets Ping, Keep Alive 121 | setInterval(function() { 122 | wss.clients.forEach(function(ws) { 123 | if (ws.isAlive === false) return ws.terminate(); 124 | 125 | ws.isAlive = false; 126 | ws.ping(); 127 | debug('Ping sent. (%s)', ws.id); 128 | }); 129 | }, 30000); 130 | 131 | return wss; 132 | }; 133 | -------------------------------------------------------------------------------- /helpers/wss-sharedb.js: -------------------------------------------------------------------------------- 1 | var WebSocket = require('ws'); 2 | var WebSocketJSONStream = require('websocket-json-stream'); 3 | var shareDBServer = require('./sharedb-server'); 4 | var debug = require('debug')('quill-sharedb-cursors:sharedb'); 5 | var uuid = require('uuid'); 6 | 7 | module.exports = function(server) { 8 | var wss = new WebSocket.Server({ 9 | noServer: true 10 | }); 11 | 12 | wss.on('connection', function(ws, req) { 13 | 14 | // generate an id for the socket 15 | ws.id = uuid(); 16 | ws.isAlive = true; 17 | 18 | debug('A new client (%s) connected.', ws.id); 19 | 20 | var stream = new WebSocketJSONStream(ws); 21 | shareDBServer.listen(stream); 22 | 23 | ws.on('pong', function(data, flags) { 24 | debug('Pong received. (%s)', ws.id); 25 | ws.isAlive = true; 26 | }); 27 | 28 | ws.on('error', function(error) { 29 | debug('Client connection errored (%s). (Error: %s)', ws.id, error); 30 | }); 31 | }); 32 | 33 | // Sockets Ping, Keep Alive 34 | setInterval(function() { 35 | wss.clients.forEach(function(ws) { 36 | if (ws.isAlive === false) return ws.terminate(); 37 | 38 | ws.isAlive = false; 39 | ws.ping(); 40 | debug('Ping sent. (%s)', ws.id); 41 | }); 42 | }, 30000); 43 | 44 | return wss; 45 | }; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quill-sharedb-cursors", 3 | "version": "0.0.0", 4 | "private": true, 5 | "author": "Pedro Machado Santa ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/pedrosanta/quill-sharedb-cursors.git" 10 | }, 11 | "scripts": { 12 | "postinstall": "npm run build", 13 | "build": "./node_modules/.bin/webpack", 14 | "watch": "./node_modules/.bin/webpack -w", 15 | "start": "node ./bin/www" 16 | }, 17 | "dependencies": { 18 | "body-parser": "^1.18.2", 19 | "cookie-parser": "~1.4.3", 20 | "debug": "^3.1.0", 21 | "express": "^4.16.2", 22 | "jade": "^1.11.0", 23 | "lodash": "^4.17.4", 24 | "morgan": "^1.9.0", 25 | "node-sass-middleware": "^0.11.0", 26 | "quill": "^1.3.5", 27 | "quill-cursors": "^1.0.1", 28 | "reconnectingwebsocket": "^1.0.0", 29 | "rich-text": "^3.1.0", 30 | "serve-favicon": "^2.4.5", 31 | "sharedb": "^1.0.0-beta.8", 32 | "sharedb-mongo": "^1.0.0-beta.3", 33 | "uuid": "^3.2.1", 34 | "websocket-json-stream": "0.0.3", 35 | "webpack": "^3.10.0", 36 | "ws": "^4.0.0" 37 | }, 38 | "devDependencies": { 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/javascripts/cursors.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnectingwebsocket'); 2 | 3 | var cursors = {}; 4 | 5 | var socketStateEl = document.getElementById('cursors-socket-state'); 6 | var socketIndicatorEl = document.getElementById('cursors-socket-indicator'); 7 | 8 | function CursorConnection(name, color) { 9 | this.id = null; 10 | this.name = name; 11 | this.color = color; 12 | } 13 | 14 | // Create browserchannel socket 15 | cursors.socket = new ReconnectingWebSocket(((location.protocol === 'https:') ? 'wss' : 'ws') + '://' + window.location.host + '/cursors'); 16 | socketStateEl.innerHTML = 'connecting'; 17 | socketIndicatorEl.style.backgroundColor = 'silver'; 18 | 19 | // Init a blank user connection to store local conn data 20 | cursors.localConnection = new CursorConnection( 21 | null, 22 | chance.color({ 23 | format: 'hex' 24 | }) 25 | ); 26 | 27 | // Update 28 | cursors.update = function() { 29 | cursors.socket.send(JSON.stringify(cursors.localConnection)); 30 | }; 31 | 32 | // Init connections array 33 | cursors.connections = []; 34 | 35 | // Send initial message to register the client, and 36 | // retrieve a list of current clients so we can set a colour. 37 | cursors.socket.onopen = function() { 38 | socketStateEl.innerHTML = 'connected'; 39 | socketIndicatorEl.style.backgroundColor = 'lime'; 40 | cursors.update(); 41 | }; 42 | 43 | // Handle updates 44 | cursors.socket.onmessage = function(message) { 45 | 46 | var data = JSON.parse(message.data); 47 | 48 | var source = {}, 49 | removedConnections = [], 50 | forceUpdate = false, 51 | reportNewConnections = true; 52 | 53 | if (!cursors.localConnection.id) 54 | forceUpdate = true; 55 | 56 | // Refresh local connection ID (because session ID might have changed because server restarts, crashes, etc.) 57 | cursors.localConnection.id = data.id; 58 | 59 | if (forceUpdate) { 60 | cursors.update(); 61 | return; 62 | } 63 | 64 | // Find removed connections 65 | for (var i = 0; i < cursors.connections.length; i++) { 66 | var testConnection = data.connections.find(function(connection) { 67 | return connection.id == cursors.connections[i].id; 68 | }); 69 | 70 | if (!testConnection) { 71 | 72 | removedConnections.push(cursors.connections[i]); 73 | console.log('[cursors] User disconnected:', cursors.connections[i]); 74 | 75 | // If the source connection was removed set it 76 | if (data.sourceId == cursors.connections[i]) 77 | source = cursors.connections[i]; 78 | } else if (testConnection.name && !cursors.connections[i].name) { 79 | console.log('[cursors] User ' + testConnection.id + ' set username:', testConnection.name); 80 | console.log('[cursors] Connections after username update:', data.connections); 81 | } 82 | } 83 | 84 | if (cursors.connections.length == 0 && data.connections.length != 0) { 85 | console.log('[cursors] Initial list of connections received from server:', data.connections); 86 | reportNewConnections = false; 87 | } 88 | 89 | for (var i = 0; i < data.connections.length; i++) { 90 | // Set the source if it's still on active connections 91 | if (data.sourceId == data.connections[i].id) 92 | source = data.connections[i]; 93 | 94 | if (reportNewConnections && !cursors.connections.find(function(connection) { 95 | return connection.id == data.connections[i].id 96 | })) { 97 | 98 | console.log('[cursors] User connected:', data.connections[i]); 99 | console.log('[cursors] Connections after new user:', data.connections); 100 | } 101 | } 102 | 103 | // Update connections array 104 | cursors.connections = data.connections; 105 | 106 | // Fire event 107 | document.dispatchEvent(new CustomEvent('cursors-update', { 108 | detail: { 109 | source: source, 110 | removedConnections: removedConnections 111 | } 112 | })); 113 | }; 114 | 115 | cursors.socket.onclose = function (event) { 116 | console.log('[cursors] Socket closed. Event:', event); 117 | socketStateEl.innerHTML = 'closed'; 118 | socketIndicatorEl.style.backgroundColor = 'red'; 119 | }; 120 | 121 | cursors.socket.onerror = function (event) { 122 | console.log('[cursors] Error on socket. Event:', event); 123 | socketStateEl.innerHTML = 'error'; 124 | socketIndicatorEl.style.backgroundColor = 'red'; 125 | }; 126 | 127 | module.exports = cursors; 128 | -------------------------------------------------------------------------------- /public/javascripts/main.js: -------------------------------------------------------------------------------- 1 | var ShareDB = require('sharedb/lib/client'); 2 | var Quill = require('quill'); 3 | var ReconnectingWebSocket = require('reconnectingwebsocket'); 4 | var cursors = require('./cursors' ); 5 | var utils = require('./utils'); 6 | 7 | import QuillCursors from 'quill-cursors/src/cursors'; 8 | 9 | ShareDB.types.register(require('rich-text').type); 10 | 11 | Quill.register('modules/cursors', QuillCursors); 12 | 13 | var shareDBSocket = new ReconnectingWebSocket(((location.protocol === 'https:') ? 'wss' : 'ws') + '://' + window.location.host + '/sharedb'); 14 | 15 | var shareDBConnection = new ShareDB.Connection(shareDBSocket); 16 | 17 | var quill = window.quill = new Quill('#editor', { 18 | theme: 'snow', 19 | modules: { 20 | cursors: { 21 | autoRegisterListener: false 22 | }, 23 | history: { 24 | userOnly: true 25 | } 26 | }, 27 | readOnly: true 28 | }); 29 | 30 | var doc = shareDBConnection.get('documents', 'foobar'); 31 | 32 | var cursorsModule = quill.getModule('cursors'); 33 | 34 | doc.subscribe(function(err) { 35 | 36 | if (err) throw err; 37 | 38 | if (!doc.type) 39 | doc.create([{ 40 | insert: '\n' 41 | }], 'rich-text'); 42 | 43 | // update editor contents 44 | quill.setContents(doc.data); 45 | 46 | // local -> server 47 | quill.on('text-change', function(delta, oldDelta, source) { 48 | if (source == 'user') { 49 | 50 | // Check if it's a formatting-only delta 51 | var formattingDelta = delta.reduce(function (check, op) { 52 | return (op.insert || op.delete) ? false : check; 53 | }, true); 54 | 55 | // If it's not a formatting-only delta, collapse local selection 56 | if ( 57 | !formattingDelta && 58 | cursors.localConnection.range && 59 | cursors.localConnection.range.length 60 | ) { 61 | cursors.localConnection.range.index += cursors.localConnection.range.length; 62 | cursors.localConnection.range.length = 0; 63 | cursors.update(); 64 | } 65 | 66 | doc.submitOp(delta, { 67 | source: quill 68 | }, function(err) { 69 | if (err) 70 | console.error('Submit OP returned an error:', err); 71 | }); 72 | 73 | updateUserList(); 74 | } 75 | }); 76 | 77 | cursorsModule.registerTextChangeListener(); 78 | 79 | // server -> local 80 | doc.on('op', function(op, source) { 81 | if (source !== quill) { 82 | quill.updateContents(op); 83 | updateUserList(); 84 | } 85 | }); 86 | 87 | // 88 | function sendCursorData(range) { 89 | cursors.localConnection.range = range; 90 | cursors.update(); 91 | } 92 | 93 | // 94 | var debouncedSendCursorData = utils.debounce(function() { 95 | var range = quill.getSelection(); 96 | 97 | if (range) { 98 | console.log('[cursors] Stopped typing, sending a cursor update/refresh.'); 99 | sendCursorData(range); 100 | } 101 | }, 1500); 102 | 103 | doc.on('nothing pending', debouncedSendCursorData); 104 | 105 | function updateCursors(source) { 106 | var activeConnections = {}, 107 | updateAll = Object.keys(cursorsModule.cursors).length == 0; 108 | 109 | cursors.connections.forEach(function(connection) { 110 | if (connection.id != cursors.localConnection.id) { 111 | 112 | // Update cursor that sent the update, source (or update all if we're initting) 113 | if ((connection.id == source.id || updateAll) && connection.range) { 114 | cursorsModule.setCursor( 115 | connection.id, 116 | connection.range, 117 | connection.name, 118 | connection.color 119 | ); 120 | } 121 | 122 | // Add to active connections hashtable 123 | activeConnections[connection.id] = connection; 124 | } 125 | }); 126 | 127 | // Clear 'disconnected' cursors 128 | Object.keys(cursorsModule.cursors).forEach(function(cursorId) { 129 | if (!activeConnections[cursorId]) { 130 | cursorsModule.removeCursor(cursorId); 131 | } 132 | }); 133 | } 134 | 135 | quill.on('selection-change', function(range, oldRange, source) { 136 | sendCursorData(range); 137 | }); 138 | 139 | document.addEventListener('cursors-update', function(e) { 140 | // Handle Removed Connections 141 | e.detail.removedConnections.forEach(function(connection) { 142 | if (cursorsModule.cursors[connection.id]) 143 | cursorsModule.removeCursor(connection.id); 144 | }); 145 | 146 | updateCursors(e.detail.source); 147 | updateUserList(); 148 | }); 149 | 150 | updateCursors(cursors.localConnection); 151 | }); 152 | 153 | window.cursors = cursors; 154 | 155 | var usernameInputEl = document.getElementById('username-input'); 156 | var usersListEl = document.getElementById('users-list'); 157 | 158 | function updateUserList() { 159 | // Wipe the slate clean 160 | usersListEl.innerHTML = null; 161 | 162 | cursors.connections.forEach(function(connection) { 163 | var userItemEl = document.createElement('li'); 164 | var userNameEl = document.createElement('div'); 165 | var userDataEl = document.createElement('div'); 166 | 167 | userNameEl.innerHTML = '' + (connection.name || '(Waiting for username...)') + ''; 168 | userNameEl.classList.add('user-name'); 169 | 170 | if (connection.id == cursors.localConnection.id) 171 | userNameEl.innerHTML += ' (You)'; 172 | 173 | if (connection.range) { 174 | 175 | if (connection.id == cursors.localConnection.id) 176 | connection.range = quill.getSelection(); 177 | 178 | userDataEl.innerHTML = [ 179 | '
', 180 | '
Index: ' + connection.range.index + '
', 181 | '
Length: ' + connection.range.length + '
', 182 | '
' 183 | ].join(''); 184 | } else 185 | userDataEl.innerHTML = '(Not focusing on editor.)'; 186 | 187 | 188 | userItemEl.appendChild(userNameEl); 189 | userItemEl.appendChild(userDataEl); 190 | 191 | userItemEl.style.backgroundColor = connection.color; 192 | usersListEl.appendChild(userItemEl); 193 | }); 194 | } 195 | 196 | usernameInputEl.value = chance.name(); 197 | usernameInputEl.focus(); 198 | usernameInputEl.select(); 199 | 200 | document.getElementById('username-form').addEventListener('submit', function(event) { 201 | cursors.localConnection.name = usernameInputEl.value; 202 | cursors.update(); 203 | quill.enable(); 204 | document.getElementById('connect-panel').style.display = 'none'; 205 | document.getElementById('users-panel').style.display = 'block'; 206 | event.preventDefault(); 207 | return false; 208 | }); 209 | 210 | // DEBUG 211 | 212 | var sharedbSocketStateEl = document.getElementById('sharedb-socket-state'); 213 | var sharedbSocketIndicatorEl = document.getElementById('sharedb-socket-indicator'); 214 | 215 | shareDBConnection.on('state', function(state, reason) { 216 | var indicatorColor; 217 | 218 | console.log('[sharedb] New connection state: ' + state + ' Reason: ' + reason); 219 | 220 | sharedbSocketStateEl.innerHTML = state.toString(); 221 | 222 | switch (state.toString()) { 223 | case 'connecting': 224 | indicatorColor = 'silver'; 225 | break; 226 | case 'connected': 227 | indicatorColor = 'lime'; 228 | break; 229 | case 'disconnected': 230 | case 'closed': 231 | case 'stopped': 232 | indicatorColor = 'red'; 233 | break; 234 | } 235 | 236 | sharedbSocketIndicatorEl.style.backgroundColor = indicatorColor; 237 | }); 238 | -------------------------------------------------------------------------------- /public/javascripts/utils.js: -------------------------------------------------------------------------------- 1 | // Returns a function, that, as long as it continues to be invoked, will not 2 | // be triggered. The function will be called after it stops being called for 3 | // N milliseconds. If `immediate` is passed, trigger the function on the 4 | // leading edge, instead of the trailing. 5 | exports.debounce = function(func, wait, immediate) { 6 | var timeout; 7 | return function() { 8 | var context = this, 9 | args = arguments; 10 | var later = function() { 11 | timeout = null; 12 | if (!immediate) func.apply(context, args); 13 | }; 14 | var callNow = immediate && !timeout; 15 | clearTimeout(timeout); 16 | timeout = setTimeout(later, wait); 17 | if (callNow) func.apply(context, args); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 13px "Lucida Grande", Helvetica, Arial, sans-serif; } 4 | 5 | a { 6 | text-decoration: none; } 7 | 8 | a:hover { 9 | text-decoration: underline; } 10 | 11 | input#username-input { 12 | box-sizing: border-box; 13 | width: 100%; 14 | margin-top: 10px; 15 | margin-bottom: 10px; } 16 | 17 | button#connect-btn { 18 | width: 100%; } 19 | 20 | .title { 21 | margin-top: 0; } 22 | 23 | .layout { 24 | display: flex; } 25 | 26 | .info-container { 27 | width: 20%; 28 | min-width: 150px; 29 | margin-right: 50px; } 30 | 31 | .editor-container { 32 | width: 80%; } 33 | 34 | #users-panel { 35 | display: none; } 36 | 37 | #users-panel ul { 38 | list-style: none; 39 | padding: 0; } 40 | 41 | #users-panel ul > li { 42 | padding: 5px; 43 | border-radius: 5px; 44 | color: white; 45 | margin-bottom: 10px; } 46 | 47 | #users-panel .user-name { 48 | margin-bottom: 5px; } 49 | 50 | #users-panel .user-data { 51 | display: flex; 52 | flex-wrap: wrap; } 53 | 54 | #users-panel .user-data > div { 55 | flex-grow: 1; } 56 | 57 | .socket-indicator { 58 | height: 10px; 59 | width: 10px; 60 | display: inline-block; 61 | margin-right: 5px; 62 | border-radius: 5px; 63 | vertical-align: baseline; } 64 | 65 | .socket-state { 66 | text-transform: capitalize; } 67 | 68 | /*# sourceMappingURL=style.css.map */ -------------------------------------------------------------------------------- /public/stylesheets/style.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "file": "style.css", 4 | "sources": [ 5 | "style.scss" 6 | ], 7 | "names": [], 8 | "mappings": "AAAA,AAAA,IAAI,CAAC;EACH,OAAO,EAAE,IAAI;EACb,IAAI,EAAE,kDAAkD,GACzD;;AAED,AAAA,CAAC,CAAC;EACA,eAAe,EAAE,IAAI,GACtB;;AAED,AAAA,CAAC,AAAA,MAAM,CAAC;EACN,eAAe,EAAE,SAAS,GAC3B;;AAED,AAAA,KAAK,AAAA,eAAe,CAAC;EACnB,UAAU,EAAE,UAAU;EACtB,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,IAAI;EAChB,aAAa,EAAE,IAAI,GACpB;;AAED,AAAA,MAAM,AAAA,YAAY,CAAC;EACjB,KAAK,EAAE,IAAI,GACZ;;AAED,AAAA,MAAM,CAAC;EACL,UAAU,EAAE,CAAC,GACd;;AAED,AAAA,OAAO,CAAC;EACN,OAAO,EAAE,IAAI,GACd;;AAED,AAAA,eAAe,CAAC;EACd,KAAK,EAAE,GAAG;EACV,SAAS,EAAE,KAAK;EAChB,YAAY,EAAE,IAAI,GACnB;;AAED,AAAA,iBAAiB,CAAC;EAChB,KAAK,EAAE,GAAG,GACX;;AAED,AAAA,YAAY,CAAC;EACX,OAAO,EAAE,IAAI,GACd;;AAED,AAAa,YAAD,CAAC,EAAE,CAAC;EACd,UAAU,EAAE,IAAI;EAChB,OAAO,EAAE,CAAC,GACX;;AAED,AAAkB,YAAN,CAAC,EAAE,GAAG,EAAE,CAAC;EACnB,OAAO,EAAE,GAAG;EACZ,aAAa,EAAE,GAAG;EAClB,KAAK,EAAE,KAAK;EACZ,aAAa,EAAE,IAAI,GACpB;;AAED,AAAa,YAAD,CAAC,UAAU,CAAC;EACtB,aAAa,EAAE,GAAG,GACnB;;AAED,AAAa,YAAD,CAAC,UAAU,CAAC;EACtB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI,GAChB;;AAED,AAA0B,YAAd,CAAC,UAAU,GAAG,GAAG,CAAC;EAC5B,SAAS,EAAE,CAAC,GACb;;AAED,AAAA,iBAAiB,CAAC;EAChB,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,OAAO,EAAE,YAAY;EACrB,YAAY,EAAE,GAAG;EACjB,aAAa,EAAE,GAAG;EAClB,cAAc,EAAE,QAAQ,GACzB;;AAED,AAAA,aAAa,CAAC;EACZ,cAAc,EAAE,UAAU,GAC3B" 9 | } -------------------------------------------------------------------------------- /public/stylesheets/style.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 13px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | text-decoration: none; 8 | } 9 | 10 | a:hover { 11 | text-decoration: underline; 12 | } 13 | 14 | input#username-input { 15 | box-sizing: border-box; 16 | width: 100%; 17 | margin-top: 10px; 18 | margin-bottom: 10px; 19 | } 20 | 21 | button#connect-btn { 22 | width: 100%; 23 | } 24 | 25 | .title { 26 | margin-top: 0; 27 | } 28 | 29 | .layout { 30 | display: flex; 31 | } 32 | 33 | .info-container { 34 | width: 20%; 35 | min-width: 150px; 36 | margin-right: 50px; 37 | } 38 | 39 | .editor-container { 40 | width: 80%; 41 | } 42 | 43 | #users-panel { 44 | display: none; 45 | } 46 | 47 | #users-panel ul { 48 | list-style: none; 49 | padding: 0; 50 | } 51 | 52 | #users-panel ul > li { 53 | padding: 5px; 54 | border-radius: 5px; 55 | color: white; 56 | margin-bottom: 10px; 57 | } 58 | 59 | #users-panel .user-name { 60 | margin-bottom: 5px; 61 | } 62 | 63 | #users-panel .user-data { 64 | display: flex; 65 | flex-wrap: wrap; 66 | } 67 | 68 | #users-panel .user-data > div { 69 | flex-grow: 1; 70 | } 71 | 72 | .socket-indicator { 73 | height: 10px; 74 | width: 10px; 75 | display: inline-block; 76 | margin-right: 5px; 77 | border-radius: 5px; 78 | vertical-align: baseline; 79 | } 80 | 81 | .socket-state { 82 | text-transform: capitalize; 83 | } 84 | -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .layout 5 | .info-container 6 | h1.title quill-sharedb-cursors 7 | p 8 | | An attempt at cursor sync in a collaborative editing scenario using 9 | a(href="https://quilljs.com") Quill 10 | | and 11 | a(href="https://github.com/share/sharedb") ShareDB 12 | | . 13 | 14 | p 15 | | Built by 16 | a(href="https://github.com/pedrosanta") pedrosanta 17 | | at 18 | a(href="https://reedsy.com") Reedsy 19 | | . 20 | 21 | p 22 | | Docs and discussion 23 | a(href="https://github.com/pedrosanta/quill-sharedb-cursors") 24 | | at 25 | i.fa.fa-lg.fa-github-square 26 | | GitHub 27 | | . 28 | 29 | h3 Sockets 30 | p 31 | strong ShareDB: 32 | | 33 | span#sharedb-socket-indicator.socket-indicator 34 | span#sharedb-socket-state.socket-state 35 | p 36 | strong Cursors: 37 | | 38 | span#cursors-socket-indicator.socket-indicator 39 | span#cursors-socket-state.socket-state 40 | 41 | #connect-panel 42 | h3 Username 43 | form#username-form 44 | | Set your username and connect to edit: 45 | br 46 | input#username-input(type='text') 47 | br 48 | button#connect-btn Connect 49 | #users-panel 50 | h3 Users editing 51 | ul#users-list 52 | .editor-container 53 | #editor 54 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | link(rel='stylesheet', href='//maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css') 6 | link(rel='stylesheet', href='/stylesheets/style.css') 7 | link(rel='stylesheet', href='/quill.snow.css') 8 | link(rel='stylesheet', href='/quill-cursors.css') 9 | body 10 | block content 11 | script(src='//cdnjs.cloudflare.com/ajax/libs/chance/1.0.6/chance.min.js') 12 | script(src='/dist/bundle.js') 13 | block scripts 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: './public/javascripts/main.js', 5 | 6 | output: { 7 | filename: 'bundle.js', 8 | path: path.resolve(__dirname, 'public/dist') 9 | } 10 | }; 11 | --------------------------------------------------------------------------------