Skip to content

Commit b604b09

Browse files
committed
feat(editable): add cursor finding and positioning
1 parent 0c22eb0 commit b604b09

File tree

4 files changed

+163
-6
lines changed

4 files changed

+163
-6
lines changed

spec/api.spec.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,79 @@ describe('Editable', function () {
123123
cursor.triggerChange()
124124
})
125125
})
126+
127+
describe('findClosestCursorOffset:', function () {
128+
/*
129+
Cursor1: | (left: 130)
130+
Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt.
131+
Comp 2: Der Spieler blieb bei fünf Champions-League-Titeln stehen.
132+
Cursor 2: | (offset: 19 chars)
133+
*/
134+
it('finds the index in a text node', function () {
135+
$div.html('Der Spieler blieb bei fünf Champions-League-Titeln stehen.')
136+
const {wasFound, offset} = editable.findClosestCursorOffset({
137+
element: $div[0],
138+
origCoordinates: {top: 0, left: 130}
139+
})
140+
expect(wasFound).toEqual(true)
141+
expect(offset).toEqual(19)
142+
})
143+
144+
/*
145+
Cursor1: | (left: 130)
146+
Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt.
147+
Comp 2: <p>Der <em>Spieler</em> blieb bei fünf <span>Champions-League-Titeln</span> stehen.</p>
148+
Cursor 2: |
149+
*/
150+
it('finds the index in a nested html tag structure', function () {
151+
$div.html('<p>Der <em>Spieler</em> blieb bei fünf <span>Champions-League-Titeln</span> stehen.</p>')
152+
const {wasFound, offset} = editable.findClosestCursorOffset({
153+
element: $div[0],
154+
origCoordinates: {top: 0, left: 130}
155+
})
156+
console.log('offset', offset)
157+
expect(wasFound).toEqual(true)
158+
expect(offset).toEqual(19)
159+
})
160+
161+
it('returns not found for empty nodes', function () {
162+
$div.html('')
163+
const {wasFound} = editable.findClosestCursorOffset({
164+
element: $div[0],
165+
origCoordinates: {top: 0, left: 130}
166+
})
167+
expect(wasFound).toEqual(false)
168+
})
169+
170+
/*
171+
Cursor1: |
172+
Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt.
173+
Comp 2: Foo
174+
Cursor 2: not found
175+
*/
176+
it('returns not found for coordinates that are out of the text area', function () {
177+
$div.html('Foo')
178+
const {wasFound} = editable.findClosestCursorOffset({
179+
element: $div[0],
180+
origCoordinates: {top: 0, left: 130}
181+
})
182+
expect(wasFound).toEqual(false)
183+
})
184+
185+
/*
186+
Note: for performance reasons this algorithm will not work on huge paragraphs.
187+
In a news case such a huge paragraph is very rare though.
188+
*/
189+
it('stops after 30 binary search iterations', function () {
190+
$div.html(`
191+
Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung. Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung. Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung. Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung. Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung. Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. Mit ihm sollte Juventus – wie lange ersehnt – die Champions League gewinnen. Ronaldo, unter Druck geraten durch die Ermittlungen der spanischen Steuerfahnder und eine Vergewaltigungsanklage, hatte selbst Interesse an einem neuen, störungsfreien Betätigungsfeld und daran, mit einem dritten Klub die Champions-League-Trophäe zu erobern. Doch das ging nicht in Erfüllung.
192+
`)
193+
const {wasFound} = editable.findClosestCursorOffset({
194+
element: $div[0],
195+
origCoordinates: {top: 0, left: 530}
196+
})
197+
expect(wasFound).toEqual(false)
198+
})
199+
})
126200
})
127201
})

src/core.js

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import highlightSupport from './highlight-support'
1212
import Highlighting from './highlighting'
1313
import createDefaultEvents from './create-default-events'
1414
import browser from 'bowser'
15+
import {getTotalCharCount, textNodesUnder, getTextNodeAndRelativeOffset} from './util/element'
1516

