diff --git a/spec/highlighting.spec.js b/spec/highlighting.spec.js
index ca944489..1e8af791 100644
--- a/spec/highlighting.spec.js
+++ b/spec/highlighting.spec.js
@@ -1,28 +1,58 @@
import $ from 'jquery'
-
+import rangy from 'rangy'
import Editable from '../src/core'
import Highlighting from '../src/highlighting'
+import highlightSupport from '../src/highlight-support'
import WordHighlighter from '../src/plugins/highlighting/text-highlighting'
-describe('Highlighting', function () {
- // Specs
+function setupHighlightEnv (context, text) {
+ context.text = text
+ context.$div = $('
' + context.text + '
').appendTo(document.body)
+ context.editable = new Editable()
+ context.editable.add(context.$div)
+ context.highlightRange = (highlightId, start, end) => {
+ return highlightSupport.highlightRange(
+ context.$div[0],
+ highlightId,
+ start, end
+ )
+ }
+
+ context.extract = () => {
+ return context.editable.getHighlightPositions({editableHost: context.$div[0]})
+ }
+
+ context.getHtml = () => {
+ return context.$div[0].innerHTML
+ }
+
+ context.formatHtml = string => {
+ return $('' + string.replace(/\n/gm, '') + '
')[0].innerHTML
+ }
+}
+
+function teardownHighlightEnv (context) {
+ context.$div.remove()
+ context.editable.off()
+ context.editable = undefined
+ context.highlightRange = undefined
+ context.assertUniqueSpan = undefined
+}
+describe('Highlighting', function () {
beforeEach(() => {
this.editable = new Editable()
})
describe('new Highlighting()', () => {
-
it('creates an instance with a reference to editable', () => {
const highlighting = new Highlighting(this.editable, {})
expect(highlighting.editable).toEqual(this.editable)
})
-
})
describe('WordHighlighter', () => {
-
beforeEach(() => {
const markerNode = $('')[0]
this.highlighter = new WordHighlighter(markerNode)
@@ -54,4 +84,461 @@ describe('Highlighting', function () {
})
})
+ describe('highlightSupport', () => {
+ beforeEach(() => {
+ setupHighlightEnv(this, 'People Make The
World Go Round')
+ })
+
+ afterEach(() => {
+ teardownHighlightEnv(this)
+ })
+
+ it('can handle a single highlight', () => {
+ const startIndex = this.highlightRange('myId', 3, 7)
+ const expectedRanges = {
+ myId: {
+ text: 'ple ',
+ start: 3,
+ end: 7
+ }
+ }
+ const expectedHtml = this.formatHtml(`Peo
+
+Make The
World Go Round`)
+
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+ expect(startIndex).toEqual(3)
+ })
+
+ it('can handle adjaccent highlights', () => {
+ this.highlightRange('first', 0, 1)
+ this.highlightRange('second', 1, 2)
+ this.highlightRange('third', 2, 3)
+ this.highlightRange('fourth', 3, 4)
+
+ const expectedRanges = {
+ first: {
+ text: 'P',
+ start: 0,
+ end: 1
+ },
+ second: {
+ text: 'e',
+ start: 1,
+ end: 2
+ },
+ third: {
+ text: 'o',
+ start: 2,
+ end: 3
+ },
+ fourth: {
+ text: 'p',
+ start: 3,
+ end: 4
+ }
+ }
+ const expectedHtml = this.formatHtml(`
+
+
+
+le Make The
World Go Round`)
+
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+
+ })
+
+ it('can handle nested highlights', () => {
+ this.highlightRange('first', 0, 1)
+ this.highlightRange('second', 1, 2)
+ this.highlightRange('third', 2, 6)
+ this.highlightRange('fourth', 0, 6)
+ const expectedRanges = {
+ first: {
+ text: 'P',
+ start: 0,
+ end: 1
+ },
+ second: {
+ text: 'e',
+ start: 1,
+ end: 2
+ },
+ third: {
+ text: 'ople',
+ start: 2,
+ end: 6
+ },
+ fourth: {
+ text: 'People',
+ start: 0,
+ end: 6
+ }
+ }
+ const expectedHtml = this.formatHtml(`
+ Make The
World Go Round`)
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+ })
+
+ it('can handle intersecting highlights', () => {
+ this.highlightRange('first', 0, 3)
+ this.highlightRange('second', 2, 7)
+ this.highlightRange('third', 4, 6)
+ const expectedRanges = {
+ first: {
+ text: 'Peo',
+ start: 0,
+ end: 3
+ },
+ second: {
+ text: 'ople ',
+ start: 2,
+ end: 7
+ },
+ third: {
+ text: 'le',
+ start: 4,
+ end: 6
+ }
+ }
+ const expectedHtml = this.formatHtml(`
+Make The
World Go Round`)
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+ })
+
+ it('can handle highlights containing break tags', () => {
+ this.highlightRange('first', 11, 22)
+ const expectedRanges = {
+ first: {
+ text: ' The \nWorld',
+ start: 11,
+ end: 22
+ }
+ }
+ const expectedHtml = this.formatHtml(`People Make
+
+ Go Round`)
+
+ expect(this.extract()).toEqual(expectedRanges)
+ expect(this.getHtml()).toEqual(expectedHtml)
+
+ })
+
+ it('can handle identical ranges', () => {
+ this.highlightRange('first', 11, 22)
+ this.highlightRange('second', 11, 22)
+ const expectedRanges = {
+ first: {
+ text: ' The \nWorld',
+ start: 11,
+ end: 22
+ },
+ second: {
+ text: ' The \nWorld',
+ start: 11,
+ end: 22
+ }
+ }
+ const expectedHtml = this.formatHtml(`People Make
+
+ Go Round`)
+
+
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+
+ })
+
+ it('will update any existing range found under `highlightId` aka upsert', () => {
+ this.highlightRange('first', 11, 22)
+ this.highlightRange('first', 8, 9)
+ const expectedRanges = {
+ first: {
+ text: 'a',
+ start: 8,
+ end: 9
+ }
+ }
+ const expectedHtml = this.formatHtml(`People M
+
+ke The
World Go Round`)
+
+ console.log(expectedHtml)
+
+ expect(this.extract()).toEqual(expectedRanges)
+ expect(this.getHtml()).toEqual(expectedHtml)
+ })
+
+ it('can handle all cases combined and creates consistent output', () => {
+ this.highlightRange('first', 4, 8)
+ this.highlightRange('second', 2, 10)
+ this.highlightRange('third', 4, 5)
+ this.highlightRange('first', 0, 24)
+ this.highlightRange('fourth', 20, 31)
+ this.highlightRange('fifth', 15, 16)
+ this.highlightRange('sixth', 15, 16)
+
+ const expectedRanges = {
+ first: {
+ text: 'People Make The \nWorld G',
+ start: 0,
+ end: 24
+ },
+ second: {
+ text: 'ople Mak',
+ start: 2,
+ end: 10
+ },
+ third: {
+ text: 'l',
+ start: 4,
+ end: 5
+ },
+ fourth: {
+ text: 'ld Go Round',
+ start: 20,
+ end: 31
+ },
+ fifth: {
+ text: ' ',
+ start: 15,
+ end: 16
+ },
+ sixth: {
+ text: ' ',
+ start: 15,
+ end: 16
+ }
+ }
+ const expectedHtml = this.formatHtml(`
+`)
+
+ const extractedHtml = this.getHtml()
+ const extractedRanges = this.extract()
+
+ expect(extractedRanges).toEqual(expectedRanges)
+ expect(extractedHtml).toEqual(expectedHtml)
+
+
+ const content = this.editable.getContent(this.$div[0])
+ this.$div.html(content)
+ expect(content).toEqual(this.text)
+ const ids = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
+ ids.forEach(highlightId => {
+ this.highlightRange(
+ highlightId,
+ extractedRanges[highlightId].start,
+ extractedRanges[highlightId].end
+ )
+ })
+
+ expect(this.extract()).toEqual(expectedRanges)
+ expect(this.getHtml()).toEqual(expectedHtml)
+ })
+
+ it('skips and warns if an invalid range object was passed', () => {
+ this.editable.highlight({
+ editableHost: this.$div[0],
+ highlightId: 'myId',
+ textRange: { foo: 3, bar: 7 }
+ })
+ const highlightSpan = this.$div.find('[data-word-id="myId"]')
+ expect(highlightSpan.length).toEqual(0)
+ })
+
+ it('skips if the range exceeds the content length', () => {
+ const result = this.editable.highlight({
+ editableHost: this.$div[0],
+ highlightId: 'myId',
+ textRange: { foo: 3, bar: 32 }
+ })
+ const highlightSpan = this.$div.find('[data-word-id="myId"]')
+ expect(highlightSpan.length).toEqual(0)
+ expect(result).toEqual(-1)
+ })
+
+ it('skips and warns if the range object represents a cursor', () => {
+ this.editable.highlight({
+ editableHost: this.$div[0],
+ highlightId: 'myId',
+ textRange: { start: 3, end: 3 }
+ })
+
+ const highlightSpan = this.$div.find('[data-word-id="myId"]')
+ expect(highlightSpan.length).toEqual(0)
+ })
+ })
+
+ describe('highlight support with special characters', () => {
+ it('treats special characters as expected', () => {
+ // actual / expected length / expected text
+ const characters = [
+ ['π', 2, 'π'],
+ [' ', 1, 'Β '], // eslint-disable-line
+ ['Β ', 1, 'Β '], // eslint-disable-line
+ ['β', 1, 'β'], // eslint-disable-line,
+ ['β', 1, 'β'], // eslint-disable-line,
+ ['\r', 0],
+ ['\n', 0],
+ ['
', 0]
+ ]
+
+ characters.forEach(([char, expectedLength, expectedText]) => {
+ setupHighlightEnv(this, char)
+ const range = rangy.createRange()
+ const node = this.$div[0]
+ range.selectNode(node.firstChild)
+ const { start, end } = range.toCharacterRange(this.$div[0])
+ this.highlightRange('char', start, end)
+ if (expectedLength === 0) {
+ expect(this.extract()).toEqual(undefined)
+ } else {
+ expect(this.extract()).toEqual({
+ char: {
+ start: 0,
+ end: expectedLength,
+ text: expectedText
+ }
+ })
+ }
+ teardownHighlightEnv(this)
+ })
+ })
+ })
+
+ describe('highlightSupport on formatted text', () => {
+ it('can handle highlights surrounding tags', () => {
+ setupHighlightEnv(this, 'abcd')
+ this.highlightRange('first', 1, 3)
+ const extract = this.extract()
+
+ expect(extract.first.text).toEqual('bc')
+
+ const content = this.getHtml()
+ expect(content).toEqual('ad')
+ })
+
+ it('can handle highlights intersecting tags', () => {
+ setupHighlightEnv(this, 'abcd')
+ this.highlightRange('first', 0, 2)
+ const extract = this.extract()
+
+ expect(extract.first.text).toEqual('ab')
+
+ const content = this.getHtml()
+ expect(content).toEqual('cd')
+ })
+ })
+
+ describe('highlightSupport with special characters', () => {
+ beforeEach(() => {
+ setupHighlightEnv(this, 'π Make The \r\n π Go \nπ')
+ })
+
+ afterEach(() => {
+ teardownHighlightEnv(this)
+ })
+
+ it('maps selection offsets to ranges containing multibyte symbols consistently', () => {
+ const range = rangy.createRange()
+ const node = this.$div[0]
+ range.setStart(node.firstChild, 0)
+ range.setEnd(node.firstChild, 2)
+ const {start, end} = range.toCharacterRange(this.$div[0])
+
+ this.highlightRange('first', start, end)
+ const expectedRanges = {
+ first: {
+ text: 'π',
+ start: 0,
+ end: 2
+ }
+ }
+
+ const expectedHtml = ' Make The \n π Go \nπ'
+
+ expect(this.extract()).toEqual(expectedRanges)
+ expect(this.getHtml()).toEqual(expectedHtml)
+ })
+
+ it('treats non-breakable spaces consistently', () => {
+ this.highlightRange('first', 2, 9)
+ const expectedRanges = {
+ first: {
+ text: ' MakeΒ T',
+ start: 2,
+ end: 9
+ }
+ }
+ const expectedHtml = 'πhe \n π Go \nπ'
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+
+
+ })
+
+ it('treats \\n\\r spaces consistently', () => {
+ this.highlightRange('first', 8, 15)
+ const expectedRanges = {
+ first: {
+ text: 'The π ',
+ start: 8,
+ end: 15
+ }
+ }
+
+ const expectedHtml = 'π Make Go \nπ'
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+
+ })
+
+ it('treats \\n spaces consistently', () => {
+ this.highlightRange('first', 15, 20)
+ const expectedRanges = {
+ first: {
+ text: 'Go π',
+ start: 15,
+ end: 20
+ }
+ }
+ const expectedHtml = 'π Make The \n π '
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+ })
+
+ it('extracts a readable text', () => {
+ this.highlightRange('first', 0, 20)
+ const expectedRanges = {
+ first: {
+ text: 'π MakeΒ The π Go π',
+ start: 0,
+ end: 20
+ }
+ }
+ const expectedHtml = ''
+ expect(this.getHtml()).toEqual(expectedHtml)
+ expect(this.extract()).toEqual(expectedRanges)
+ })
+ })
})
diff --git a/spec/spellcheck.spec.js b/spec/spellcheck.spec.js
index e24646e9..8a496d4d 100644
--- a/spec/spellcheck.spec.js
+++ b/spec/spellcheck.spec.js
@@ -122,6 +122,22 @@ describe('Spellcheck:', function () {
expect($(this.p).find('.misspelled-word').length).toEqual(0)
})
+ it('does not remove the highlights config.removeOnCorrection is set to false', () => {
+ this.highlighting.config.removeOnCorrection = false
+ sinon.stub(this.editable, 'getSelection').callsFake(() => createCursor(this.p, this.highlight, 0))
+
+ this.highlighting.onChange(this.p)
+ expect($(this.p).find('.misspelled-word').length).toEqual(1)
+ })
+
+ it('does not remove the highlights if cursor is within a match of highlight type != spellcheck', () => {
+ $(this.p).find('.misspelled-word').attr('data-highlight', 'comment')
+ sinon.stub(this.editable, 'getSelection').callsFake(() => createCursor(this.p, this.highlight, 0))
+
+ this.highlighting.removeHighlightsAtCursor(this.p)
+ expect($(this.p).find('.misspelled-word').length).toEqual(1)
+ })
+
it('does not remove the highlights if cursor is outside a match', () => {
sinon.stub(this.editable, 'getSelection').callsFake(() => createCursor(this.p, this.p.firstChild, 0))
diff --git a/src/core.js b/src/core.js
index f0f27986..c2864007 100644
--- a/src/core.js
+++ b/src/core.js
@@ -1,6 +1,5 @@
import $ from 'jquery'
import rangy from 'rangy'
-
import * as config from './config'
import error from './util/error'
import * as parser from './parser'
@@ -332,19 +331,62 @@ const Editable = module.exports = class Editable {
}
}
- // Highlight text within an editable.
- //
- // The first occurrence of the provided 'text' will be highlighted.
- //
- // The markup used for the highlighting will be removed from
- // the final content.
- //
- // @param editableHost {DomNode}
- // @param text {String}
- // @param highlightId {String} Optional
- // Added to the highlight markups in the property `data-word-id`
- highlight ({editableHost, text, highlightId}) {
- return highlightSupport.highlightText(editableHost, text, highlightId)
+ /**
+ * Highlight text within an editable.
+ *
+ * By default highlights all occurences of `text`.
+ * Pass it a `textRange` object to highlight a
+ * specific text portion.
+ *
+ * The markup used for the highlighting will be removed
+ * from the final content.
+ *
+ *
+ * @param {Object} options
+ * @param {DOMNode} options.editableHost
+ * @param {String} options.text
+ * @param {String} options.highlightId Added to the highlight markups in the property `data-word-id`
+ * @param {Object} [options.textRange] An optional range which gets used to set the markers.
+ * @param {Number} options.textRange.start
+ * @param {Number} options.textRange.end
+ * @return {Number} The text-based start offset of the newly applied highlight or `-1` if the range was considered invalid.
+ */
+ highlight ({editableHost, text, highlightId, textRange}) {
+ if (!textRange) {
+ return highlightSupport.highlightText(editableHost, text, highlightId)
+ }
+ if (typeof textRange.start !== 'number' || typeof textRange.end !== 'number') {
+ error(
+ 'Error in Editable.highlight: You passed a textRange object with invalid keys. Expected shape: { start: Number, end: Number }'
+ )
+ return -1
+ }
+ if (textRange.start === textRange.end) {
+ error(
+ 'Error in Editable.highlight: You passed a textRange object with equal start and end offsets, which is considered a cursor and therefore unfit to create a highlight.'
+ )
+ return -1
+ }
+ return highlightSupport.highlightRange(editableHost, highlightId, textRange.start, textRange.end)
+ }
+
+ /**
+ * Extracts positions of all DOMNodes that match `[data-word-id]`.
+ *
+ * Returns an object where the keys represent a highlight id and the value
+ * a text range object of shape:
+ * ```
+ * { start: number, end: number, text: string}
+ * ```
+ *
+ * @param {Object} options
+ * @param {DOMNode} options.editableHos
+ * @return {Object} ranges
+ */
+ getHighlightPositions ({ editableHost }) {
+ return highlightSupport.extractHighlightedRanges(
+ editableHost
+ )
}
removeHighlight ({editableHost, highlightId}) {
diff --git a/src/highlight-support.js b/src/highlight-support.js
index 97b4cc65..30212a52 100644
--- a/src/highlight-support.js
+++ b/src/highlight-support.js
@@ -1,9 +1,16 @@
import $ from 'jquery'
-
+import rangy from 'rangy'
import * as content from './content'
import highlightText from './highlight-text'
import TextHighlighting from './plugins/highlighting/text-highlighting'
+function isInHost (el, host) {
+ if (!el.closest) {
+ el = el.parentNode
+ }
+ return el.closest('[data-editable]:not([data-word-id])') === host
+}
+
const highlightSupport = {
highlightText (editableHost, text, highlightId) {
@@ -24,6 +31,30 @@ const highlightSupport = {
}
},
+ highlightRange (editableHost, highlightId, startIndex, endIndex) {
+ if (this.hasHighlight(editableHost, highlightId)) {
+ this.removeHighlight(editableHost, highlightId)
+ }
+ const range = rangy.createRange()
+ range.selectCharacters(editableHost, startIndex, endIndex)
+
+ if (!isInHost(range.commonAncestorContainer, editableHost)) {
+ return -1
+ }
+
+ const marker = highlightSupport.createMarkerNode(
+ '',
+ 'comment',
+ this.win
+ )
+ const fragment = range.extractContents()
+ marker.appendChild(fragment)
+ range.deleteContents()
+ range.insertNode(marker)
+ highlightSupport.cleanupStaleMarkerNodes(editableHost, 'comment')
+ return startIndex
+ },
+
updateHighlight (editableHost, highlightId, addCssClass, removeCssClass) {
if (!document.documentElement.classList) return
@@ -46,6 +77,54 @@ const highlightSupport = {
return !!matches.length
},
+ extractHighlightedRanges (editableHost) {
+ const markers = $(editableHost).find('[data-word-id]')
+ if (!markers.length) {
+ return
+ }
+ const groups = {}
+ markers.each((_, marker) => {
+ const highlightId = $(marker).data('word-id')
+ if (!groups[highlightId]) {
+ groups[highlightId] = $(editableHost).find('[data-word-id="' + highlightId + '"]')
+ }
+ })
+ const res = {}
+ Object.keys(groups).forEach(highlightId => {
+ const position = this.extractMarkerNodePosition(editableHost, groups[highlightId])
+ if (position) {
+ res[highlightId] = position
+ }
+ })
+
+ return res
+ },
+
+ extractMarkerNodePosition (editableHost, markers) {
+ const range = rangy.createRange()
+ if (markers.length > 1) {
+ range.setStartBefore(markers.first()[0])
+ range.setEndAfter(markers.last()[0])
+ } else {
+ range.selectNode(markers[0])
+ }
+ const textRange = range.toCharacterRange(editableHost)
+ return {
+ start: textRange.start,
+ end: textRange.end,
+ text: range.text()
+ }
+ },
+
+ cleanupStaleMarkerNodes (editableHost, highlightType) {
+ editableHost.querySelectorAll('span[data-highlight="' + highlightType + '"]')
+ .forEach(node => {
+ if (!node.textContent.length) {
+ node.parentNode.removeChild(node)
+ }
+ })
+ },
+
createMarkerNode (markerMarkup, highlightType, win) {
let marker = $(markerMarkup)[0]
diff --git a/src/highlighting.js b/src/highlighting.js
index f03a3e06..b7926282 100644
--- a/src/highlighting.js
+++ b/src/highlighting.js
@@ -180,7 +180,8 @@ export default class Highlighting {
let wordId
do {
if (elementAtCursor === editableHost) return
- if (elementAtCursor.hasAttribute('data-word-id')) {
+ const highlightType = elementAtCursor.getAttribute('data-highlight')
+ if (highlightType === 'spellcheck') {
wordId = elementAtCursor.getAttribute('data-word-id')
break
}
diff --git a/src/selection.js b/src/selection.js
index b03d693e..f779bc47 100644
--- a/src/selection.js
+++ b/src/selection.js
@@ -1,5 +1,6 @@
-import $ from 'jquery'
+import 'rangy/lib/rangy-textrange'
+import $ from 'jquery'
import Cursor from './cursor'
import * as content from './content'
import * as parser from './parser'
@@ -51,6 +52,10 @@ export default class Selection extends Cursor {
)
}
+ getTextRange () {
+ return this.range.toCharacterRange(this.host)
+ }
+
// Get the ClientRects of this selection.
// Use this if you want more precision than getBoundingClientRect can give.
getRects () {