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 +ple +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(`P +e +o +p +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(` +P +e +ople + + 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(`Pe + +op +le 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 + The
World
+ 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 + + The
World
+
+ 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 +a +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(`Pe +op +l +e Make The + + +
Wor
+ +ld G +o Round`) + + 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('abcd') + }) + + 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('abcd') + }) + }) + + 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 = '😐 Make The \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 The \n 🌍 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 🌍 Go \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 = '😐 Make The \n 🌍 Go \nπŸ”„' + 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 () {