Skip to content

Commit

Permalink
✨ Add presence support
Browse files Browse the repository at this point in the history
This change adds support for the `transformPresence()` method that
[`sharedb` uses][1].

We add support for both `text0` and `json0`.

`text0`
-------

The `text0` implementation leans on the existing
[`transformPosition`][2], and takes its form and tests from
[`rich-text`][3].

Its shape takes the form:

```js
{
  index: 3,
  length: 5,
}
```

Where:

 - `index` is the cursor position
 - `length` is the selection length (`0` for a collapsed selection)

`json0`
-------

The `json0` implementation has limited functionality because of the
limitations of the `json0` type itself: we handle list moves `lm`, but
cannot infer any information when moving objects around the tree,
because the `oi` and `od` operations are destructive.

However, it will attempt to transform embedded subtypes that support
presence.

Its shape takes the form:

```js
{
  p: ['key', 123],
  v: {},
}
```

Where:

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

The presence value `v` can take any arbitrary value (in simple cases it
may even be omitted entirely).

The exception to this is when using subtypes, where `v` should take the
presence shape defined by the subtype. For example, when using `text0`:

```js
{
  p: ['key'],
  v: {index: 5, length: 0},
}
```

[1]: share/sharedb#322
[2]: https://github.com/ottypes/json0/blob/90a3ae26364c4fa3b19b6df34dad46707a704421/lib/text0.js#L147
[3]: ottypes/rich-text#32
  • Loading branch information
alecgibson committed Dec 30, 2021
1 parent 90a3ae2 commit 7d721e7
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 0 deletions.
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
58 changes: 58 additions & 0 deletions lib/json0.js
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,64 @@ 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);

// Deletions should be treated as insertions: transforming
// by them should result in a no-op (null presence), so
// let's just pretend that deletions are oi to get the
// desired transform behaviour
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);
}
}

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

for (var i = 0; i < op.length; i++) {
var component = op[i];
// Set side as 'right' because we always want the op to win ties, since
// our transformed "op" isn't really an op
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', ->
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

0 comments on commit 7d721e7

Please sign in to comment.