diff --git a/@types/automerge/index.d.ts b/@types/automerge/index.d.ts index e279ce3a2..59b5ec8f0 100644 --- a/@types/automerge/index.d.ts +++ b/@types/automerge/index.d.ts @@ -42,6 +42,7 @@ declare module 'automerge' { function getAllChanges(doc: Doc): Uint8Array[] function getChanges(olddoc: Doc, newdoc: Doc): Uint8Array[] 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): Hash[] function getObjectById(doc: Doc, objectId: OpId): any @@ -74,6 +75,8 @@ declare module 'automerge' { class Text extends List { constructor(text?: string | string[]) get(index: number): string + getCursorAt(index: number): Cursor + getElemId(index: number): string toSpans(): (string | T)[] } @@ -92,6 +95,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 7d88d83c5..ca34b23f0 100644 --- a/backend/index.js +++ b/backend/index.js @@ -267,7 +267,17 @@ function getMissingDeps(backend) { } } +function getListIndex(backend, objectId, elemId) { + const opSet = backendState(backend).get('opSet') + return OpSet.getListIndex(opSet, objectId, elemId) +} + +function getPrecedingListIndex(backend, objectId, elemId) { + const opSet = backendState(backend).get('opSet') + return OpSet.getPrecedingListIndex(opSet, objectId, elemId) +} + module.exports = { init, clone, free, applyChanges, applyLocalChange, save, load, loadChanges, getPatch, - getHeads, getChanges, getMissingDeps + getHeads, getChanges, getMissingDeps, getListIndex, getPrecedingListIndex } diff --git a/backend/op_set.js b/backend/op_set.js index 320078f47..9567b0718 100644 --- a/backend/op_set.js +++ b/backend/op_set.js @@ -71,6 +71,29 @@ function applyInsert(opSet, op) { .setIn(['byObject', objectId, '_insertion', opId], op) } +// 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, patch) { const ops = getFieldOps(opSet, objectId, elemId) let elemIds = opSet.getIn(['byObject', objectId, '_elemIds']) @@ -87,21 +110,9 @@ function updateListElement(opSet, objectId, elemId, patch) { } else { elemIds = elemIds.setValue(elemId, ops.first().get('value')) } - } 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 += 1 + index = getPrecedingListIndex(opSet, objectId, elemId) + 1 elemIds = elemIds.insertIndex(index, elemId, ops.first().get('value')) if (patch) patch.edits.push({action: 'insert', index, elemId}) } @@ -631,5 +642,6 @@ function constructObject(opSet, objectId) { module.exports = { init, addChange, addLocalChange, getHeads, getMissingChanges, getMissingDeps, - constructObject, getFieldOps, getOperationKey, finalizePatch + constructObject, getFieldOps, getOperationKey, finalizePatch, + getListIndex, getPrecedingListIndex } diff --git a/frontend/text.js b/frontend/text.js index da2df269c..02dd407f6 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 49f17e1eb..90d5e5698 100644 --- a/src/automerge.js +++ b/src/automerge.js @@ -123,10 +123,33 @@ function setDefaultBackend(newBackend) { backend = newBackend } +/** + * Finds the latest integer index of a cursor object. + * If the character was deleted, returns -1. + * + * todos: + * - consider returning the closest character if deleted + * - consider optimizing the linear search + */ +function getCursorIndex(doc, cursor, findClosest = false) { + if (cursor.objectId === undefined || cursor.elemId === undefined) { + throw new TypeError('Invalid cursor object') + } + + const state = Frontend.getBackendState(doc) + let index = backend.getListIndex(state, cursor.objectId, cursor.elemId) + + if (index === -1 && findClosest) { + index = backend.getPrecedingListIndex(state, cursor.objectId, cursor.elemId) + } + return index +} + + module.exports = { init, from, change, emptyChange, clone, free, load, save, merge, getChanges, getAllChanges, applyChanges, getMissingDeps, - encodeChange, decodeChange, equals, getHistory, uuid, + encodeChange, decodeChange, equals, getHistory, getCursorIndex, uuid, Frontend, setDefaultBackend, get Backend() { return backend } } diff --git a/test/text_test.js b/test/text_test.js index a737f0a20..61125fb6b 100644 --- a/test/text_test.js +++ b/test/text_test.js @@ -363,6 +363,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 7f3afb22a..d26fe5bad 100644 --- a/test/typescript_test.ts +++ b/test/typescript_test.ts @@ -379,6 +379,41 @@ describe('TypeScript support', () => { assert.deepStrictEqual(elemIds, [`2@${Automerge.getActorId(doc)}`, `3@${Automerge.getActorId(doc)}`]) }) }) + + describe('cursors API', () => { + interface CursorPerUser { + [userName: string]: Automerge.Cursor + } + interface TextDocWithCursors { + text: Automerge.Text + cursors: CursorPerUser + } + + 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, `3@${Automerge.getActorId(doc)}`) + assert.strictEqual(Automerge.getCursorIndex(doc, cursor), 1) + }) + + it('should allow cursors to be stored in a document', () => { + let doc = Automerge.from({ + text: new Automerge.Text(), + cursors: {} + }) + doc = Automerge.change(doc, doc => { + doc.text.insertAt(0, 'a', 'b', 'c') + doc.cursors['user1'] = doc.text.getCursorAt(1) + }) + assert.strictEqual(doc.cursors.user1.objectId, Automerge.getObjectId(doc.text)) + assert.strictEqual(doc.cursors.user1.elemId, `4@${Automerge.getActorId(doc)}`) + assert.strictEqual(Automerge.getCursorIndex(doc, doc.cursors.user1, true), 1) + }) + }) }) describe('Automerge.Table', () => {