Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP Format over multiple editables #237

Open
wants to merge 12 commits into
base: select-over-multiple-editables
Choose a base branch
from
67 changes: 62 additions & 5 deletions spec/dispatcher.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,19 @@ describe('Dispatcher', function () {
expect(insert.calls).toEqual(1)
})

it('fires merge if cursor is in the middle', function () {
// <div>fo|o</div>
elem.innerHTML = 'foo'
it('fires "split" if cursor is in the middle', function () {
// <div>ba|r</div>
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)
})

Expand Down Expand Up @@ -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')
})
})
})
})
14 changes: 13 additions & 1 deletion spec/highlighting.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sinon from 'sinon'
import rangy from 'rangy'
import {Editable} from '../src/core'
import Highlighting from '../src/highlighting'
Expand Down Expand Up @@ -417,11 +418,22 @@ o Round</span>`)
}
const expectedHtml = this.formatHtml(`Peo
<span class="highlight-comment" data-word-id="myId" data-editable="ui-unwrap" data-highlight="comment">ple </span>
Make The <br> W<span class="highlight-comment" data-word-id="spellcheckId" data-editable="ui-unwrap" data-highlight="spellcheck">orld</span> Go Round`)
Make The <br> W<span class="highlight-spellcheck" data-word-id="spellcheckId" data-editable="ui-unwrap" data-highlight="spellcheck">orld</span> 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 () {
Expand Down
2 changes: 1 addition & 1 deletion spec/selection.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 13 additions & 3 deletions src/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ 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)
}
})
.setupDocumentListener('cut', function cutListener (evt) {
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)
}
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 19 additions & 7 deletions src/feature-detection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 43 additions & 6 deletions src/highlight-support.js
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 = `<span class="highlight-selection"></span>`
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 = '<span class="highlight-comment"></span>'
const markerNode = highlightSupport.createMarkerNode(marker, 'highlight', this.win)
const marker = `<span class="highlight-${type}"></span>`
const markerNode = highlightSupport.createMarkerNode(marker, type, this.win)

const textSearch = new TextHighlighting(markerNode, 'text')
const matches = textSearch.findMatches(blockText, [text])
Expand All @@ -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)
}
Expand All @@ -40,8 +75,8 @@ const highlightSupport = {
}

const marker = highlightSupport.createMarkerNode(
`<span class="highlight-comment" data-word-id="${highlightId}"></span>`,
type || 'comment',
`<span class="highlight-${type}" data-word-id="${highlightId}"></span>`,
type,
this.win
)
const fragment = range.extractContents()
Expand Down Expand Up @@ -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)
}
},
Expand Down
10 changes: 4 additions & 6 deletions src/highlight-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down