Skip to content

Commit 58eea07

Browse files
committed
✨ Add presence support
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
1 parent 90a3ae2 commit 58eea07

File tree

5 files changed

+233
-0
lines changed

5 files changed

+233
-0
lines changed

README.md

+54
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,60 @@ offset in a string. `TEXT` must be contained at the location specified.
294294

295295
---
296296

297+
## Presence
298+
299+
`json0` has some limited support for presence information: information about
300+
clients' transient position within a document (eg their cursor or selection).
301+
302+
It also supports presence in `text0`.
303+
304+
### Format
305+
306+
#### `json0`
307+
308+
The format of a `json0` presence object follows a similar syntax to its ops:
309+
310+
{p: ['key', 123], v: 0}
311+
312+
Where :
313+
314+
- `p` is the path to the client's position within the document
315+
- `v` is the client's presence "value"
316+
317+
The presence value `v` can take any arbitrary value or shape, unless the property
318+
is a subtype. In this case, the value in `v` will be passed to the subtype's own
319+
`transformPresence` method (see below for an example with `text0`).
320+
321+
#### `text0`
322+
323+
The `text0` presence takes the format of:
324+
325+
{index: 0, length: 5}
326+
327+
Where:
328+
329+
- `index` is the start of the client's cursor
330+
- `length` is the length of their selection (`0` for a collapsed selection)
331+
332+
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.
333+
334+
`text0` presence can be embedded within `json0`. For example, given this document:
335+
`{foo: 'abc'}`, the same highlight would be represented as:
336+
`{p: ['foo'], v: {index: 1, length: 1}}`
337+
338+
### Limitations
339+
340+
`json0` presence mostly exists to allow subtype presence updates for embedded
341+
documents.
342+
343+
Moving embedded documents within a `json0` document has limited presence support,
344+
because `json0` has no concept of object moves. As such, `json0` will preserve
345+
presence information when performing a list move `lm`, but any `oi` or `od` ops
346+
will destroy presence information in the affected subtree, since these are
347+
destructive operations.
348+
349+
---
350+
297351
# Commentary
298352

