Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add presence support #48

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,60 @@ offset in a string. `TEXT` must be contained at the location specified.

---

## Presence

`json0` has some limited support for presence information: information about
clients' transient position within a document (eg their cursor or selection).

It also supports presence in `text0`.

### Format

#### `json0`

The format of a `json0` presence object follows a similar syntax to its ops:

{p: ['key', 123], v: 0}

Where :

- `p` is the path to the client's position within the document
- `v` is the client's presence "value"

The presence value `v` can take any arbitrary value or shape, unless the property
is a subtype. In this case, the value in `v` will be passed to the subtype's own
`transformPresence` method (see below for an example with `text0`).

#### `text0`

The `text0` presence takes the format of:

{index: 0, length: 5}

Where:

- `index` is the start of the client's cursor
- `length` is the length of their selection (`0` for a collapsed selection)

For example, given a string `'abc'`, a client's position could be represented as: `{index: 1, length: 1}` if they have the letter "b" highlighted.

`text0` presence can be embedded within `json0`. For example, given this document:
`{foo: 'abc'}`, the same highlight would be represented as:
`{p: ['foo'], v: {index: 1, length: 1}}`

### Limitations

`json0` presence mostly exists to allow subtype presence updates for embedded
documents.

Moving embedded documents within a `json0` document has limited presence support,
because `json0` has no concept of object moves. As such, `json0` will preserve
presence information when performing a list move `lm`, but any `oi` or `od` ops
will destroy presence information in the affected subtree, since these are
destructive operations.

---

# Commentary

