diff --git a/.gitignore b/.gitignore index 691378f..3344b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.swp *.DS_Store node_modules +package-lock.json diff --git a/README.md b/README.md index cc1de54..a404729 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,27 @@ The JSON OT type can be used to edit arbitrary JSON documents. +Forked from [ottypes/json0](https://github.com/ottypes/json0) to implement [presence](https://github.com/ottypes/docs/issues/29). + +Current status: Presence is basically working, but it's only transformed by subtype ops. Remaining work includes transforming presence by ops that are not text (`si`, `sd`) or subtype ops. Includes `li`, `ld`, `lm`, `oi`, `od`. The goal is that one day this fork will be merged into ottypes/json0 via this PR: [ottypes/json0: Presence](https://github.com/ottypes/json0/pull/31). + +In the mean time, this fork is published on NPM as [@datavis-tech/ot-json0](https://www.npmjs.com/package/@datavis-tech/ot-json0). If you want to try it out: + +``` +npm install -S @datavis-tech/ot-json0 +``` + +To [use it as the default ShareDB OT Type](https://github.com/share/sharedb/issues/284), you'll need to do the following (in both client and server): + +```js +const json0 = require('fork-of-ot-json0'); +const ShareDB = require('sharedb'); // or require('sharedb/lib/client'); +ShareDB.types.register(json0.type); +ShareDB.types.defaultType = json0.type; +``` + +To use the presence feature, you'll need to use the [Teamwork fork of ShareDB](https://github.com/teamwork/sharedb#readme) until the [ShareDB Presence PR](https://github.com/share/sharedb/pull/207) is merged. + ## Features The JSON OT type supports the following operations: @@ -294,6 +315,31 @@ offset in a string. `TEXT` must be contained at the location specified. --- +## Presence + +(inspired by https://github.com/Teamwork/ot-rich-text#presence) + +The shape of our presence data is as follows: + +```js +{ + p: ['some', 'path'], // Path of the presence. + t: 'ot-rich-text', // Subtype of the presence (a registered subtype). + s: { // Opaque presence object (subtype-specific structure). + u: '123', // An example of an ot-rich-text presence object. + c: 8, + s: [ [ 1, 1 ], [ 5, 7 ]] + } +} +``` + +Here's a demo https://github.com/datavis-tech/json0-presence-demo + +![presence](https://user-images.githubusercontent.com/68416/56134824-ffac3400-5fac-11e9-89a1-c60064c3eb67.gif) + +![selections](https://user-images.githubusercontent.com/68416/56134832-033fbb00-5fad-11e9-9274-a19b2287c5b1.gif) + + # Commentary This library was written a couple of years ago by [Jeremy Apthorp](https://github.com/nornagon). It was diff --git a/lib/json0.js b/lib/json0.js index dc3a405..e0558ee 100644 --- a/lib/json0.js +++ b/lib/json0.js @@ -651,6 +651,43 @@ json.transformComponent = function(dest, c, otherC, type) { return dest; }; +json.createPresence = function(presenceData) { + return presenceData; +}; + +json.comparePresence = function(pres1, pres2) { + return JSON.stringify(pres1) === JSON.stringify(pres2); +}; + +json.transformPresence = function(presence, op, isOwnOp) { + // Don't transform path-only presence objects. + if(!presence.t) return presence; + + for (var i = 0; i < op.length; i++) { + var c = op[i]; + + // convert old string ops to use subtype for backwards compatibility + if (c.si != null || c.sd != null) { + convertFromText(c); + } + + // Transform against subtype ops. + if (c.t && c.t === presence.t && json.pathMatches(c.p, presence.p)) { + presence = Object.assign({}, presence, { + s: subtypes[presence.t].transformPresence(presence.s, c.o, isOwnOp) + }); + } + + // convert back to old string ops + if (c.t === 'text0') { + convertToText(c); + } + + // TODO transform against non-subtype ops. + }; + return presence; +}; + require('./bootstrapTransform')(json, json.transformComponent, json.checkValidOp, json.append); /** diff --git a/lib/text0.js b/lib/text0.js index e26c6a9..cf7a64a 100644 --- a/lib/text0.js +++ b/lib/text0.js @@ -253,4 +253,34 @@ text.invert = function(op) { return op; }; +text.createPresence = function(presenceData) { + return presenceData; +}; + +// Draws from https://github.com/Teamwork/ot-rich-text/blob/master/src/Operation.js +text.transformPresence = function(presence, operation, isOwnOperation) { + var user = presence.u; + var change = presence.c; + var selections = presence.s; + var side = isOwnOperation ? 'right' : 'left'; + var newSelections = new Array(selections.length); + + for (var i = 0, l = selections.length; i < l; ++i) { + newSelections[i] = [ + text.transformCursor(selections[i][0], operation, side), + text.transformCursor(selections[i][1], operation, side) + ]; + } + + return { + u: user, + c: change, + s: newSelections + } +} + +text.comparePresence = function(pres1, pres2) { + return JSON.stringify(pres1) === JSON.stringify(pres2); +}; + require('./bootstrapTransform')(text, transformComponent, checkValidOp, append); diff --git a/package.json b/package.json index b6c9df6..d0ca82d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ot-json0", - "version": "1.1.0", + "version": "1.2.0", "description": "JSON OT type", "main": "lib/index.js", "directories": { @@ -8,9 +8,10 @@ }, "dependencies": {}, "devDependencies": { - "ot-fuzzer": "^1.0.0", + "@teamwork/ot-rich-text": "^8.1.0", + "coffee-script": "^1.7.1", "mocha": "^1.20.1", - "coffee-script": "^1.7.1" + "ot-fuzzer": "^1.0.0" }, "scripts": { "test": "mocha" diff --git a/test/json0-presence.js b/test/json0-presence.js new file mode 100644 index 0000000..e819fc0 --- /dev/null +++ b/test/json0-presence.js @@ -0,0 +1,152 @@ +const assert = require('assert'); +const json = require('../lib/json0'); +const text = require('../lib/text0'); +const otRichText = require('@teamwork/ot-rich-text') + +const { createInsertText, createRetain, createDelete } = otRichText.Action + +json.registerSubtype(otRichText.type); + +const { createPresence, comparePresence, transformPresence } = json; + +// Sample presence object using ot-rich-text sub-presence. +const samplePresence = { + p: ['some', 'path'], // Path of the presence. + t: 'ot-rich-text', // Subtype of the presence (a registered subtype). + s: { // Opaque presence object (subtype-specific structure). + u: '123', // An example of an ot-rich-text presence object. + c: 8, + s: [ [ 1, 1 ], [ 5, 7 ]] + } +} + +// Sample presence object using text0 sub-presence. +const sampleTextPresence = Object.assign({}, samplePresence, { + t: 'text0' +}); + +// Sample presence object indicating only that +// the user has "joined" the document at the top level. +const samplePathOnlyPresence = { p: [] }; + +// These tests are inspired by the ones found here: +// https://github.com/Teamwork/ot-rich-text/blob/master/test/Operation.js +describe('json0 presence', () => { + describe('createPresence', () => { + it('should return the passed in presence object', () => { + assert.strictEqual(createPresence(samplePresence), samplePresence); + }); + }); + + describe('comparePresence', () => { + it('should return true if equal', () => { + assert(comparePresence(samplePresence, samplePresence)); + }); + + it('should return false if not equal', () => { + assert(!comparePresence(samplePresence, sampleTextPresence)); + }); + }); + + describe('transformPresence', () => { + it('should preserve original presence in case of no-op', () => { + assert.deepEqual( + transformPresence(samplePresence, [], true), + samplePresence + ); + assert.deepEqual( + transformPresence(samplePresence, [], false), + samplePresence + ); + }); + + it('should transform by subtype op with matching path and subtype', () => { + const o = [ createInsertText('a') ]; + const op = [{ p: ['some', 'path'], t: otRichText.type.name, o }]; + const isOwnOp = true; + + assert.deepEqual( + transformPresence( samplePresence, op, isOwnOp), + Object.assign({}, samplePresence, { + s: otRichText.type.transformPresence(samplePresence.s, o, isOwnOp) + }) + ); + }); + + it('should transform by op with multiple components', () => { + const o1 = [ createInsertText('foo') ]; + const o2 = [ createRetain(3), createDelete(2), createInsertText('a') ]; + + let s = samplePresence.s; + s = otRichText.type.transformPresence(s, o1); + s = otRichText.type.transformPresence(s, o2); + + assert.deepEqual( + transformPresence(samplePresence, [ + { p: ['some', 'path'], t: otRichText.type.name, o: o1 }, + { p: ['some', 'path'], t: otRichText.type.name, o: o2 } + ]), + Object.assign({}, samplePresence, { s }) + ); + }); + + it('should not transform by op with matching path and non-matching subtype', () => { + assert.deepEqual( + transformPresence(samplePresence, [{ + p: ['some', 'path'], + t: 'some-invalid-name', + o: [ createInsertText('a') ] + }]), + samplePresence + ); + }); + + it('should not transform by op with non-matching path and matching subtype', () => { + assert.deepEqual( + transformPresence(samplePresence, [{ + p: ['some', 'other', 'path'], + t: otRichText.type.name, + o: [ createInsertText('a') ] + }]), + samplePresence + ); + }); + + it('should transform by text0 op', () => { + const o = [{ p: 0, i: 'a' }]; + const op = [{ p: ['some', 'path'], t: text.name, o }]; // text0 op + assert.deepEqual( + transformPresence(sampleTextPresence, op), + Object.assign({}, sampleTextPresence, { + s: text.transformPresence(sampleTextPresence.s, o) + }) + ); + }); + + it('should transform by text op (auto-convert to & from internal text0 type)', () => { + const o = [{ p: 0, i: 'a' }]; + const op = [{ p: ['some', 'path', 0], si: 'a' }]; // json0 text op + const opClone = JSON.parse(JSON.stringify(op)); + + assert.deepEqual( + transformPresence(sampleTextPresence, op), + Object.assign({}, sampleTextPresence, { + s: text.transformPresence(sampleTextPresence.s, o) + }) + ); + + // Ensure the original op survives. + assert.deepEqual(op, opClone); + }); + + it('should not break when given path-only presence', () => { + assert.deepEqual( + transformPresence(samplePathOnlyPresence, [{ + p: ['some', 'path', 0], + si: 'a' + }]), + samplePathOnlyPresence + ); + }); + }); +}); diff --git a/test/text0-presence.js b/test/text0-presence.js new file mode 100644 index 0000000..b6abe86 --- /dev/null +++ b/test/text0-presence.js @@ -0,0 +1,107 @@ +const assert = require('assert'); +const text = require('../lib/text0'); + +const { createPresence, comparePresence, transformPresence } = text; + +// Inspired by ot-rich-text presence structure. +const sampleTextPresence = { + u: '123', + c: 8, + s: [ [ 1, 1 ], [ 5, 7 ]] +}; + +// These tests are inspired by the ones found here: +// https://github.com/Teamwork/ot-rich-text/blob/master/test/Operation.js +describe('text0 presence', () => { + describe('createPresence', () => { + it('should return the passed in presence object', () => { + assert.strictEqual(createPresence(sampleTextPresence), sampleTextPresence); + }); + }); + + describe('comparePresence', () => { + it('should return true if equal', () => { + assert(comparePresence(sampleTextPresence, sampleTextPresence)); + }); + + it('should return false if not equal', () => { + assert(!comparePresence( + sampleTextPresence, + Object.assign({}, sampleTextPresence, { + s: [ [ 2, 2 ], [ 6, 8 ]] + }) + )); + }); + }); + + describe('transformPresence', () => { + it('should preserve original presence in case of no-op', () => { + assert.deepEqual( + transformPresence(sampleTextPresence, [], true), + sampleTextPresence + ); + assert.deepEqual( + transformPresence(sampleTextPresence, [], false), + sampleTextPresence + ); + }); + + it('should transform against string insertion', () => { + assert.deepEqual( + transformPresence( + sampleTextPresence, + [{ p: 0, i: 'a' }], // Insert the 'a' character at position 0. + true + ), + Object.assign({}, sampleTextPresence, { + s: [ [ 2, 2 ], [ 6, 8 ]] + }) + ); + }); + + it('should transform against own string insertion at presence position', () => { + const isOwnOperation = true; + assert.deepEqual( + transformPresence( sampleTextPresence, [{ p: 1, i: 'a' }], isOwnOperation), + Object.assign({}, sampleTextPresence, { s: [ [ 2, 2 ], [ 6, 8 ]] }) + ); + }); + + it('should transform against non-own string insertion at presence position', () => { + const isOwnOperation = false; + assert.deepEqual( + transformPresence( sampleTextPresence, [{ p: 1, i: 'a' }], isOwnOperation), + Object.assign({}, sampleTextPresence, { s: [ [ 1, 1 ], [ 6, 8 ]] }) + ); + }); + + it('should transform against string deletion', () => { + assert.deepEqual( + transformPresence( + sampleTextPresence, + [{ p: 0, d: 'a' }], + true + ), + Object.assign({}, sampleTextPresence, { + s: [ [ 0, 0 ], [ 4, 6 ]] + }) + ); + }); + + it('should transform against ops with multiple components deletion', () => { + assert.deepEqual( + transformPresence( + sampleTextPresence, + [ + { p: 0, i: 'a' }, + { p: 6, d: 'b' } + ], + true + ), + Object.assign({}, sampleTextPresence, { + s: [ [ 2, 2 ], [ 6, 7 ]] + }) + ); + }); + }); +});