Skip to content

Commit

Permalink
Merge 929d515 into eee5acc
Browse files Browse the repository at this point in the history
  • Loading branch information
alecgibson committed Mar 2, 2020
2 parents eee5acc + 929d515 commit d241867
Show file tree
Hide file tree
Showing 28 changed files with 2,877 additions and 19 deletions.
120 changes: 120 additions & 0 deletions README.md
Expand Up @@ -95,6 +95,8 @@ __Options__
through this pub/sub adapter. Defaults to `ShareDB.MemoryPubSub()`.
* `options.milestoneDb` _(instance of ShareDB.MilestoneDB`)_
Store snapshots of documents at a specified interval of versions
* `options.presence` _boolean_
Enable presence functionality. Off by default. Note that this feature is not optimized for large numbers of clients and could cause fan-out issues

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

`connection.getPresence(channel): Presence;`
Get a [`Presence`](#class-sharedbpresence) instance that can be used to subscribe to presence information to other clients, and create instances of local presence.

* `channel` _(String)_
Presence channel to subscribe to

`connection.getDocPresence(collection, id): DocPresence;`
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.

* `collection` _(String)_
Document collection
* `id` _(String)_
Document ID

### Class: `ShareDB.Doc`

`doc.type` _(String_)
Expand Down Expand Up @@ -640,6 +657,109 @@ const connectionInfo = getUserPermissions();
const connection = backend.connect(null, connectionInfo);
```

### Class: `ShareDB.Presence`

Representation of the presence data associated with a given channel.

#### `subscribe`

```javascript
presence.subscribe(callback): void;
```

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.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `unsubscribe`

```javascript
presence.unsubscribe(callback): void;
```

Unsubscribe from presence updates from remote clients.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `on`

```javascript
presence.on('receive', callback): void;
```

An update from a remote presence client has been received.

* `callback` _Function_: callback for handling the received presence: `function (presenceId, presenceValue): void;`

```javascript
presence.on('error', callback): void;
```

A presence-related error has occurred.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `create`

```javascript
presence.create(presenceId): LocalPresence;
```

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.

* `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.

#### `destroy`

```javascript
presence.destroy(callback);
```

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.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

### Class: `ShareDB.DocPresence`

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`.

See [`Presence`](#class-sharedbpresence) for available methods.

### Class: `ShareDB.LocalPresence`

`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.

#### `submit`

```javascript
localPresence.submit(presence, callback): void;
```

Update the local representation of presence, and broadcast that presence to any other document presence subscribers.

* `presence` _Object_: the presence object to broadcast. The structure of this will depend on the OT type
* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `send`

```javascript
localPresence.send(callback): void;
```

Send presence like `submit`, but without updating the value. Can be useful if local presences expire periodically.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `destroy`

```javascript
localPresence.destroy(callback): void;
```

Informs all remote clients that this presence is now `null`, and deletes itself for garbage collection.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

### Logging

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.
Expand Down
1 change: 1 addition & 0 deletions examples/rich-text-presence/.gitignore
@@ -0,0 +1 @@
static/dist/
20 changes: 20 additions & 0 deletions examples/rich-text-presence/README.md
@@ -0,0 +1,20 @@
# Collaborative Rich Text Editor with ShareDB

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).

In this demo, data is not persisted. To persist data, run a Mongo
server and initialize ShareDB with the
[ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter.

## Install dependencies
```
npm install
```

## Build JavaScript bundle and run server
```
npm run build && npm start
```

## Run app in browser
Load [http://localhost:8080](http://localhost:8080)
101 changes: 101 additions & 0 deletions examples/rich-text-presence/client.js
@@ -0,0 +1,101 @@
var ReconnectingWebSocket = require('reconnecting-websocket');
var sharedb = require('sharedb/lib/client');
var richText = require('./rich-text');
var Quill = require('quill');
var QuillCursors = require('quill-cursors');
var tinycolor = require('tinycolor2');
var ObjectID = require('bson-objectid');

sharedb.types.register(richText.type);
Quill.register('modules/cursors', QuillCursors);

var connectionButton = document.getElementById('client-connection');
connectionButton.addEventListener('click', function() {
toggleConnection(connectionButton);
});

var nameInput = document.getElementById('name');

var colors = {};

var collection = 'examples';
var id = 'richtext';
var presenceId = new ObjectID().toString();

var socket = new ReconnectingWebSocket('ws://' + window.location.host);
var connection = new sharedb.Connection(socket);
var doc = connection.get(collection, id);

doc.subscribe(function(err) {
if (err) throw err;
initialiseQuill(doc);
});

function initialiseQuill(doc) {
var quill = new Quill('#editor', {
theme: 'bubble',
modules: {cursors: true}
});
var cursors = quill.getModule('cursors');

quill.setContents(doc.data);

quill.on('text-change', function(delta, oldDelta, source) {
if (source !== 'user') return;
doc.submitOp(delta);
});

doc.on('op', function(op, source) {
if (source) return;
quill.updateContents(op);
});

var presence = doc.connection.getDocPresence(collection, id);
presence.subscribe(function(error) {
if (error) throw error;
});
var localPresence = presence.create(presenceId);

quill.on('selection-change', function(range) {
// Ignore blurring, so that we can see lots of users in the
// same window. In real use, you may want to clear the cursor.
if (!range) return;
// In this particular instance, we can send extra information
// on the presence object. This ability will vary depending on
// type.
range.name = nameInput.value;
localPresence.submit(range, function(error) {
if (error) throw error;
});
});

presence.on('receive', function(id, range) {
colors[id] = colors[id] || tinycolor.random().toHexString();
var name = (range && range.name) || 'Anonymous';
cursors.createCursor(id, name, colors[id]);
cursors.moveCursor(id, range);
});

return quill;
}

function toggleConnection(button) {
if (button.classList.contains('connected')) {
button.classList.remove('connected');
button.textContent = 'Connect';
disconnect();
} else {
button.classList.add('connected');
button.textContent = 'Disconnect';
connect();
}
}

function disconnect() {
doc.connection.close();
}

function connect() {
var socket = new ReconnectingWebSocket('ws://' + window.location.host);
doc.connection.bindToSocket(socket);
}
32 changes: 32 additions & 0 deletions examples/rich-text-presence/package.json
@@ -0,0 +1,32 @@
{
"name": "sharedb-example-rich-text-presence",
"version": "1.0.0",
"description": "An example of presence using ShareDB and Quill",
"main": "server.js",
"scripts": {
"build": "mkdir -p static/dist/ && ./node_modules/.bin/browserify client.js -o static/dist/bundle.js",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"author": "Nate Smith",
"contributors": [
"Avital Oliver <avital@aoliver.org> (https://aoliver.org/)",
"Alec Gibson <alec@reedsy.com>"
],
"license": "MIT",
"dependencies": {
"@teamwork/websocket-json-stream": "^2.0.0",
"bson-objectid": "^1.3.0",
"express": "^4.17.1",
"quill": "^1.3.7",
"quill-cursors": "^2.2.1",
"reconnecting-websocket": "^4.2.0",
"rich-text": "^4.0.0",
"sharedb": "file:../../",
"tinycolor2": "^1.4.1",
"ws": "^7.2.0"
},
"devDependencies": {
"browserify": "^16.5.0"
}
}
20 changes: 20 additions & 0 deletions examples/rich-text-presence/rich-text.js
@@ -0,0 +1,20 @@
var richText = require('rich-text');

richText.type.transformPresence = function(presence, op, isOwnOp) {
if (!presence) {
return null;
}

var start = presence.index;
var end = presence.index + presence.length;
var delta = new richText.Delta(op);
start = delta.transformPosition(start, !isOwnOp);
end = delta.transformPosition(end, !isOwnOp);

return Object.assign({}, presence, {
index: start,
length: end - start
});
};

module.exports = richText;
42 changes: 42 additions & 0 deletions examples/rich-text-presence/server.js
@@ -0,0 +1,42 @@
var http = require('http');
var express = require('express');
var ShareDB = require('sharedb');
var richText = require('./rich-text');
var WebSocket = require('ws');
var WebSocketJSONStream = require('@teamwork/websocket-json-stream');

ShareDB.types.register(richText.type);
var backend = new ShareDB({presence: true});
createDoc(startServer);

// Create initial document then fire callback
function createDoc(callback) {
var connection = backend.connect();
var doc = connection.get('examples', 'richtext');
doc.fetch(function(err) {
if (err) throw err;
if (doc.type === null) {
doc.create([{insert: 'Hi!'}], 'rich-text', callback);
return;
}
callback();
});
}

function startServer() {
// Create a web server to serve files and listen to WebSocket connections
var app = express();
app.use(express.static('static'));
app.use(express.static('node_modules/quill/dist'));
var server = http.createServer(app);

// Connect any incoming WebSocket connection to ShareDB
var wss = new WebSocket.Server({server: server});
wss.on('connection', function(ws) {
var stream = new WebSocketJSONStream(ws);
backend.listen(stream);
});

server.listen(8080);
console.log('Listening on http://localhost:8080');
}
19 changes: 19 additions & 0 deletions examples/rich-text-presence/static/index.html
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>ShareDB Rich Text</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<link href="quill.bubble.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">

<div class="controls">
<input type="text" placeholder="Enter your name..." id="name" />
<button id="client-connection" class="connected">Disconnect</button>
</div>

<center>
Open a new window to see another client!
</center>

<div id="editor"></div>

<script src="dist/bundle.js"></script>

0 comments on commit d241867

Please sign in to comment.