service2.js: Add contenteditable=plaintext-only support

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

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BesService &lt;div contenteditable="plaintext-only"&gt; Example</title>
<link rel="stylesheet" href="../styles.css" />
<link rel="stylesheet" href="styles.css" />
<script>const besUrl = 'http://localhost:225/api/v2';</script>
<script src="../service2.js"></script>
</head>
<body>
<p class="my-block">This is an example of a simple <code>&lt;div contenteditable="plaintext-only"&gt;</code> edit control. Edit the text, resize the control or browser window, scroll around, click...</p>
<div class="my-block my-control bes-service" contenteditable="plaintext-only">Tukaj vpišite besedilo ki ga želite popraviti.
Prišla je njena lepa hčera. Smatram da tega nebi bilo potrebno storiti. Predavanje je trajalo dve ure. S njim grem v Kamnik. Janez jutri nebo prišel. Prišel je z 100 idejami.
To velja tudi v Bledu. To se je zgodilo na velikemu vrtu. Prišel je na Kamnik. On je včeraj prišel z svojo torbo. Dve žemlje prosim. Pogosto brskam po temu forumu. Prišel je včeraj in sicer s otroci. To ne vem. Pogleda vse kar daš v odložišče. Nisem jo videl. Ona izgleda dobro. Pri zanikanju ne smete uporabljati tožilnik. Vlak gre v Ljubljano čez Zidani Most. Skočil je čez okno. Slovenija meji na avstrijo. Jaz pišem v Slovenščini vsak Torek. Novica, da je skupina 25 planincev hodila pod vodstvom gorskega vodnika je napačna in zavajujoča. Želim da poješ kosmizailo. Jaz pogosto brskam po temu forumu. Med tem ko je iskal ključe, so se odprla vrata. V takoimenovanem skladišču je bilo veliko ljudi. V sobi sta dve mize. Stekel je h mami. Videl sem Jurčič Micko. To je bil njegov življenski cilj. Po vrsti popravite vse kar želite. Preden zaspiva mi prebere pravljico. Prišel je s stricom. Oni zadanejo tarčo. Mi gremo teči po polju. Mi gremo peči kruh. Usedel se je k miza. Postreži kosilo! Skul je veslanje z dvemi vesli.
Na mizo nisem položil knjigo.</div>
<bes-popup-el/>
</body>
</html>

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