diff --git a/spec/dispatcher.spec.js b/spec/dispatcher.spec.js
index 374abc29..1a07dc80 100644
--- a/spec/dispatcher.spec.js
+++ b/spec/dispatcher.spec.js
@@ -140,18 +140,19 @@ describe('Dispatcher', function () {
expect(insert.calls).toEqual(1)
})
- it('fires merge if cursor is in the middle', function () {
- //
fo|o
- elem.innerHTML = 'foo'
+ it('fires "split" if cursor is in the middle', function () {
+ // ba|r
+ elem.innerHTML = 'bar'
const range = rangy.createRange()
range.setStart(elem.firstChild, 2)
range.setEnd(elem.firstChild, 2)
+ range.collapse()
createCursor(range)
const insert = on('split', (element, before, after, cursor) => {
expect(element).toEqual(elem)
- expect(content.getInnerHtmlOfFragment(before)).toEqual('fo')
- expect(content.getInnerHtmlOfFragment(after)).toEqual('o')
+ expect(content.getInnerHtmlOfFragment(before)).toEqual('ba')
+ expect(content.getInnerHtmlOfFragment(after)).toEqual('r')
expect(cursor.isCursor).toEqual(true)
})
@@ -304,5 +305,61 @@ describe('Dispatcher', function () {
elem.dispatchEvent(evt)
})
})
+
+ describe('selectToBoundary event:', function () {
+
+ it('fires "both" if all is selected', function () {
+ elem.innerHTML = 'People Make The World Go Round'
+ // select all
+ const range = rangy.createRange()
+ range.selectNodeContents(elem)
+ createCursor(range)
+ // listen for event
+ let position
+ editable.selectToBoundary(function (element, evt, pos) {
+ position = pos
+ })
+ // trigger selectionchange event
+ const selectionEvent = new Event('selectionchange', {bubbles: true})
+ elem.dispatchEvent(selectionEvent)
+ expect(position).toEqual('both')
+ })
+
+ it('fires "start" if selection is at beginning but not end', function () {
+ elem.innerHTML = 'People Make The World Go Round'
+ // select "People"
+ const range = rangy.createRange()
+ range.setStart(elem.firstChild, 0)
+ range.setEnd(elem.firstChild, 5)
+ createCursor(range)
+ // listen for event
+ let position
+ editable.selectToBoundary(function (element, evt, pos) {
+ position = pos
+ })
+ // trigger selectionchange event
+ const selectionEvent = new Event('selectionchange', {bubbles: true})
+ elem.dispatchEvent(selectionEvent)
+ expect(position).toEqual('start')
+ })
+
+ it('fires "end" if selection is at end but not beginning', function () {
+ elem.innerHTML = 'People Make The World Go Round'
+ // select "Round"
+ const range = rangy.createRange()
+ range.setStart(elem.firstChild, 25)
+ range.setEnd(elem.firstChild, 30)
+ createCursor(range)
+ // listen for event
+ let position
+ editable.selectToBoundary(function (element, evt, pos) {
+ position = pos
+ })
+ // trigger selectionchange event
+ const selectionEvent = new Event('selectionchange', {bubbles: true})
+ elem.dispatchEvent(selectionEvent)
+ expect(position).toEqual('end')
+ })
+ })
})
})
diff --git a/spec/highlighting.spec.js b/spec/highlighting.spec.js
index 957d209f..efdfbc2e 100644
--- a/spec/highlighting.spec.js
+++ b/spec/highlighting.spec.js
@@ -1,3 +1,4 @@
+import sinon from 'sinon'
import rangy from 'rangy'
import {Editable} from '../src/core'
import Highlighting from '../src/highlighting'
@@ -417,11 +418,22 @@ o Round`)
}
const expectedHtml = this.formatHtml(`Peo
-Make The
W Go Round`)
+Make The
World Go Round`)
expect(this.getHtml()).toEqual(expectedHtml)
expect(this.extract('comment')).toEqual(expectedRanges)
expect(startIndex).toEqual(3)
})
+
+ it('normalizes a simple text node after removing a highlight', function () {
+ setupHighlightEnv(this, 'People Make The World Go Round')
+ this.highlightRange('myId', 3, 7)
+ const normalizeSpy = sinon.spy(this.div, 'normalize')
+ this.removeHighlight('myId')
+ // There is no way to see the actual error in a test since it only happens in (non-headless)
+ // Chome environments. We just check if the normalize method has been called here.
+ expect(normalizeSpy.callCount).toEqual(1)
+ normalizeSpy.restore()
+ })
})
describe('highlight support with special characters', function () {
diff --git a/spec/selection.spec.js b/spec/selection.spec.js
index 6f79dec3..917d2369 100644
--- a/spec/selection.spec.js
+++ b/spec/selection.spec.js
@@ -100,7 +100,7 @@ describe('Selection', function () {
expect(this.selection.isAllSelected()).toEqual(true)
})
- it('returns true if all is selected', function () {
+ it('returns false if not all is selected', function () {
const textNode = this.oneWord.firstChild
let range = rangy.createRange()
range.setStartBefore(textNode)
diff --git a/src/core.js b/src/core.js
index 54abda33..739809f9 100644
--- a/src/core.js
+++ b/src/core.js
@@ -7,6 +7,7 @@ import * as content from './content'
import * as clipboard from './clipboard'
import Dispatcher from './dispatcher'
import Cursor from './cursor'
+import RangeContainer from './range-container'
import highlightSupport from './highlight-support'
import Highlighting from './highlighting'
import createDefaultEvents from './create-default-events'
@@ -329,6 +330,18 @@ export class Editable {
return selection
}
+ getSelectionAroundText ({editableHost, text}) {
+ const res = highlightSupport.getRangeOfText({editableHost, text})
+ if (!res) return
+ const range = rangy.createRange()
+ range.setStart(res.startNode, res.startOffset)
+ range.setEnd(res.endNode, res.endOffset)
+
+ const rangeContainer = new RangeContainer(editableHost, range)
+ const selection = rangeContainer.getSelection(this.win)
+ return selection
+ }
+
/**
* Enable spellchecking
*
@@ -384,9 +397,9 @@ export class Editable {
* @param {Boolean} options.raiseEvents do throw change events
* @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, raiseEvents}) {
+ highlight ({editableHost, text, highlightId, textRange, raiseEvents, type = 'comment'}) {
if (!textRange) {
- return highlightSupport.highlightText(editableHost, text, highlightId)
+ return highlightSupport.highlightText(editableHost, text, highlightId, type)
}
if (typeof textRange.start !== 'number' || typeof textRange.end !== 'number') {
error(
@@ -400,7 +413,7 @@ export class Editable {
)
return -1
}
- return highlightSupport.highlightRange(editableHost, highlightId, textRange.start, textRange.end, raiseEvents ? this.dispatcher : undefined)
+ return highlightSupport.highlightRange(editableHost, highlightId, textRange.start, textRange.end, raiseEvents ? this.dispatcher : undefined, type)
}
/**
@@ -521,7 +534,7 @@ Editable.browser = browser
// Set up callback functions for several events.
;['focus', 'blur', 'flow', 'selection', 'cursor', 'newline',
'insert', 'split', 'merge', 'empty', 'change', 'switch',
- 'move', 'clipboard', 'paste', 'spellcheckUpdated'
+ 'move', 'clipboard', 'paste', 'spellcheckUpdated', 'selectToBoundary'
].forEach((name) => {
// Generate a callback function to subscribe to an event.
Editable.prototype[name] = function (handler) {
diff --git a/src/dispatcher.js b/src/dispatcher.js
index 3716a82e..907efae7 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -111,7 +111,7 @@ export default class Dispatcher {
const block = this.getEditableBlockByEvent(evt)
if (!block) return
const selection = this.selectionWatcher.getFreshSelection()
- if (selection.isSelection) {
+ if (selection && selection.isSelection) {
this.notify('clipboard', block, 'copy', selection)
}
})
@@ -119,7 +119,7 @@ export default class Dispatcher {
const block = this.getEditableBlockByEvent(evt)
if (!block) return
const selection = this.selectionWatcher.getFreshSelection()
- if (selection.isSelection) {
+ if (selection && selection.isSelection) {
this.notify('clipboard', block, 'cut', selection)
this.triggerChangeEvent(block)
}
@@ -326,7 +326,17 @@ export default class Dispatcher {
// fires on mousemove (thats probably a bit too much)
// catches changes like 'select all' from context menu
- this.setupDocumentListener('selectionchange', function (evt) {
+ this.setupDocumentListener('selectionchange', (evt) => {
+ const cursor = this.selectionWatcher.getFreshSelection()
+
+ if (cursor && cursor.isSelection && cursor.isAtBeginning() && cursor.isAtEnd()) {
+ this.notify('selectToBoundary', cursor.host, evt, 'both')
+ } else if (cursor && cursor.isSelection && cursor.isAtBeginning()) {
+ this.notify('selectToBoundary', cursor.host, evt, 'start')
+ } else if (cursor && cursor.isSelection && cursor.isAtEnd()) {
+ this.notify('selectToBoundary', cursor.host, evt, 'end')
+ }
+
if (suppressSelectionChanges) {
selectionDirty = true
} else {
diff --git a/src/feature-detection.js b/src/feature-detection.js
index 7ce3f91f..3f7f0add 100644
--- a/src/feature-detection.js
+++ b/src/feature-detection.js
@@ -11,18 +11,30 @@ import browser from 'bowser'
export const contenteditable = typeof document.documentElement.contentEditable !== 'undefined'
const parser = browser.getParser(window.navigator.userAgent)
-const browserName = parser.getBrowser()
const browserEngine = parser.getEngineName()
const webKit = browserEngine === 'WebKit'
/**
- * Check selectionchange event (currently supported in IE, Chrome and Safari)
- *
- * To handle selectionchange in firefox see CKEditor selection object
- * https://github.com/ckeditor/ckeditor-dev/blob/master/core/selection.js#L388
+ * Check selectionchange event (supported in IE, Chrome, Firefox and Safari)
+ * Firefox supports it since version 52 (2017).
+ * Opera has no support as of 2021.
*/
-// not exactly feature detection... is it?
-export const selectionchange = !(browserEngine === 'Gecko' || browserName === 'Opera')
+const hasNativeSelectionchangeSupport = (document) => {
+ const doc = document
+ const osc = doc.onselectionchange
+ if (osc !== undefined) {
+ try {
+ doc.onselectionchange = 0
+ return doc.onselectionchange === null
+ } catch (e) {
+ } finally {
+ doc.onselectionchange = osc
+ }
+ }
+ return false
+}
+
+export const selectionchange = hasNativeSelectionchangeSupport(document)
// See Keyboard.prototype.preventContenteditableBug for more information.
export const contenteditableSpanBug = !!webKit
diff --git a/src/highlight-support.js b/src/highlight-support.js
index fb17dec6..4f50f964 100644
--- a/src/highlight-support.js
+++ b/src/highlight-support.js
@@ -1,6 +1,7 @@
import rangy from 'rangy'
import * as content from './content'
import highlightText from './highlight-text'
+import * as nodeType from './node-type'
import TextHighlighting from './plugins/highlighting/text-highlighting'
import {closest, createElement} from './util/dom'
@@ -10,13 +11,47 @@ function isInHost (elem, host) {
const highlightSupport = {
- highlightText (editableHost, text, highlightId) {
+ getRangeOfText ({editableHost, text}) {
+ const blockText = highlightText.extractText(editableHost)
+
+ // todo get rid of this
+ const marker = ``
+ const markerNode = highlightSupport.createMarkerNode(marker, 'selection', this.win)
+
+ const textSearch = new TextHighlighting(markerNode, 'text')
+ const matches = textSearch.findMatches(blockText, [text])
+ if (!matches || matches.length === 0) return
+
+ let nodeMatches
+ highlightText.highlightMatches(editableHost, matches, function (portions) {
+ nodeMatches = portions
+ })
+ if (!nodeMatches || nodeMatches.length === 0) {
+ return
+ } else if (nodeMatches.length === 1) {
+ return {
+ startNode: nodeMatches[0].element,
+ endNode: nodeMatches[0].element,
+ startOffset: matches[0].startIndex,
+ endOffset: matches[0].endIndex
+ }
+ } else {
+ return {
+ startNode: nodeMatches[0].element,
+ startOffset: nodeMatches[0].offset,
+ endNode: nodeMatches[nodeMatches.length - 1].element,
+ endOffset: nodeMatches[nodeMatches.length - 1].offset
+ }
+ }
+ },
+
+ highlightText (editableHost, text, highlightId, type) {
if (this.hasHighlight(editableHost, highlightId)) return
const blockText = highlightText.extractText(editableHost)
- const marker = ''
- const markerNode = highlightSupport.createMarkerNode(marker, 'highlight', this.win)
+ const marker = ``
+ const markerNode = highlightSupport.createMarkerNode(marker, type, this.win)
const textSearch = new TextHighlighting(markerNode, 'text')
const matches = textSearch.findMatches(blockText, [text])
@@ -28,7 +63,7 @@ const highlightSupport = {
}
},
- highlightRange (editableHost, highlightId, startIndex, endIndex, dispatcher, type) {
+ highlightRange (editableHost, highlightId, startIndex, endIndex, dispatcher, type = 'comment') {
if (this.hasHighlight(editableHost, highlightId)) {
this.removeHighlight(editableHost, highlightId)
}
@@ -40,8 +75,8 @@ const highlightSupport = {
}
const marker = highlightSupport.createMarkerNode(
- ``,
- type || 'comment',
+ ``,
+ type,
this.win
)
const fragment = range.extractContents()
@@ -69,6 +104,8 @@ const highlightSupport = {
const elems = editableHost.querySelectorAll(`[data-word-id="${highlightId}"]`)
for (const elem of elems) {
content.unwrap(elem)
+ // in Chrome browsers the unwrap method leaves the host node split into 2 (lastChild !== firstChild)
+ editableHost.normalize()
if (dispatcher) dispatcher.notify('change', editableHost)
}
},
diff --git a/src/highlight-text.js b/src/highlight-text.js
index 2125bc41..927664bc 100644
--- a/src/highlight-text.js
+++ b/src/highlight-text.js
@@ -22,10 +22,8 @@ export default {
// - matches
// Array of positions in the string to highlight:
// e.g [{startIndex: 0, endIndex: 1, match: 'The'}]
- highlightMatches (element, matches) {
- if (!matches || matches.length === 0) {
- return
- }
+ highlightMatches (element, matches, action) {
+ if (!action) action = this.wrapMatch
const iterator = new NodeIterator(element)
let currentMatchIndex = 0
@@ -85,8 +83,8 @@ export default {
portions.push(portion)
if (isLastPortion) {
- const lastNode = this.wrapMatch(portions, currentMatch.marker, currentMatch.title)
- iterator.replaceCurrent(lastNode)
+ const lastNode = action.apply(this, [portions, currentMatch.marker, currentMatch.title])
+ if (lastNode) iterator.replaceCurrent(lastNode)
// recalculate nodeEndOffset if we have to replace the current node.
nodeEndOffset = totalOffset + portion.length + portion.offset