diff --git a/@types/automerge/index.d.ts b/@types/automerge/index.d.ts index 614ea53dd..33bce63f9 100644 --- a/@types/automerge/index.d.ts +++ b/@types/automerge/index.d.ts @@ -39,6 +39,7 @@ declare module 'automerge' { function getAllChanges(doc: Doc): Change[] function getChanges(olddoc: Doc, newdoc: Doc): Change[] function getConflicts(doc: Doc, key: keyof T): any + function getCursorIndex(doc: Doc, cursor: Cursor, findClosest?: boolean): number function getHistory>(doc: Doc): State[] function getMissingDeps(doc: Doc): Clock function getObjectById(doc: Doc, objectId: UUID): any @@ -77,6 +78,7 @@ declare module 'automerge' { class Text extends List { constructor(objectId?: UUID, elems?: string[], maxElem?: number) get(index: number): string + getCursorAt(index: number): Cursor getElemId(index: number): string toSpans(): (string | T)[] } @@ -96,6 +98,11 @@ declare module 'automerge' { value: number } + interface Cursor { + objectId: string + elemId: string + } + // Readonly variants type ReadonlyTable = ReadonlyArray & Table diff --git a/backend/index.js b/backend/index.js index 2633a99bb..d822e9377 100644 --- a/backend/index.js +++ b/backend/index.js @@ -248,6 +248,14 @@ function merge(local, remote) { return applyChanges(local, changes) } +function getListIndex(state, objectId, elemId) { + return OpSet.getListIndex(state.get('opSet'), objectId, elemId) +} + +function getPrecedingListIndex(state, objectId, elemId) { + return OpSet.getPrecedingListIndex(state.get('opSet'), objectId, elemId) +} + /** * Undoes the last change by the local user in the node state `state`. The * `request` object contains all parts of the change except the operations; @@ -317,5 +325,6 @@ function redo(state, request) { module.exports = { init, applyChanges, applyLocalChange, getPatch, - getChanges, getChangesForActor, getMissingChanges, getMissingDeps, merge + getChanges, getChangesForActor, getMissingChanges, getMissingDeps, merge, + getListIndex, getPrecedingListIndex } diff --git a/backend/op_set.js b/backend/op_set.js index 552b142c7..e6df2b2f3 100644 --- a/backend/op_set.js +++ b/backend/op_set.js @@ -49,7 +49,7 @@ function getPath(opSet, objectId) { const objType = opSet.getIn(['byObject', objectId, '_init', 'action']) if (objType === 'makeList' || objType === 'makeText') { - const index = opSet.getIn(['byObject', objectId, '_elemIds']).indexOf(ref.get('key')) + const index = getListIndex(opSet, objectId, ref.get('key')) if (index < 0) return null path.unshift(index) } else { @@ -141,10 +141,32 @@ function patchList(opSet, objectId, index, elemId, action, ops) { return [opSet, [edit]] } +// Finds the index of the list element with a given ID. +// Returns -1 if the element does not exist or has been deleted. +function getListIndex(opSet, objectId, elemId) { + return opSet.getIn(['byObject', objectId, '_elemIds']).indexOf(elemId) +} + +// Find the index of the closest visible list element that precedes the given element ID. +// Returns -1 if there is no such element. +function getPrecedingListIndex(opSet, objectId, elemId) { + const elemIds = opSet.getIn(['byObject', objectId, '_elemIds']) + + let prevId = elemId, index + while (true) { + index = -1 + prevId = getPrevious(opSet, objectId, prevId) + if (!prevId) break + index = elemIds.indexOf(prevId) + if (index >= 0) break + } + + return index +} + function updateListElement(opSet, objectId, elemId) { const ops = getFieldOps(opSet, objectId, elemId) - const elemIds = opSet.getIn(['byObject', objectId, '_elemIds']) - let index = elemIds.indexOf(elemId) + let index = getListIndex(opSet, objectId, elemId) if (index >= 0) { if (ops.isEmpty()) { @@ -152,20 +174,9 @@ function updateListElement(opSet, objectId, elemId) { } else { return patchList(opSet, objectId, index, elemId, 'set', ops) } - } else { if (ops.isEmpty()) return [opSet, []] // deleting a non-existent element = no-op - - // find the index of the closest preceding list element - let prevId = elemId - while (true) { - index = -1 - prevId = getPrevious(opSet, objectId, prevId) - if (!prevId) break - index = elemIds.indexOf(prevId) - if (index >= 0) break - } - + index = getPrecedingListIndex(opSet, objectId, elemId) return patchList(opSet, objectId, index + 1, elemId, 'insert', ops) } } @@ -569,5 +580,5 @@ function listIterator(opSet, listId, context) { module.exports = { init, addChange, getMissingChanges, getChangesForActor, getMissingDeps, getObjectFields, getObjectField, getObjectConflicts, getFieldOps, - listElemByIndex, listLength, listIterator, ROOT_ID + listElemByIndex, listLength, listIterator, getListIndex, getPrecedingListIndex, ROOT_ID } diff --git a/frontend/text.js b/frontend/text.js index 6804d385c..1f377e75f 100644 --- a/frontend/text.js +++ b/frontend/text.js @@ -27,6 +27,18 @@ class Text { return this.elems[index].elemId } + /** + * Returns a cursor that points to a specific point in the text. + * For now, represented by a plain object. + */ + getCursorAt (index) { + return { + // todo: are there any points in the lifecycle where the Text object doesn't have an ID? + objectId: this[OBJECT_ID], + elemId: this.getElemId(index) + } + } + /** * Iterates over the text elements character by character, including any * inline objects. diff --git a/src/automerge.js b/src/automerge.js index e9a3ec6d3..58e42d31f 100644 --- a/src/automerge.js +++ b/src/automerge.js @@ -133,10 +133,32 @@ function getHistory(doc) { }).toArray() } +/** + * Returns the updated integer index of a cursor pointing into a text object. + * + * If the character has been deleted: + * - By default, returns -1 + * - If findClosest parameter is true, returns closest preceding index. + */ +function getCursorIndex(doc, cursor, findClosest = false) { + if (cursor.objectId === undefined || cursor.elemId === undefined) { + throw new TypeError('Invalid cursor object') + } + + const backend = Frontend.getBackendState(doc) + let index = Backend.getListIndex(backend, cursor.objectId, cursor.elemId) + + if (index === -1 && findClosest) { + index = Backend.getPrecedingListIndex(backend, cursor.objectId, cursor.elemId) + } + return index +} + + module.exports = { init, from, change, emptyChange, undo, redo, load, save, merge, diff, getChanges, getAllChanges, applyChanges, getMissingDeps, - equals, getHistory, uuid, + equals, getHistory, getCursorIndex, uuid, Frontend, Backend, DocSet: require('./doc_set'), WatchableDoc: require('./watchable_doc'), diff --git a/test/text_test.js b/test/text_test.js index dd04d0002..991efc103 100644 --- a/test/text_test.js +++ b/test/text_test.js @@ -365,6 +365,52 @@ describe('Automerge.Text', () => { }) }) + describe('cursors', () => { + let s1 + beforeEach(() => { + s1 = Automerge.change(Automerge.init(), doc => { + doc.text = new Automerge.Text('hello world') + doc.cursor = doc.text.getCursorAt(2) + }) + }) + + it('can retrieve the initial index on the cursor', () => { + assert.deepStrictEqual(Automerge.getCursorIndex(s1, s1.cursor), 2) + }) + + it('updates the cursor index when text is updated', () => { + s1 = Automerge.change(s1, doc => { + doc.text.insertAt(0, 'a', 'b', 'c') + }) + + assert.deepStrictEqual(Automerge.getCursorIndex(s1, s1.cursor), 5) + }) + + it('throws an error if a non-cursor is passed in', () => { + s1 = Automerge.change(s1, doc => { + doc.value = "random string" + }) + + assert.throws(() => Automerge.getCursorIndex(s1, s1.value), /Invalid cursor object/) + }) + + it('returns -1 by default if character was deleted', () => { + s1 = Automerge.change(s1, doc => { + doc.text.deleteAt(2) + }) + + assert.deepStrictEqual(Automerge.getCursorIndex(s1, s1.cursor), -1) + }) + + it('returns closest index if character was deleted and findClosest is set to true', () => { + s1 = Automerge.change(s1, doc => { + doc.text.deleteAt(2) + }) + + assert.deepStrictEqual(Automerge.getCursorIndex(s1, s1.cursor, true), 1) + }) + }) + describe('non-textual control characters', () => { let s1 beforeEach(() => { diff --git a/test/typescript_test.ts b/test/typescript_test.ts index 86e77c4cd..48e24d499 100644 --- a/test/typescript_test.ts +++ b/test/typescript_test.ts @@ -431,6 +431,19 @@ describe('TypeScript support', () => { it('supports `concat`', () => assert.strictEqual(doc.text.concat(['j']).length, 10)) it('supports `includes`', () => assert.strictEqual(doc.text.includes('q'), false)) }) + + describe('cursors API', () => { + beforeEach(() => { + doc = Automerge.change(doc, doc => doc.text.insertAt(0, 'a', 'b')) + }) + + it('should convert between cursor and index', () => { + const cursor = doc.text.getCursorAt(1) + assert.strictEqual(cursor.objectId, Automerge.getObjectId(doc.text)) + assert.strictEqual(cursor.elemId, `${Automerge.getActorId(doc)}:2`) + assert.strictEqual(Automerge.getCursorIndex(doc, cursor), 1) + }) + }) }) describe('Automerge.Table', () => {