Skip to content

Commit fad8e11

Browse files
authored
Merge pull request #322 from share/yet-another-presence
Add Presence functionality
2 parents eee5acc + 929d515 commit fad8e11

28 files changed

+2877
-19
lines changed

README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ __Options__
9595
through this pub/sub adapter. Defaults to `ShareDB.MemoryPubSub()`.
9696
* `options.milestoneDb` _(instance of ShareDB.MilestoneDB`)_
9797
Store snapshots of documents at a specified interval of versions
98+
* `options.presence` _boolean_
99+
Enable presence functionality. Off by default. Note that this feature is not optimized for large numbers of clients and could cause fan-out issues
98100

99101
#### Database Adapters
100102
* `ShareDB.MemoryDB`, backed by a non-persistent database with no queries
@@ -158,6 +160,7 @@ Register a new middleware.
158160
the database.
159161
* `'receive'`: Received a message from a client
160162
* `'reply'`: About to send a non-error reply to a client message
163+
* `'sendPresence'`: About to send presence information to a client
161164
* `fn` _(Function(context, callback))_
162165
Call this function at the time specified by `action`.
163166
* `context` will always have the following properties:
@@ -307,6 +310,20 @@ Get a read-only snapshot of a document at the requested version.
307310
}
308311
```
309312

313+
`connection.getPresence(channel): Presence;`
314+
Get a [`Presence`](#class-sharedbpresence) instance that can be used to subscribe to presence information to other clients, and create instances of local presence.
315+
316+
* `channel` _(String)_
317+
Presence channel to subscribe to
318+
319+
`connection.getDocPresence(collection, id): DocPresence;`
320+
Get a special [`DocPresence`](#class-sharedbdocpresence) instance that can be used to subscribe to presence information to other clients, and create instances of local presence. This is tied to a `Doc`, and all presence will be automatically transformed against ops to keep presence current. Note that the `Doc` must be of a type that supports presence.
321+
322+
* `collection` _(String)_
323+
Document collection
324+
* `id` _(String)_
325+
Document ID
326+
310327
### Class: `ShareDB.Doc`
311328

312329
`doc.type` _(String_)
@@ -640,6 +657,109 @@ const connectionInfo = getUserPermissions();
640657
const connection = backend.connect(null, connectionInfo);
641658
```
642659

660+
### Class: `ShareDB.Presence`
661+
662+
Representation of the presence data associated with a given channel.
663+
664+
#### `subscribe`
665+
666+
```javascript
667+
presence.subscribe(callback): void;
668+
```
669+
670+
Subscribe to presence updates from other clients. Note that presence can be submitted without subscribing, but remote clients will not be able to re-request presence from you if you are not subscribed.
671+
672+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
673+
674+
#### `unsubscribe`
675+
676+
```javascript
677+
presence.unsubscribe(callback): void;
678+
```
679+
680+
Unsubscribe from presence updates from remote clients.
681+
682+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
683+
684+
#### `on`
685+
686+
```javascript
687+
presence.on('receive', callback): void;
688+
```
689+
690+
An update from a remote presence client has been received.
691+
692+
* `callback` _Function_: callback for handling the received presence: `function (presenceId, presenceValue): void;`
693+
694+
```javascript
695+
presence.on('error', callback): void;
696+
```
697+
698+
A presence-related error has occurred.
699+
700+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
701+
702+
#### `create`
703+
704+
```javascript
705+
presence.create(presenceId): LocalPresence;
706+
```
707+
708+
Create an instance of [`LocalPresence`](#class-sharedblocalpresence), which can be used to represent local presence. Many or none such local presences may exist on a `Presence` instance.
709+
710+
* `presenceId` _string (optional)_: a unique ID representing the local presence. Remember - depending on use-case - the same client might have multiple presences, so this might not necessarily be a user or client ID. If one is not provided, a random ID will be assigned for you.
711+
712+
#### `destroy`
713+
714+
```javascript
715+
presence.destroy(callback);
716+
```
717+
718+
Updates all remote clients with a `null` presence, and removes it from the `Connection` cache, so that it can be garbage-collected. This should be called when you are done with a presence, and no longer need to use it to fire updates.
719+
720+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
721+
722+
### Class: `ShareDB.DocPresence`
723+
724+
Specialised case of [`Presence`](#class-sharedbpresence), which is tied to a specific [`Doc`](#class-sharedbdoc). When using presence with an associated `Doc`, any ops applied to the `Doc` will automatically be used to transform associated presence. On destroy, the `DocPresence` will unregister its listeners from the `Doc`.
725+
726+
See [`Presence`](#class-sharedbpresence) for available methods.
727+
728+
### Class: `ShareDB.LocalPresence`
729+
730+
`LocalPresence` represents the presence of the local client in a given `Doc`. For example, this might be the position of a caret in a text document; which field has been highlighted in a complex JSON object; etc. Multiple presences may exist per `Doc` even on the same client.
731+
732+
#### `submit`
733+
734+
```javascript
735+
localPresence.submit(presence, callback): void;
736+
```
737+
738+
Update the local representation of presence, and broadcast that presence to any other document presence subscribers.
739+
740+
* `presence` _Object_: the presence object to broadcast. The structure of this will depend on the OT type
741+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
742+
743+
#### `send`
744+
745+
```javascript
746+
localPresence.send(callback): void;
747+
```
748+
749+
Send presence like `submit`, but without updating the value. Can be useful if local presences expire periodically.
750+
751+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
752+
753+
#### `destroy`
754+
755+
```javascript
756+
localPresence.destroy(callback): void;
757+
```
758+
759+
Informs all remote clients that this presence is now `null`, and deletes itself for garbage collection.
760+
761+
* `callback` _Function_: a callback with the signature `function (error: Error): void;`
762+
643763
### Logging
644764

645765
By default, ShareDB logs to `console`. This can be overridden if you wish to silence logs, or to log to your own logging driver or alert service.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
static/dist/

examples/rich-text-presence/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Collaborative Rich Text Editor with ShareDB
2+
3+
This is a collaborative rich text editor using [Quill](https://github.com/quilljs/quill) and the [rich-text OT type](https://github.com/ottypes/rich-text).
4+
5+
In this demo, data is not persisted. To persist data, run a Mongo
6+
server and initialize ShareDB with the
7+
[ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter.
8+
9+
## Install dependencies
10+
```
11+
npm install
12+
```
13+
14+
## Build JavaScript bundle and run server
15+
```
16+
npm run build && npm start
17+
```
18+
19+
## Run app in browser
20+
Load [http://localhost:8080](http://localhost:8080)

examples/rich-text-presence/client.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
var ReconnectingWebSocket = require('reconnecting-websocket');
2+
var sharedb = require('sharedb/lib/client');
3+
var richText = require('./rich-text');
4+
var Quill = require('quill');
5+
var QuillCursors = require('quill-cursors');
6+
var tinycolor = require('tinycolor2');
7+
var ObjectID = require('bson-objectid');
8+
9+
sharedb.types.register(richText.type);
10+
Quill.register('modules/cursors', QuillCursors);
11+
12+
var connectionButton = document.getElementById('client-connection');
13+
connectionButton.addEventListener('click', function() {
14+
toggleConnection(connectionButton);
15+
});
16+
17+
var nameInput = document.getElementById('name');
18+
19+
var colors = {};
20+
21+
var collection = 'examples';
22+
var id = 'richtext';
23+
var presenceId = new ObjectID().toString();
24+
25+
var socket = new ReconnectingWebSocket('ws://' + window.location.host);
26+
var connection = new sharedb.Connection(socket);
27+
var doc = connection.get(collection, id);
28+
29+
doc.subscribe(function(err) {
30+
if (err) throw err;
31+
initialiseQuill(doc);
32+
});
33+
34+
function initialiseQuill(doc) {
35+
var quill = new Quill('#editor', {
36+
theme: 'bubble',
37+
modules: {cursors: true}
38+
});
39+
var cursors = quill.getModule('cursors');
40+
41+
quill.setContents(doc.data);
42+
43+
quill.on('text-change', function(delta, oldDelta, source) {
44+
if (source !== 'user') return;
45+
doc.submitOp(delta);
46+
});
47+
48+
doc.on('op', function(op, source) {
49+
if (source) return;
50+
quill.updateContents(op);
51+
});
52+
53+
var presence = doc.connection.getDocPresence(collection, id);
54+
presence.subscribe(function(error) {
55+
if (error) throw error;
56+
});
57+
var localPresence = presence.create(presenceId);
58+
59+
quill.on('selection-change', function(range) {
60+
// Ignore blurring, so that we can see lots of users in the
61+
// same window. In real use, you may want to clear the cursor.
62+
if (!range) return;
63+
// In this particular instance, we can send extra information
64+
// on the presence object. This ability will vary depending on
65+
// type.
66+
range.name = nameInput.value;
67+
localPresence.submit(range, function(error) {
68+
if (error) throw error;
69+
});
70+
});
71+
72+
presence.on('receive', function(id, range) {
73+
colors[id] = colors[id] || tinycolor.random().toHexString();
74+
var name = (range && range.name) || 'Anonymous';
75+
cursors.createCursor(id, name, colors[id]);
76+
cursors.moveCursor(id, range);
77+
});
78+
79+
return quill;
80+
}
81+
82+
function toggleConnection(button) {
83+
if (button.classList.contains('connected')) {
84+
button.classList.remove('connected');
85+
button.textContent = 'Connect';
86+
disconnect();
87+
} else {
88+
button.classList.add('connected');
89+
button.textContent = 'Disconnect';
90+
connect();
91+
}
92+
}
93+
94+
function disconnect() {
95+
doc.connection.close();
96+
}
97+
98+
function connect() {
99+
var socket = new ReconnectingWebSocket('ws://' + window.location.host);
100+
doc.connection.bindToSocket(socket);
101+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "sharedb-example-rich-text-presence",
3+
"version": "1.0.0",
4+
"description": "An example of presence using ShareDB and Quill",
5+
"main": "server.js",
6+
"scripts": {
7+
"build": "mkdir -p static/dist/ && ./node_modules/.bin/browserify client.js -o static/dist/bundle.js",
8+
"test": "echo \"Error: no test specified\" && exit 1",
9+
"start": "node server.js"
10+
},
11+
"author": "Nate Smith",
12+
"contributors": [
13+
"Avital Oliver <[email protected]> (https://aoliver.org/)",
14+
"Alec Gibson <[email protected]>"
15+
],
16+
"license": "MIT",
17+
"dependencies": {
18+
"@teamwork/websocket-json-stream": "^2.0.0",
19+
"bson-objectid": "^1.3.0",
20+
"express": "^4.17.1",
21+
"quill": "^1.3.7",
22+
"quill-cursors": "^2.2.1",
23+
"reconnecting-websocket": "^4.2.0",
24+
"rich-text": "^4.0.0",
25+
"sharedb": "file:../../",
26+
"tinycolor2": "^1.4.1",
27+
"ws": "^7.2.0"
28+
},
29+
"devDependencies": {
30+
"browserify": "^16.5.0"
31+
}
32+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
var richText = require('rich-text');
2+
3+
richText.type.transformPresence = function(presence, op, isOwnOp) {
4+
if (!presence) {
5+
return null;
6+
}
7+
8+
var start = presence.index;
9+
var end = presence.index + presence.length;
10+
var delta = new richText.Delta(op);
11+
start = delta.transformPosition(start, !isOwnOp);
12+
end = delta.transformPosition(end, !isOwnOp);
13+
14+
return Object.assign({}, presence, {
15+
index: start,
16+
length: end - start
17+
});
18+
};
19+
20+
module.exports = richText;

examples/rich-text-presence/server.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
var http = require('http');
2+
var express = require('express');
3+
var ShareDB = require('sharedb');
4+
var richText = require('./rich-text');
5+
var WebSocket = require('ws');
6+
var WebSocketJSONStream = require('@teamwork/websocket-json-stream');
7+
8+
ShareDB.types.register(richText.type);
9+
var backend = new ShareDB({presence: true});
10+
createDoc(startServer);
11+
12+
// Create initial document then fire callback
13+
function createDoc(callback) {
14+
var connection = backend.connect();
15+
var doc = connection.get('examples', 'richtext');
16+
doc.fetch(function(err) {
17+
if (err) throw err;
18+
if (doc.type === null) {
19+
doc.create([{insert: 'Hi!'}], 'rich-text', callback);
20+
return;
21+
}
22+
callback();
23+
});
24+
}
25+
26+
function startServer() {
27+
// Create a web server to serve files and listen to WebSocket connections
28+
var app = express();
29+
app.use(express.static('static'));
30+
app.use(express.static('node_modules/quill/dist'));
31+
var server = http.createServer(app);
32+
33+
// Connect any incoming WebSocket connection to ShareDB
34+
var wss = new WebSocket.Server({server: server});
35+
wss.on('connection', function(ws) {
36+
var stream = new WebSocketJSONStream(ws);
37+
backend.listen(stream);
38+
});
39+
40+
server.listen(8080);
41+
console.log('Listening on http://localhost:8080');
42+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<title>ShareDB Rich Text</title>
4+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
5+
<link href="quill.bubble.css" rel="stylesheet">
6+
<link href="style.css" rel="stylesheet">
7+
8+
<div class="controls">
9+
<input type="text" placeholder="Enter your name..." id="name" />
10+
<button id="client-connection" class="connected">Disconnect</button>
11+
</div>
12+
13+
<center>
14+
Open a new window to see another client!
15+
</center>
16+
17+
<div id="editor"></div>
18+
19+
<script src="dist/bundle.js"></script>

0 commit comments

Comments
 (0)