Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions @types/automerge/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ declare module 'automerge' {
function getAllChanges<T>(doc: Doc<T>): Uint8Array[]
function getChanges<T>(olddoc: Doc<T>, newdoc: Doc<T>): Uint8Array[]
function getConflicts<T>(doc: Doc<T>, key: keyof T): any
function getCursorIndex<T>(doc: Doc<T>, cursor: Cursor, findClosest?: boolean): number
function getHistory<D, T = Proxy<D>>(doc: Doc<T>): State<T>[]
function getMissingDeps<T>(doc: Doc<T>): Hash[]
function getObjectById<T>(doc: Doc<T>, objectId: OpId): any
Expand Down Expand Up @@ -74,6 +75,8 @@ declare module 'automerge' {
class Text extends List<string> {
constructor(text?: string | string[])
get(index: number): string
getCursorAt(index: number): Cursor
getElemId(index: number): string
toSpans<T>(): (string | T)[]
}

Expand All @@ -92,6 +95,11 @@ declare module 'automerge' {
value: number
}

interface Cursor {
objectId: string
elemId: string
}

// Readonly variants

type ReadonlyTable<T> = ReadonlyArray<T> & Table<T>
Expand Down
12 changes: 11 additions & 1 deletion backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
40 changes: 26 additions & 14 deletions backend/op_set.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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})
}
Expand Down Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions frontend/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion src/automerge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
46 changes: 46 additions & 0 deletions test/text_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
35 changes: 35 additions & 0 deletions test/typescript_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextDocWithCursors>({
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', () => {
Expand Down