1617
/**
1718
* The Core module provides the Editable class that defines the Editable.JS
@@ -221,11 +222,23 @@ export default class Editable {
221222
return rangy.createRange()
222223
}
223224

224-
createCursor (element, range) {
225+
createCursorWithRange ({element, range}) {
225226
const $host = $(element).closest(this.editableSelector)
226227
return new Cursor($host[0], range)
227228
}
228229

230+
createCursorAtCharacterOffset ({element, offset}) {
231+
const textNodes = textNodesUnder(element)
232+
const {node, relativeOffset} = getTextNodeAndRelativeOffset({textNodes, absOffset: offset})
233+
const newRange = this.createRangyRange()
234+
newRange.setStart(node, relativeOffset)
235+
newRange.setEnd(node, relativeOffset)
236+
newRange.collapse()
237+
const nextCursor = this.createCursorWithRange({element, range: newRange})
238+
nextCursor.setVisibleSelection()
239+
return nextCursor
240+
}
241+
229242
createCursorAtBeginning (element) {
230243
return this.createCursor(element, 'beginning')
231244
}
@@ -455,6 +468,58 @@ export default class Editable {
455468
this.dispatcher.unload()
456469
return this
457470
}
471+
472+
findClosestCursorOffset (
473+
{element, origCoordinates, requiredOnFirstLine = false, requiredOnLastLine = false}) {
474+
const totalCharCount = getTotalCharCount(element)
475+
if (totalCharCount === 0) return {wasFound: false}
476+
let start = Math.floor(totalCharCount / 2)
477+
let rightLimit = totalCharCount
478+
let leftLimit = 0
479+
const history = []
480+
let found = false
481+
const bluriness = 5
482+
const goLeft = () => {
483+
rightLimit = start
484+
start = Math.floor((start - leftLimit) / 2)
485+
}
486+
const goRight = () => {
487+
leftLimit = start
488+
start = start + Math.floor((rightLimit - start) / 2)
489+
}
490+
const convergedOnWrong = () => {
491+
const lastTwo = history.slice(-2)
492+
if (lastTwo.length === 2 && lastTwo[0] === lastTwo[1]) return true
493+
return false
494+
}
495+
// limit binary search depth to 30 partitions for performance
496+
for (let i = 0; i < 30; i++) {
497+
history.push(start)
498+
if (convergedOnWrong()) break
499+
const cursor = this.createCursorAtCharacterOffset({element, offset: start})
500+
// up / down axis
501+
if (requiredOnFirstLine && !cursor.isAtFirstLine()) {
502+
goLeft()
503+
continue
504+
} else if (requiredOnLastLine && !cursor.isAtLastLine()) {
505+
goRight()
506+
continue
507+
}
508+
const coordinates = cursor.getCoordinates()
509+
const distance = Math.abs(coordinates.left - origCoordinates.left)
510+
if (distance <= bluriness) {
511+
found = true
512+
break
513+
}
514+
// left / right axis
515+
if (coordinates.left < origCoordinates.left) {
516+
goRight()
517+
} else {
518+
goLeft()
519+
}
520+
}
521+
return {wasFound: found, offset: start}
522+
}
458523
}
459524

460525
// Expose modules and editable

src/dispatcher.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export default class Dispatcher {
168168
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
169169
const cursor = this.selectionWatcher.getSelection()
170170
if (!cursor || cursor.isSelection) return
171-
171+
172172
const totalCharCount = getTotalCharCount(element)
173173
if (direction === 'up' && (cursor.isAtFirstLine() || totalCharCount === 0)) {
174174
event.preventDefault()

src/util/element.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,37 @@
22
function textNodesUnder (node) {
33
let all = []
44
for (node = node.firstChild; node; node = node.nextSibling) {
5-
if (node.nodeType === 3) {
5+
if (node.nodeType === 3) {
66
all.push(node)
7-
} else {
7+
} else {
88
all = all.concat(textNodesUnder(node))
9-
}
9+
}
1010
}
1111
return all
1212
}
1313

14+
// NOTE: if there is only one text node, then just that node and
15+
// the abs offset are returned
16+
function getTextNodeAndRelativeOffset ({textNodes, absOffset}) {
17+
let cumulativeOffset = 0
18+
let relativeOffset = 0
19+
let targetNode
20+
for (let i = 0; i < textNodes.length; i++) {
21+
const node = textNodes[i]
22+
if (absOffset <= cumulativeOffset + node.textContent.length) {
23+
targetNode = node
24+
relativeOffset = absOffset - cumulativeOffset
25+
break
26+
}
27+
cumulativeOffset += node.textContent.length
28+
}
29+
return {node: targetNode, relativeOffset}
30+
}
31+
1432
function getTotalCharCount (element) {
1533
const textNodes = textNodesUnder(element)
1634
const reducer = (acc, node) => acc + node.textContent.length
1735
return textNodes.reduce(reducer, 0)
1836
}
1937

20-
module.exports = {getTotalCharCount}
38+
module.exports = {getTotalCharCount, textNodesUnder, getTextNodeAndRelativeOffset}

0 commit comments

Comments
 (0)