This library was written a couple of years ago by [Jeremy Apthorp](https://github.com/nornagon). It was
Expand Down
66 changes: 66 additions & 0 deletions lib/json0.js
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,72 @@ json.transformComponent = function(dest, c, otherC, type) {
return dest;
};

json.transformPresence = function(presence, op, isOwnOp) {
if (!presence || !isArray(presence.p)) return null;
if (!op) return presence;

presence = clone(presence);
op = clone(op);

// Create a fake op so we can transform the presence path using
// existing machinery
var transformed = [{p: presence.p, oi: ''}];

// Below, we transform the presence path using existing json0
// transform() machinery. Since oi and od are both destructive
// operations, we want them both to act the same way: destroy
// our presence.
// We transform by constructing a "fake op" to hold our presence
// path, which just as an empty oi. In json0:
// transform([{p: [...], oi: {...}}], [{p: [...], oi: {...}}])
// will result in a no-op, which is the behaviour we want.
// However:
// transform([{p: [...], oi: {...}}], [{p: [...], od: {...}}])
// does **not** no-op.
// In order to get our desired behaviour, we turn our od and ld
// op components into oi, in order to correctly transform to a
// no-op.
for (var i = 0; i < op.length; i++) {
const component = op[i];
if ('od' in component) {
component.oi = component.od;
delete component.od;
}

// Need to actively check that the list deletion matches
// the presence deletion, otherwise we need to keep this
// as an ld to correctly transform the path.
if ('ld' in component && pathMatches(component.p, presence.p)) {
component.oi = component.ld;
delete component.ld;
}

// Handle text0 ops using the subtype
if ('si' in component || 'sd' in component) {
convertFromText(component);
}

// Set side as 'right' because we always want the op to win ties, since
// our transformed "op" isn't really an op.
// This transform is just to handle list changes as a result of li, ld or lm.
transformed = json.transform(transformed, [component], 'right');
if (!transformed.length) return null;
presence.p = transformed[0].p;

var subtype = component.t && subtypes[component.t];

var subtypeShouldTransform = subtype &&
typeof subtype.transformPresence === 'function' &&
pathMatches(component.p, presence.p);

if (subtypeShouldTransform) {
presence.v = subtype.transformPresence(presence.v, component.o, isOwnOp);
}
}

return presence;
};

require('./bootstrapTransform')(json, json.transformComponent, json.checkValidOp, json.append);

/**
Expand Down
16 changes: 16 additions & 0 deletions lib/text0.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,20 @@ text.invert = function(op) {
return op;
};

text.transformPresence = function(range, op, isOwnOp) {
if (!range) return null;
if (!op) return range;

range = JSON.parse(JSON.stringify(range));
var side = isOwnOp ? 'right' : 'left';

var start = text.transformCursor(range.index, op, side);
var end = text.transformCursor(range.index + range.length, op, side);

range.index = start;
range.length = end - start;

return range;
};

require('./bootstrapTransform')(text, transformComponent, checkValidOp, append);
61 changes: 61 additions & 0 deletions test/json0.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,67 @@ genTests = (type) ->
fuzzer type, require('./json0-generator'), 1000
delete type._testStringSubtype

describe '#transformPresence', ->
it 'moves presence touched directly with lm', ->
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['x', 1], lm: 2}]

it 'does not move presence when touching other parts of the document', ->
assert.deepEqual {p: ['x', 1], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['a'], oi: 'foo'}]

it 'moves presence indirectly moved by li', ->
alecgibson marked this conversation as resolved.
Show resolved Hide resolved
assert.deepEqual {p: ['x', 3], v: 0}, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 0], li: 'foo'}]

it 'moves presence indirectly moved by ld', ->
assert.deepEqual {p: ['x', 1], v: 0}, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 0], ld: 'foo'}]

it 'moves deep presence moved by a higher li', ->
assert.deepEqual {p: ['x', 3, 'y'], v: 0}, type.transformPresence {p: ['x', 2, 'y'], v: 0}, [{p: ['x', 1], li: 'foo'}]

it 'removes presence when an object is overwritten', ->
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], oi: 'foo'}]

it 'removes presence when an object is deleted', ->
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], od: 'foo'}]

it 'removes presence when a list item is deleted', ->
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], ld: 'foo'}]

it 'moves presence as part of a series of op components', ->
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['a'], oi: 'baz'}, {p: ['x', 1], lm: 2}]

it 'moves presence as part of a series of op components affecting the presence', ->
presence = {p: ['x', 3], v: 0}
op = [
{p: ['x', 3], lm: 2},
{p: ['x', 2], lm: 1},
{p: ['x', 0], li: 'foo'},
]
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence presence, op

it 'returns null when no presence is provided', ->
assert.deepEqual null, type.transformPresence undefined, [{p: ['x'], oi: 'foo'}]

it 'does nothing if no op is provided', ->
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 2], v:0}, undefined

it 'does not mutate the original presence', ->
presence = {p: ['x', 2], v: 0}
type.transformPresence presence, [{p: ['x', 2], lm: 1}]
assert.deepEqual {p: ['x', 2], v: 0}, presence

it 'keeps extra metadata when tranforming', ->
assert.deepEqual {p: ['x', 1], v: 0, meta: 'foo'}, type.transformPresence {p: ['x', 2], v: 0, meta: 'foo'}, [{p: ['x', 2], lm: 1}]

it 'returns null for an invalid presence', ->
assert.deepEqual null, type.transformPresence {}, [{p: ['x', 1], lm: 2}]

describe 'text0', ->
it 'transforms presence by an si', ->
assert.deepEqual {p: ['x'], v: {index: 3, length: 1}}, type.transformPresence {p: ['x'], v: {index: 2, length: 1}}, [{p: ['x', 0], si: 'a'}]

it 'transforms presence by an sd', ->
assert.deepEqual {p: ['x'], v: {index: 2, length: 0}}, type.transformPresence {p: ['x'], v: {index: 3, length: 1}}, [{p: ['x', 2], sd: 'abc'}]

describe 'json', ->
describe 'native type', -> genTests nativetype
#exports.webclient = genTests require('../helpers/webclient').types.json
36 changes: 36 additions & 0 deletions test/text0.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ describe 'text0', ->
t [{d:'abc', p:10}, {d:'xyz', p:6}]
t [{d:'abc', p:10}, {d:'xyz', p:11}]

describe '#transformPresence', ->
it 'transforms a zero-length range by an op before it', ->
assert.deepEqual {index: 13, length: 0}, text0.transformPresence {index: 10, length: 0}, [{p: 0, i: 'foo'}]

it 'does not transform a zero-length range by an op after it', ->
assert.deepEqual {index: 10, length: 0}, text0.transformPresence {index: 10, length: 0}, [{p: 20, i: 'foo'}]

it 'transforms a range with length by an op before it', ->
assert.deepEqual {index: 13, length: 3}, text0.transformPresence {index: 10, length: 3}, [{p: 0, i: 'foo'}]

it 'transforms a range with length by an op that deletes part of it', ->
assert.deepEqual {index: 9, length: 1}, text0.transformPresence {index: 10, length: 3}, [{p: 9, d: 'abc'}]

it 'transforms a range with length by an op that deletes the whole range', ->
assert.deepEqual {index: 9, length: 0}, text0.transformPresence {index: 10, length: 3}, [{p: 9, d: 'abcde'}]

it 'keeps extra metadata when transforming', ->
assert.deepEqual {index: 13, length: 0, meta: 'lorem ipsum'}, text0.transformPresence {index: 10, length: 0, meta: 'lorem ipsum'}, [{p: 0, i: 'foo'}]

it 'returns null when no presence is provided', ->
assert.deepEqual null, text0.transformPresence undefined, [{p: 0, i: 'foo'}]

it 'advances the cursor if inserting at own index', ->
assert.deepEqual {index: 13, length: 2}, text0.transformPresence {index: 10, length: 2}, [{p: 10, i: 'foo'}], true

it 'does not advance the cursor if not own op', ->
assert.deepEqual {index: 10, length: 5}, text0.transformPresence {index: 10, length: 2}, [{p: 10, i: 'foo'}], false

it 'does nothing if no op is provided', ->
assert.deepEqual {index: 10, length: 0}, text0.transformPresence {index: 10, length: 0}, undefined

it 'does not mutate the original range', ->
range = {index: 10, length: 0}
text0.transformPresence range, [{p: 0, i: 'foo'}]
assert.deepEqual {index: 10, length: 0}, range


describe 'randomizer', -> it 'passes', ->
@timeout 4000
Expand Down