299353
This library was written a couple of years ago by [Jeremy Apthorp](https://github.com/nornagon). It was

lib/json0.js

+66
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,72 @@ json.transformComponent = function(dest, c, otherC, type) {
663663
return dest;
664664
};
665665

666+
json.transformPresence = function(presence, op, isOwnOp) {
667+
if (!presence || !isArray(presence.p)) return null;
668+
if (!op) return presence;
669+
670+
presence = clone(presence);
671+
op = clone(op);
672+
673+
// Create a fake op so we can transform the presence path using
674+
// existing machinery
675+
var transformed = [{p: presence.p, oi: ''}];
676+
677+
// Below, we transform the presence path using existing json0
678+
// transform() machinery. Since oi and od are both destructive
679+
// operations, we want them both to act the same way: destroy
680+
// our presence.
681+
// We transform by constructing a "fake op" to hold our presence
682+
// path, which just as an empty oi. In json0:
683+
// transform([{p: [...], oi: {...}}], [{p: [...], oi: {...}}])
684+
// will result in a no-op, which is the behaviour we want.
685+
// However:
686+
// transform([{p: [...], oi: {...}}], [{p: [...], od: {...}}])
687+
// does **not** no-op.
688+
// In order to get our desired behaviour, we turn our od and ld
689+
// op components into oi, in order to correctly transform to a
690+
// no-op.
691+
for (var i = 0; i < op.length; i++) {
692+
const component = op[i];
693+
if ('od' in component) {
694+
component.oi = component.od;
695+
delete component.od;
696+
}
697+
698+
// Need to actively check that the list deletion matches
699+
// the presence deletion, otherwise we need to keep this
700+
// as an ld to correctly transform the path.
701+
if ('ld' in component && pathMatches(component.p, presence.p)) {
702+
component.oi = component.ld;
703+
delete component.ld;
704+
}
705+
706+
// Handle text0 ops using the subtype
707+
if ('si' in component || 'sd' in component) {
708+
convertFromText(component);
709+
}
710+
711+
// Set side as 'right' because we always want the op to win ties, since
712+
// our transformed "op" isn't really an op.
713+
// This transform is just to handle list changes as a result of li, ld or lm.
714+
transformed = json.transform(transformed, [component], 'right');
715+
if (!transformed.length) return null;
716+
presence.p = transformed[0].p;
717+
718+
var subtype = component.t && subtypes[component.t];
719+
720+
var subtypeShouldTransform = subtype &&
721+
typeof subtype.transformPresence === 'function' &&
722+
pathMatches(component.p, presence.p);
723+
724+
if (subtypeShouldTransform) {
725+
presence.v = subtype.transformPresence(presence.v, component.o, isOwnOp);
726+
}
727+
}
728+
729+
return presence;
730+
};
731+
666732
require('./bootstrapTransform')(json, json.transformComponent, json.checkValidOp, json.append);
667733

668734
/**

lib/text0.js

+16
Original file line numberDiff line numberDiff line change
@@ -257,4 +257,20 @@ text.invert = function(op) {
257257
return op;
258258
};
259259

260+
text.transformPresence = function(range, op, isOwnOp) {
261+
if (!range) return null;
262+
if (!op) return range;
263+
264+
range = JSON.parse(JSON.stringify(range));
265+
var side = isOwnOp ? 'right' : 'left';
266+
267+
var start = text.transformCursor(range.index, op, side);
268+
var end = text.transformCursor(range.index + range.length, op, side);
269+
270+
range.index = start;
271+
range.length = end - start;
272+
273+
return range;
274+
};
275+
260276
require('./bootstrapTransform')(text, transformComponent, checkValidOp, append);

test/json0.coffee

+61
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,67 @@ genTests = (type) ->
438438
fuzzer type, require('./json0-generator'), 1000
439439
delete type._testStringSubtype
440440

441+
describe '#transformPresence', ->
442+
it 'moves presence touched directly with lm', ->
443+
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['x', 1], lm: 2}]
444+
445+
it 'does not move presence when touching other parts of the document', ->
446+
assert.deepEqual {p: ['x', 1], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['a'], oi: 'foo'}]
447+
448+
it 'moves presence indirectly moved by li', ->
449+
assert.deepEqual {p: ['x', 3], v: 0}, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 0], li: 'foo'}]
450+
451+
it 'moves presence indirectly moved by ld', ->
452+
assert.deepEqual {p: ['x', 1], v: 0}, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 0], ld: 'foo'}]
453+
454+
it 'moves deep presence moved by a higher li', ->
455+
assert.deepEqual {p: ['x', 3, 'y'], v: 0}, type.transformPresence {p: ['x', 2, 'y'], v: 0}, [{p: ['x', 1], li: 'foo'}]
456+
457+
it 'removes presence when an object is overwritten', ->
458+
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], oi: 'foo'}]
459+
460+
it 'removes presence when an object is deleted', ->
461+
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], od: 'foo'}]
462+
463+
it 'removes presence when a list item is deleted', ->
464+
assert.deepEqual null, type.transformPresence {p: ['x', 2], v: 0}, [{p: ['x', 2], ld: 'foo'}]
465+
466+
it 'moves presence as part of a series of op components', ->
467+
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 1], v: 0}, [{p: ['a'], oi: 'baz'}, {p: ['x', 1], lm: 2}]
468+
469+
it 'moves presence as part of a series of op components affecting the presence', ->
470+
presence = {p: ['x', 3], v: 0}
471+
op = [
472+
{p: ['x', 3], lm: 2},
473+
{p: ['x', 2], lm: 1},
474+
{p: ['x', 0], li: 'foo'},
475+
]
476+
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence presence, op
477+
478+
it 'returns null when no presence is provided', ->
479+
assert.deepEqual null, type.transformPresence undefined, [{p: ['x'], oi: 'foo'}]
480+
481+
it 'does nothing if no op is provided', ->
482+
assert.deepEqual {p: ['x', 2], v: 0}, type.transformPresence {p: ['x', 2], v:0}, undefined
483+
484+
it 'does not mutate the original presence', ->
485+
presence = {p: ['x', 2], v: 0}
486+
type.transformPresence presence, [{p: ['x', 2], lm: 1}]
487+
assert.deepEqual {p: ['x', 2], v: 0}, presence
488+
489+
it 'keeps extra metadata when tranforming', ->
490+
assert.deepEqual {p: ['x', 1], v: 0, meta: 'foo'}, type.transformPresence {p: ['x', 2], v: 0, meta: 'foo'}, [{p: ['x', 2], lm: 1}]
491+
492+
it 'returns null for an invalid presence', ->
493+
assert.deepEqual null, type.transformPresence {}, [{p: ['x', 1], lm: 2}]
494+
495+
describe 'text0', ->
496+
it 'transforms presence by an si', ->
497+
assert.deepEqual {p: ['x'], v: {index: 3, length: 1}}, type.transformPresence {p: ['x'], v: {index: 2, length: 1}}, [{p: ['x', 0], si: 'a'}]
498+
499+
it 'transforms presence by an sd', ->
500+
assert.deepEqual {p: ['x'], v: {index: 2, length: 0}}, type.transformPresence {p: ['x'], v: {index: 3, length: 1}}, [{p: ['x', 2], sd: 'abc'}]
501+
441502
describe 'json', ->
442503
describe 'native type', -> genTests nativetype
443504
#exports.webclient = genTests require('../helpers/webclient').types.json

test/text0.coffee

+36
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,42 @@ describe 'text0', ->
112112
t [{d:'abc', p:10}, {d:'xyz', p:6}]
113113
t [{d:'abc', p:10}, {d:'xyz', p:11}]
114114

115+
describe '#transformPresence', ->
116+
it 'transforms a zero-length range by an op before it', ->
117+
assert.deepEqual {index: 13, length: 0}, text0.transformPresence {index: 10, length: 0}, [{p: 0, i: 'foo'}]
118+
119+
it 'does not transform a zero-length range by an op after it', ->
120+
assert.deepEqual {index: 10, length: 0}, text0.transformPresence {index: 10, length: 0}, [{p: 20, i: 'foo'}]
121+
122+
it 'transforms a range with length by an op before it', ->
123+
assert.deepEqual {index: 13, length: 3}, text0.transformPresence {index: 10, length: 3}, [{p: 0, i: 'foo'}]
124+
125+
it 'transforms a range with length by an op that deletes part of it', ->
126+
assert.deepEqual {index: 9, length: 1}, text0.transformPresence {index: 10, length: 3}, [{p: 9, d: 'abc'}]
127+
128+
it 'transforms a range with length by an op that deletes the whole range', ->
129+
assert.deepEqual {index: 9, length: 0}, text0.transformPresence {index: 10, length: 3}, [{p: 9, d: 'abcde'}]
130+
131+
it 'keeps extra metadata when transforming', ->
132+
assert.deepEqual {index: 13, length: 0, meta: 'lorem ipsum'}, text0.transformPresence {index: 10, length: 0, meta: 'lorem ipsum'}, [{p: 0, i: 'foo'}]
133+
134+
it 'returns null when no presence is provided', ->
135+
assert.deepEqual null, text0.transformPresence undefined, [{p: 0, i: 'foo'}]
136+
137+
it 'advances the cursor if inserting at own index', ->
138+
assert.deepEqual {index: 13, length: 2}, text0.transformPresence {index: 10, length: 2}, [{p: 10, i: 'foo'}], true
139+
140+
it 'does not advance the cursor if not own op', ->
141+
assert.deepEqual {index: 10, length: 5}, text0.transformPresence {index: 10, length: 2}, [{p: 10, i: 'foo'}], false
142+
143+
it 'does nothing if no op is provided', ->
144+
assert.deepEqual {index: 10, length: 0}, text0.transformPresence {index: 10, length: 0}, undefined
145+
146+
it 'does not mutate the original range', ->
147+
range = {index: 10, length: 0}
148+
text0.transformPresence range, [{p: 0, i: 'foo'}]
149+
assert.deepEqual {index: 10, length: 0}, range
150+
115151

116152
describe 'randomizer', -> it 'passes', ->
117153
@timeout 4000

0 commit comments

Comments
 (0)