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 ple -Make The
World 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