service2.js: Add contenteditable=plaintext-only support

This commit is contained in:
2024-05-21 18:32:30 +02:00
parent 478f6269ee
commit 5f83dfa4ba
2 changed files with 471 additions and 3 deletions

View File

@@ -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)
}
})
})