service2.js: Add contenteditable=plaintext-only support
This commit is contained in:
451
service2.js
451
service2.js
@@ -731,6 +731,444 @@ class BesDOMService extends BesService {
|
||||
}
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
*
|
||||
* Plain-text grammar-checking service
|
||||
*
|
||||
*************************************************************************/
|
||||
class BesPlainTextService extends BesService {
|
||||
constructor(hostElement) {
|
||||
super(hostElement)
|
||||
this.reEOP = /(\r?\n){2,}/g
|
||||
this.onBeforeInput = this.onBeforeInput.bind(this)
|
||||
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
|
||||
this.onInput = this.onInput.bind(this)
|
||||
this.hostElement.addEventListener('input', this.onInput)
|
||||
this.onClick = this.onClick.bind(this)
|
||||
this.hostElement.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers grammar checking service.
|
||||
*
|
||||
* @param {Element} hostElement DOM element to register grammar checking service for
|
||||
* @returns {BesService} Grammar checking service instance
|
||||
*/
|
||||
static register(hostElement) {
|
||||
let service = new BesPlainTextService(hostElement)
|
||||
service.proofAll()
|
||||
return service
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters grammar checking service.
|
||||
*/
|
||||
unregister() {
|
||||
this.hostElement.removeEventListener('click', this.onClick)
|
||||
this.hostElement.removeEventListener('input', this.onInput)
|
||||
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
super.unregister()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report the text is about to change
|
||||
*
|
||||
* Marks section of the text that is about to change as not-yet-grammar-checked.
|
||||
*
|
||||
* @param {InputEvent} event The event notifying the user of editable content changes
|
||||
*/
|
||||
onBeforeInput(event) {
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
if (this.abortController) this.abortController.abort()
|
||||
|
||||
// Firefox does not support contenteditable="plaintext-only" at all, Chrome/Edge's InputEvent.getTargetRanges() return
|
||||
// a useless empty array for contenteditable="plaintext-only". This makes tracking location of changes a pain.
|
||||
// We need to save the text on beforeinput and compare it to the text on input event to do the this.clearProofing().
|
||||
let { text } = this.getTextFromNodes()
|
||||
this.textBeforeChange = text
|
||||
// Continues in onInput...
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report the text has changed
|
||||
*
|
||||
* @param {InputEvent} event The event notifying the user of editable content changes
|
||||
*/
|
||||
onInput(event) {
|
||||
// ...Continued from onBeforeInput: Remove markup of all paragraphs that changed.
|
||||
// Use the offsets before change, as paragraph changes have not been updated yet.
|
||||
let paragraphRanges = new Set()
|
||||
this.getTargetRanges().forEach(range => {
|
||||
this.results.forEach(result => {
|
||||
if (BesPlainTextService.isOverlappingParagraph(result.range, range))
|
||||
paragraphRanges.add(result.range)
|
||||
})
|
||||
})
|
||||
paragraphRanges.forEach(range => this.clearProofing(range))
|
||||
delete this.textBeforeChange
|
||||
|
||||
// Now that the text is done changing, we can correctly calculate markup position.
|
||||
this.repositionAllMarkup()
|
||||
|
||||
// Defer grammar-checking to reduce stress on grammar-checking server.
|
||||
this.timer = setTimeout(() => {
|
||||
this.proofAll()
|
||||
delete this.timer
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of ranges that will be affected by a change to the DOM.
|
||||
*
|
||||
* This method attempts to fix the Chrome/Edge shortcoming in InputEvent.getTargetRanges()
|
||||
* failing to return meaningful range array on beforeinput event.
|
||||
*/
|
||||
getTargetRanges() {
|
||||
let textA = this.textBeforeChange
|
||||
let { text, nodes } = this.getTextFromNodes()
|
||||
let textB = text
|
||||
let nodesB = nodes
|
||||
|
||||
let ranges = []
|
||||
for (let i = 0, j = 0, nodeIdxB = 0; ; ) {
|
||||
if (i >= textA.length && j >= textB.length) break
|
||||
if (i >= textA.length) {
|
||||
// Some text was appended.
|
||||
let range = document.createRange()
|
||||
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
||||
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
||||
ranges.push(range)
|
||||
break
|
||||
}
|
||||
if (j >= textB.length) {
|
||||
// Some text was deleted at the end.
|
||||
let range = document.createRange()
|
||||
// range.setStartAfter(nodesB[nodesB.length - 1].node) // This puts range start at the </div>:1???
|
||||
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
||||
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
||||
ranges.push(range)
|
||||
break
|
||||
}
|
||||
if (textA.charAt(i) != textB.charAt(j)) {
|
||||
let range = document.createRange()
|
||||
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
||||
let a = textA.indexOf(textB.substr(j, 3), i)
|
||||
if (a < 0) a = textA.length
|
||||
let b = textB.indexOf(textA.substr(i, 3), j)
|
||||
if (b < 0) b = textB.length
|
||||
if (3 * (a - i) <= b - j) {
|
||||
// Suppose some text was deleted.
|
||||
i = a
|
||||
range.setEnd(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
||||
} else if (3 * (b - j) <= a - i) {
|
||||
// Suppose some text was inserted.
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
||||
nodeIdxB++
|
||||
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
||||
nodeIdxB++
|
||||
} else {
|
||||
// Suppose some text was replaced.
|
||||
i = a
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
||||
nodeIdxB++
|
||||
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
||||
nodeIdxB++
|
||||
}
|
||||
ranges.push(range)
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
||||
continue
|
||||
}
|
||||
i++
|
||||
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
||||
j++
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
/**
|
||||
* Grammar-(re)checks the host element.
|
||||
*/
|
||||
proofAll() {
|
||||
this.onStartProofing()
|
||||
|
||||
let { text, nodes } = this.getTextFromNodes()
|
||||
let nextParagraphRange = document.createRange()
|
||||
nextParagraphRange.setStartBefore(nodes[0].node)
|
||||
for (
|
||||
let start = 0, eop, end, nodeIdx = 0;
|
||||
start < text.length && nodeIdx < nodes.length;
|
||||
start = end
|
||||
) {
|
||||
this.reEOP.lastIndex = start
|
||||
let match = this.reEOP.exec(text)
|
||||
if (match !== null) {
|
||||
eop = match.index
|
||||
end = this.reEOP.lastIndex
|
||||
} else {
|
||||
eop = end = text.length
|
||||
}
|
||||
|
||||
let paragraphRange = nextParagraphRange
|
||||
nextParagraphRange = document.createRange()
|
||||
while (nodeIdx < nodes.length && nodes[nodeIdx].end < eop) nodeIdx++
|
||||
nextParagraphRange.setStart(
|
||||
nodes[nodeIdx].node,
|
||||
eop - nodes[nodeIdx].start
|
||||
)
|
||||
while (nodeIdx < nodes.length && nodes[nodeIdx].end < end) nodeIdx++
|
||||
paragraphRange.setEnd(nodes[nodeIdx].node, end - nodes[nodeIdx].start)
|
||||
while (nodeIdx < nodes.length && nodes[nodeIdx].end <= end) nodeIdx++
|
||||
|
||||
this.onProofing()
|
||||
let result = this.getProofing(paragraphRange)
|
||||
if (result != null) {
|
||||
this.onProofingProgress(result.matches.length)
|
||||
continue
|
||||
}
|
||||
const signal = this.abortController.signal
|
||||
fetch(
|
||||
new Request(besUrl + '/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
format: 'plain',
|
||||
data: JSON.stringify({
|
||||
annotation: [
|
||||
{
|
||||
text: text.substring(start, end)
|
||||
}
|
||||
]
|
||||
}),
|
||||
language: this.hostElement.lang ? this.hostElement.lang : 'sl',
|
||||
level: 'picky'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
this.onFailedProofing(response)
|
||||
throw new Error('Unexpected BesStr server response')
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then(responseData => {
|
||||
let matches = []
|
||||
responseData.matches.forEach(match => {
|
||||
let matchRange = document.createRange()
|
||||
let nodeIdx = 0,
|
||||
matchStart = start + match.offset
|
||||
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchStart)
|
||||
nodeIdx++
|
||||
matchRange.setStart(
|
||||
nodes[nodeIdx].node,
|
||||
matchStart - nodes[nodeIdx].start
|
||||
)
|
||||
let matchEnd = matchStart + match.length
|
||||
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchEnd)
|
||||
nodeIdx++
|
||||
matchRange.setEnd(
|
||||
nodes[nodeIdx].node,
|
||||
matchEnd - nodes[nodeIdx].start
|
||||
)
|
||||
const { clientRects, highlights } =
|
||||
this.addMistakeMarkup(matchRange)
|
||||
matches.push({
|
||||
rects: clientRects,
|
||||
highlights: highlights,
|
||||
range: matchRange,
|
||||
match: match
|
||||
})
|
||||
})
|
||||
this.markProofed(paragraphRange, matches)
|
||||
this.onProofingProgress(matches.length)
|
||||
})
|
||||
.catch(error => this.onFailedProofingResult(error))
|
||||
}
|
||||
|
||||
this.onProofingProgress(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates child text nodes
|
||||
*
|
||||
* @returns {Object} Concatenated text and array of nodes
|
||||
*/
|
||||
getTextFromNodes() {
|
||||
let nodes = []
|
||||
let text = ''
|
||||
for (
|
||||
let node = this.hostElement.childNodes[0];
|
||||
node;
|
||||
node = node.nextSibling
|
||||
) {
|
||||
nodes.push({
|
||||
node: node,
|
||||
start: text.length,
|
||||
end: text.length + node.data.length
|
||||
})
|
||||
text += node.data
|
||||
}
|
||||
return { text, nodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if given paragraph has already been grammar-checked.
|
||||
*
|
||||
* @param {Range} range Paragraph range
|
||||
* @returns {*} Result of grammar check if the element has already been grammar-checked; null otherwise.
|
||||
*/
|
||||
getProofing(range) {
|
||||
return this.results.find(result =>
|
||||
BesPlainTextService.isSameParagraph(result.range, range)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks given paragraph as grammar-checked.
|
||||
*
|
||||
* @param {Range} range Paragraph range
|
||||
* @param {Array} matches Grammar mistakes
|
||||
*/
|
||||
markProofed(range, matches) {
|
||||
this.results.push({
|
||||
range: range,
|
||||
matches: matches
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes given paragraph from this.results array and clearing its markup.
|
||||
*
|
||||
* @param {Range} range Paragraph range
|
||||
*/
|
||||
clearProofing(range) {
|
||||
this.clearMarkup(range)
|
||||
this.results = this.results.filter(
|
||||
result => !BesPlainTextService.isSameParagraph(result.range, range)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all grammar mistake markup positions.
|
||||
*/
|
||||
repositionAllMarkup() {
|
||||
this.results.forEach(result => {
|
||||
result.matches.forEach(match => {
|
||||
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
|
||||
match.rects = clientRects
|
||||
if (match.highlights) match.highlights.forEach(h => h.remove())
|
||||
match.highlights = highlights
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears given paragraph grammar mistake markup.
|
||||
*
|
||||
* @param {Range} range Paragraph range
|
||||
*/
|
||||
clearMarkup(range) {
|
||||
this.results
|
||||
.filter(result =>
|
||||
BesPlainTextService.isSameParagraph(result.range, range)
|
||||
)
|
||||
.forEach(result =>
|
||||
result.matches.forEach(match => {
|
||||
if (match.highlights) {
|
||||
match.highlights.forEach(h => h.remove())
|
||||
delete match.highlights
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if given ranges represent the same paragraph of text
|
||||
*
|
||||
* @param {Range} range1 Range 1
|
||||
* @param {Range} range2 Range 2
|
||||
* @returns {Boolean} true if ranges overlap
|
||||
*/
|
||||
static isSameParagraph(range1, range2) {
|
||||
return (
|
||||
range1.compareBoundaryPoints(Range.START_TO_START, range2) == 0 &&
|
||||
range1.compareBoundaryPoints(Range.END_TO_END, range2) == 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if given ranges overlap or are adjacent to each other
|
||||
*
|
||||
* @param {Range} range1 Range 1
|
||||
* @param {Range} range2 Range 2
|
||||
* @returns {Boolean} true if ranges overlap
|
||||
*/
|
||||
static isOverlappingParagraph(range1, range2) {
|
||||
const a = range1.compareBoundaryPoints(Range.END_TO_START, range2)
|
||||
const b = range1.compareBoundaryPoints(Range.START_TO_END, range2)
|
||||
return a == 0 || b == 0 || (a < 0 && b > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report mouse click
|
||||
*
|
||||
* Displays or hides grammar mistake popup.
|
||||
*
|
||||
* @param {PointerEvent} event The event produced by a pointer such as the geometry of the contact point, the device type that generated the event, the amount of pressure that was applied on the contact surface, etc.
|
||||
*/
|
||||
onClick(event) {
|
||||
const source = event?.detail !== 1 ? event?.detail : event
|
||||
const el = source.targetElement || source.target || this.hostElement
|
||||
if (!el) return
|
||||
|
||||
for (let result of this.results) {
|
||||
if (result.matches) {
|
||||
for (let m of result.matches) {
|
||||
if (m.rects) {
|
||||
for (let r of m.rects) {
|
||||
if (BesService.isPointInRect(source.clientX, source.clientY, r)) {
|
||||
const popup = document.querySelector('bes-popup-el')
|
||||
popup.changeMessage(m.match.message)
|
||||
popup.appendReplacements(
|
||||
result.range,
|
||||
m,
|
||||
this,
|
||||
this.hostElement.contentEditable !== 'false'
|
||||
)
|
||||
popup.show(source.clientX, source.clientY)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BesPopup.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces grammar checking match with a suggestion provided by grammar checking service.
|
||||
*
|
||||
* @param {Range} range Paragraph range
|
||||
* @param {*} match Grammar checking rule match
|
||||
* @param {String} replacement Text to replace grammar checking match with
|
||||
*/
|
||||
replaceText(range, match, replacement) {
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
if (this.abortController) this.abortController.abort()
|
||||
this.clearProofing(range)
|
||||
match.range.deleteContents()
|
||||
match.range.insertNode(document.createTextNode(replacement))
|
||||
this.proofAll()
|
||||
}
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
*
|
||||
* Grammar mistake popup dialog
|
||||
@@ -1109,8 +1547,15 @@ customElements.define('bes-popup-el', BesPopup)
|
||||
// Auto-register all elements with bes-service class.
|
||||
window.addEventListener('load', () => {
|
||||
document.querySelectorAll('.bes-service').forEach(hostElement => {
|
||||
// TODO: Treat contenteditable="plaintext-only" separately. It requires manual paragraph splitting on \n\n.
|
||||
if (hostElement.tagName === 'TEXTAREA') BesTAService.register(hostElement)
|
||||
else BesDOMService.register(hostElement)
|
||||
if (hostElement.tagName === 'TEXTAREA') {
|
||||
BesTAService.register(hostElement)
|
||||
} else if (
|
||||
hostElement.getAttribute('contenteditable').toLowerCase() ==
|
||||
'plaintext-only'
|
||||
) {
|
||||
BesPlainTextService.register(hostElement)
|
||||
} else {
|
||||
BesDOMService.register(hostElement)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
Reference in New Issue
Block a user