From 458d825fcb084be82ced562beb6769376715c9ce Mon Sep 17 00:00:00 2001 From: Alec Gibson <12036746+alecgibson@users.noreply.github.com> Date: Wed, 22 Dec 2021 11:12:46 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20presence=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]: https://github.com/share/sharedb/pull/322 [2]: https://github.com/ottypes/json0/blob/90a3ae26364c4fa3b19b6df34dad46707a704421/lib/text0.js#L147 [3]: https://github.com/ottypes/rich-text/pull/32 --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++ lib/json0.js | 55 ++++++++++++++++++++++++++++++++++++++++++++ lib/text0.js | 16 +++++++++++++ test/json0.coffee | 58 +++++++++++++++++++++++++++++++++++++++++++++++ test/text0.coffee | 36 +++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+) diff --git a/README.md b/README.md index cc1de54..b25cb19 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/json0.js b/lib/json0.js index 9f538ee..294c67f 100644 --- a/lib/json0.js +++ b/lib/json0.js @@ -663,6 +663,61 @@ 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; + } + + if ('ld' in component) { + 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); /** diff --git a/lib/text0.js b/lib/text0.js index 238d448..2008ce1 100644 --- a/lib/text0.js +++ b/lib/text0.js @@ -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); diff --git a/test/json0.coffee b/test/json0.coffee index e2ee6df..fcdab4a 100644 --- a/test/json0.coffee +++ b/test/json0.coffee @@ -438,6 +438,64 @@ 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 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 diff --git a/test/text0.coffee b/test/text0.coffee index 15592dd..8364222 100644 --- a/test/text0.coffee +++ b/test/text0.coffee @@ -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