From 292e76e74096bcf6f883940c38a3a69e7efbdc1e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 7 Jul 2021 15:35:49 +0200 Subject: [PATCH 01/12] code(dispatcher): new event for boundary selection --- src/core.js | 2 +- src/dispatcher.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core.js b/src/core.js index 54abda33..29438114 100644 --- a/src/core.js +++ b/src/core.js @@ -521,7 +521,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..6d59ed02 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -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.isSelection && cursor.isAtBeginning() && cursor.isAtEnd()) { + this.notify('selectToBoundary', cursor.host, evt, 'both', cursor) + } else if (cursor.isSelection && cursor.isAtBeginning()) { + this.notify('selectToBoundary', cursor.host, evt, 'start', cursor) + } else if (cursor.isSelection && cursor.isAtEnd()) { + this.notify('selectToBoundary', cursor.host, evt, 'end', cursor) + } + if (suppressSelectionChanges) { selectionDirty = true } else { From e363c6758a4ba8fbc96a993ecdfa28aeacb3def7 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 9 Jul 2021 22:54:26 +0200 Subject: [PATCH 02/12] fix(highlight): use type for class --- src/core.js | 6 +++--- src/highlight-support.js | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core.js b/src/core.js index 29438114..fe46ddc0 100644 --- a/src/core.js +++ b/src/core.js @@ -384,9 +384,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 +400,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) } /** diff --git a/src/highlight-support.js b/src/highlight-support.js index fb17dec6..5eb60a6a 100644 --- a/src/highlight-support.js +++ b/src/highlight-support.js @@ -10,13 +10,13 @@ function isInHost (elem, host) { const highlightSupport = { - highlightText (editableHost, text, highlightId) { + 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]) @@ -40,8 +40,8 @@ const highlightSupport = { } const marker = highlightSupport.createMarkerNode( - ``, - type || 'comment', + ``, + type, this.win ) const fragment = range.extractContents() From e2c6a72d9f668e3065df829ebfc63c8a9348f980 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 12 Jul 2021 11:30:34 +0200 Subject: [PATCH 03/12] chore(selection): safeguard null cursor --- src/dispatcher.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dispatcher.js b/src/dispatcher.js index 6d59ed02..69dd35c4 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -329,11 +329,11 @@ export default class Dispatcher { this.setupDocumentListener('selectionchange', (evt) => { const cursor = this.selectionWatcher.getFreshSelection() - if (cursor.isSelection && cursor.isAtBeginning() && cursor.isAtEnd()) { + if (cursor && cursor.isSelection && cursor.isAtBeginning() && cursor.isAtEnd()) { this.notify('selectToBoundary', cursor.host, evt, 'both', cursor) - } else if (cursor.isSelection && cursor.isAtBeginning()) { + } else if (cursor && cursor.isSelection && cursor.isAtBeginning()) { this.notify('selectToBoundary', cursor.host, evt, 'start', cursor) - } else if (cursor.isSelection && cursor.isAtEnd()) { + } else if (cursor && cursor.isSelection && cursor.isAtEnd()) { this.notify('selectToBoundary', cursor.host, evt, 'end', cursor) } From 170d0186a825c0f10c8622170e87156ae1b1d5ca Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 14 Jul 2021 18:33:00 +0200 Subject: [PATCH 04/12] fix(content): normalise host after unwrap --- src/highlight-support.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/highlight-support.js b/src/highlight-support.js index 5eb60a6a..d3ff6abc 100644 --- a/src/highlight-support.js +++ b/src/highlight-support.js @@ -69,6 +69,7 @@ const highlightSupport = { const elems = editableHost.querySelectorAll(`[data-word-id="${highlightId}"]`) for (const elem of elems) { content.unwrap(elem) + editableHost.normalize() if (dispatcher) dispatcher.notify('change', editableHost) } }, From fc23b21e43b231e2457d1591579475f5ff86e43e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 21 Jul 2021 16:10:26 +0200 Subject: [PATCH 05/12] fix(selectionchange): firefox supports it --- src/feature-detection.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/feature-detection.js b/src/feature-detection.js index 7ce3f91f..769ebcbc 100644 --- a/src/feature-detection.js +++ b/src/feature-detection.js @@ -16,13 +16,11 @@ 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 (currently supported in IE, Chrome, Firefox and Safari) + * Firefox supports it since version 52 (2017) so pretty sure this is fine. */ // not exactly feature detection... is it? -export const selectionchange = !(browserEngine === 'Gecko' || browserName === 'Opera') +export const selectionchange = !(browserName === 'Opera') // See Keyboard.prototype.preventContenteditableBug for more information. export const contenteditableSpanBug = !!webKit From 91c7634cfebe6a64435b30c5b48cacf4ea3db4d0 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 28 Jul 2021 23:41:09 +0200 Subject: [PATCH 06/12] chore(cut/copy): safeguard selection --- src/dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dispatcher.js b/src/dispatcher.js index 69dd35c4..fb4f14e3 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) } From 2cb2d02711b1106a744e78bc71abf9516dd5ce9f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 29 Jul 2021 23:10:50 +0200 Subject: [PATCH 07/12] chore(highlight): fix tests --- spec/highlighting.spec.js | 2 +- src/highlight-support.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/highlighting.spec.js b/spec/highlighting.spec.js index 957d209f..73683eda 100644 --- a/spec/highlighting.spec.js +++ b/spec/highlighting.spec.js @@ -417,7 +417,7 @@ 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) diff --git a/src/highlight-support.js b/src/highlight-support.js index d3ff6abc..6a449ee9 100644 --- a/src/highlight-support.js +++ b/src/highlight-support.js @@ -28,7 +28,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) } From 2304164215decb9f43793ddb22433ff059a1005f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 31 Jul 2021 15:49:27 +0200 Subject: [PATCH 08/12] chore(dispatcher): add selectToBoundary tests --- spec/dispatcher.spec.js | 69 ++++++++++++++++++++++++++++++++++++--- spec/highlighting.spec.js | 12 +++++++ spec/selection.spec.js | 2 +- src/dispatcher.js | 9 +++-- src/highlight-support.js | 1 + 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/spec/dispatcher.spec.js b/spec/dispatcher.spec.js index 374abc29..6802019c 100644 --- a/spec/dispatcher.spec.js +++ b/spec/dispatcher.spec.js @@ -140,24 +140,27 @@ 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 () { + console.log('HERE................') + //
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) }) const evt = new KeyboardEvent('keydown', {keyCode: key.enter}) elem.dispatchEvent(evt) expect(insert.calls).toEqual(1) + console.log('END...........') }) }) @@ -304,5 +307,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 73683eda..3fde939b 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' @@ -422,6 +423,17 @@ Make The
W Date: Mon, 2 Aug 2021 17:55:03 +0200 Subject: [PATCH 09/12] fix(dispatcher): remove fdescribe --- spec/dispatcher.spec.js | 2 -- src/dispatcher.js | 3 --- 2 files changed, 5 deletions(-) diff --git a/spec/dispatcher.spec.js b/spec/dispatcher.spec.js index 6802019c..1a07dc80 100644 --- a/spec/dispatcher.spec.js +++ b/spec/dispatcher.spec.js @@ -141,7 +141,6 @@ describe('Dispatcher', function () { }) it('fires "split" if cursor is in the middle', function () { - console.log('HERE................') //
ba|r
elem.innerHTML = 'bar' const range = rangy.createRange() @@ -160,7 +159,6 @@ describe('Dispatcher', function () { const evt = new KeyboardEvent('keydown', {keyCode: key.enter}) elem.dispatchEvent(evt) expect(insert.calls).toEqual(1) - console.log('END...........') }) }) diff --git a/src/dispatcher.js b/src/dispatcher.js index 45d47cc5..907efae7 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -276,13 +276,10 @@ export default class Dispatcher { const cursor = range.forceCursor() if (cursor.isAtTextEnd()) { - console.log('at end', event.target) self.notify('insert', this, 'after', cursor) } else if (cursor.isAtBeginning()) { - console.log('at beginning', event.target) self.notify('insert', this, 'before', cursor) } else { - console.log('in split', event.target) self.notify('split', this, cursor.before(), cursor.after(), cursor) } }) From 20f2bb9fa34be274de07ae66a7e46502b3070949 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 2 Aug 2021 19:00:53 +0200 Subject: [PATCH 10/12] chore(highlight-spec): use this context --- spec/highlighting.spec.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/highlighting.spec.js b/spec/highlighting.spec.js index 3fde939b..efdfbc2e 100644 --- a/spec/highlighting.spec.js +++ b/spec/highlighting.spec.js @@ -425,10 +425,10 @@ Make The
W Date: Mon, 2 Aug 2021 21:25:39 +0200 Subject: [PATCH 11/12] chore(selectionchange): use feature detection --- src/feature-detection.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/feature-detection.js b/src/feature-detection.js index 769ebcbc..3f7f0add 100644 --- a/src/feature-detection.js +++ b/src/feature-detection.js @@ -11,16 +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, Firefox and Safari) - * Firefox supports it since version 52 (2017) so pretty sure this is fine. + * 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 = !(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 From 6705f3f1ede12b3ab428dfe8465754714f61f715 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 3 Aug 2021 20:07:39 +0200 Subject: [PATCH 12/12] mvp(selection): create selection by text --- src/core.js | 13 +++++++++++++ src/highlight-support.js | 35 +++++++++++++++++++++++++++++++++++ src/highlight-text.js | 10 ++++------ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/core.js b/src/core.js index fe46ddc0..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 * diff --git a/src/highlight-support.js b/src/highlight-support.js index e9c73baa..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,6 +11,40 @@ function isInHost (elem, host) { const highlightSupport = { + 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 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