service2.js: Finish <textarea> support

This commit is contained in:
Simon Rozman 2024-06-14 12:25:33 +02:00
parent e1b4bfb2c0
commit 4060f0866c
3 changed files with 546 additions and 221 deletions

25
samples/textarea.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BesService &lt;textarea&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 <code>&lt;textarea&gt;</code> edit control. Edit the text, resize the control or browser window, scroll around, click...</p>
<div class="my-block">
<textarea class="my-control bes-service">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.</textarea>
</div>
<bes-popup-el/>
</body>
</html>

View File

@ -1,5 +1,7 @@
// TODO: Implement <textarea> class
// TODO: Research if there is a way to disable languageTool & Grammarly extensions in CKEditor
// TODO: Revise absolute/relative placement of auxiliary <div> we inject into DOM. Absolute is more
// controllable, but lacks PlacementObserver; relative is tricky to prevent document flow
// issues, but moves with the DOM element.
/**
* Collection of all grammar checking services in the document
@ -8,33 +10,38 @@
*/
let besServices = []
// TODO: Window resize may cause host element(s) to move. That needs correction panel and status icon
// repositioning. Also, should any parent element of our service host element move, we should reposition
// correction panel and status icon. How to do this? Alas there is no PlacementObserver to monitor host
// element movements.
// TODO: Window resize may cause host element(s) to move. That needs correction panel and status
// icon repositioning. Also, should any parent element of our service host element move, we
// should reposition correction panel and status icon. How to do this? Alas there is no
// PlacementObserver to monitor host element movements. Switch to relative placement for our
// auxiliary <div>s?
window.addEventListener('resize', () =>
besServices.forEach(service => service.onReposition())
)
/*************************************************************************
/**************************************************************************************************
*
* Base class for all grammar-checking services
*
* This class provides properties and implementations of methods common to
* all types of HTML controls.
* This class provides properties and implementations of methods common to all types of HTML
* controls.
*
* This is an intermediate class and may not be used directly in client
* code.
* This is an intermediate class and may not be used directly in client code.
*
*************************************************************************/
*************************************************************************************************/
class BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service for
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
*/
constructor(hostElement) {
constructor(hostElement, textElement) {
this.hostElement = hostElement
this.textElement = textElement
this.results = [] // Results of grammar-checking, one per each block/paragraph of text
this.createCorrectionPanel()
@ -159,6 +166,11 @@ class BesService {
// Scroll panel is "position: absolute", we need to keep it aligned with the host element.
this.scrollPanel.style.top = `${-this.hostElement.scrollTop}px`
this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}px`
if (this.hostElement !== this.textElement) {
this.textElement.scrollTop = this.hostElement.scrollTop
this.textElement.scrollLeft = this.hostElement.scrollLeft
}
}
/**
@ -234,7 +246,7 @@ class BesService {
this.correctionPanel.appendChild(this.scrollPanel)
panelParent.appendChild(this.correctionPanel)
this.hostElement.parentElement.insertBefore(panelParent, this.hostElement)
this.textElement.parentElement.insertBefore(panelParent, this.textElement)
this.statusDiv = document.createElement('div')
this.statusDiv.classList.add('bes-status-div')
@ -242,9 +254,9 @@ class BesService {
this.statusIcon.classList.add('bes-status-icon')
this.statusDiv.appendChild(this.statusIcon)
this.setStatusDivPosition()
this.hostElement.parentNode.insertBefore(
this.textElement.parentNode.insertBefore(
this.statusDiv,
this.hostElement.nextSibling
this.textElement.nextSibling
)
// const statusPopup = document.createElement('bes-popup-status')
// document.body.appendChild(statusPopup)
@ -267,7 +279,7 @@ class BesService {
* Resizes correction and scroll panels to match host element size.
*/
setCorrectionPanelSize() {
const styles = window.getComputedStyle(this.hostElement)
const styles = window.getComputedStyle(this.textElement)
const totalWidth =
parseFloat(styles.paddingLeft) +
parseFloat(styles.marginLeft) +
@ -286,7 +298,7 @@ class BesService {
this.correctionPanel.style.marginRight = styles.marginRight
this.correctionPanel.style.paddingLeft = styles.paddingLeft
this.correctionPanel.style.paddingRight = styles.paddingRight
this.scrollPanel.style.height = `${this.hostElement.scrollHeight}px`
this.scrollPanel.style.height = `${this.textElement.scrollHeight}px`
}
/**
@ -299,15 +311,19 @@ class BesService {
popupCorrectionPanel(el, match, source) {
const popup = document.querySelector('bes-popup-el')
popup.changeMessage(match.match.message)
popup.appendReplacements(
el,
match,
this,
this.hostElement.contentEditable !== 'false'
)
popup.appendReplacements(el, match, this, this.isContentEditable())
popup.show(source.clientX, source.clientY)
}
/**
* Checks if host element content is editable.
*
* @returns true if editable; false otherwise
*/
isContentEditable() {
return this.hostElement.contentEditable !== 'false'
}
/**
* Updates all grammar mistake markup positions.
*/
@ -343,18 +359,13 @@ class BesService {
* Repositions status DIV element.
*/
setStatusDivPosition() {
const rect = this.hostElement.getBoundingClientRect()
const parentRect = this.hostElement.parentElement.getBoundingClientRect()
const rect = this.textElement.getBoundingClientRect()
const scrollbarWidth =
this.hostElement.offsetWidth - this.hostElement.clientWidth
this.statusDiv.style.left = `${
rect.right - parentRect.left - 40 - scrollbarWidth
}px`
this.textElement.offsetWidth - this.textElement.clientWidth
this.statusDiv.style.left = `${rect.right - 40 - scrollbarWidth}px`
const scrollbarHeight =
this.hostElement.offsetHeight - this.hostElement.clientHeight
this.statusDiv.style.top = `${
rect.bottom - parentRect.top - 30 - scrollbarHeight
}px`
this.textElement.offsetHeight - this.textElement.clientHeight
this.statusDiv.style.top = `${rect.bottom - 30 - scrollbarHeight}px`
}
/**
@ -378,36 +389,38 @@ class BesService {
}
}
/*************************************************************************
/**************************************************************************************************
*
* Grammar-checking service base class for tree-organized editors
*
* This class provides common properties and methods for HTML controls
* where text content is organized as a DOM tree. The grammar is checked
* recursively and the DOM elements in tree specify which parts of text
* represent same unit of text: block element => one standalone paragraph
* This class provides common properties and methods for HTML controls where text content is
* organized as a DOM tree. The grammar is checked recursively and the DOM elements in tree specify
* which parts of text represent same unit of text: block element => one standalone paragraph
*
* This is an intermediate class and may not be used directly in client
* code.
* This is an intermediate class and may not be used directly in client code.
*
*************************************************************************/
*************************************************************************************************/
class BesTreeService extends BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service for
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
*/
constructor(hostElement) {
super(hostElement)
constructor(hostElement, textElement) {
super(hostElement, textElement)
this.onClick = this.onClick.bind(this)
this.hostElement.addEventListener('click', this.onClick)
this.textElement.addEventListener('click', this.onClick)
}
/**
* Unregisters grammar checking service.
*/
unregister() {
this.hostElement.removeEventListener('click', this.onClick)
this.textElement.removeEventListener('click', this.onClick)
super.unregister()
}
@ -416,7 +429,7 @@ class BesTreeService extends BesService {
*/
proofAll() {
this.onStartProofing()
this.proofNode(this.hostElement, this.abortController)
this.proofNode(this.textElement, this.abortController)
this.onProofingProgress(0)
}
@ -508,7 +521,7 @@ class BesTreeService extends BesService {
) {
if (
!data[idx].markup &&
/*startingOffset <= endOffset ← not needed, kept for reference &&*/ endOffset <=
/*startingOffset <= endOffset &&*/ endOffset <=
startingOffset + data[idx].text.length
) {
range.setEnd(data[idx].node, endOffset - startingOffset)
@ -545,7 +558,8 @@ class BesTreeService extends BesService {
* Tests if given block element has already been grammar-checked.
*
* @param {Element} el DOM element to check
* @returns {*} Result of grammar check if the element has already been grammar-checked; undefined otherwise.
* @returns {*} Result of grammar check if the element has already been grammar-checked;
* undefined otherwise.
*/
getProofing(el) {
return this.results.find(result =>
@ -615,8 +629,9 @@ class BesTreeService extends BesService {
*/
isBlockElement(el) {
// Always treat our host element as block.
// Otherwise, should one make it inline, proofing would not start on it misbelieving it's a part of a bigger block of text.
if (el === this.hostElement) return true
// Otherwise, should one make it inline, proofing would not start on it misbelieving it's a
// part of a bigger block of text.
if (el === this.textElement) return true
switch (
document.defaultView
.getComputedStyle(el, null)
@ -720,7 +735,9 @@ class BesTreeService extends BesService {
*
* 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.
* @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
@ -747,26 +764,25 @@ class BesTreeService extends BesService {
}
}
/*************************************************************************
/**************************************************************************************************
*
* DOM grammar-checking service
*
* This class provides grammar-checking functionality to contenteditable=
* "true" HTML controls.
* This class provides grammar-checking functionality to contenteditable="true" HTML controls.
*
* May also be used on most of the HTML elements to highlight grammar
* mistakes. Replacing text with suggestions from the grammar-checker will
* not be possible when contenteditable is not "true".
* May also be used on most of the HTML elements to highlight grammar mistakes. Replacing text with
* suggestions from the grammar-checker will not be possible when contenteditable is not "true".
*
*************************************************************************/
*************************************************************************************************/
class BesDOMService extends BesTreeService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service for
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
*/
constructor(hostElement) {
super(hostElement)
super(hostElement, hostElement)
this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
this.onInput = this.onInput.bind(this)
@ -831,23 +847,23 @@ class BesDOMService extends BesTreeService {
}
}
/*************************************************************************
/**************************************************************************************************
*
* CKEditor grammar-checking service
*
* This class provides grammar-checking functionality to CKEditor
* controls.
* This class provides grammar-checking functionality to CKEditor controls.
*
*************************************************************************/
*************************************************************************************************/
class BesCKService extends BesTreeService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service for
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} ckEditorInstance CKEditor instance
*/
constructor(hostElement, ckEditorInstance) {
super(hostElement)
super(hostElement, hostElement)
this.ckEditorInstance = ckEditorInstance
this.disableCKEditorSpellcheck()
this.onChangeData = this.onChangeData.bind(this)
@ -893,9 +909,10 @@ class BesCKService extends BesTreeService {
this.clearProofing(domElement)
}
// TODO: Research if input event or any other event that is called *after* the change is completed
// is possible with CKEditor, and move the code below this line there.
// SetTimeout is in fact necessary, because if we set specific height to the editable CKeditor element, it will not be updated immediately.
// TODO: Research if input event or any other event that is called *after* the change is
// completed is possible with CKEditor, and move the code below this line there.
// SetTimeout is in fact necessary, because if we set specific height to the editable CKeditor
// element, it will not be updated immediately.
setTimeout(() => {
// Now that the text is done changing, we can correctly calculate markup position.
this.repositionAllMarkup()
@ -981,26 +998,26 @@ class BesCKService extends BesTreeService {
}
}
/*************************************************************************
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides grammar-checking functionality to contenteditable=
* "plaintext-only" HTML controls.
* This class provides common properties and methods for plain-text-only HTML controls like
* <input>, <textarea>, <div contenteditable="plaintext-only">...
*
* Note: Chrome and Edge only, as Firefox reverts contenteditable=
* "plaintext-only" to "false". The grammar mistakes will be highlighted
* nevertheless, but consider using BesDOMService on Firefox instead.
*
*************************************************************************/
*************************************************************************************************/
class BesPlainTextService extends BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service for
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
*/
constructor(hostElement) {
super(hostElement)
constructor(hostElement, textElement) {
super(hostElement, textElement)
this.reEOP = /(\r?\n){2,}/g
this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
@ -1010,18 +1027,6 @@ class BesPlainTextService extends BesService {
this.hostElement.addEventListener('click', this.onClick)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @returns {BesPlainTextService} Grammar checking service instance
*/
static register(hostElement) {
let service = new BesPlainTextService(hostElement)
service.proofAll()
return service
}
/**
* Unregisters grammar checking service.
*/
@ -1033,123 +1038,6 @@ class BesPlainTextService extends BesService {
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.
*/
@ -1267,7 +1155,7 @@ class BesPlainTextService extends BesService {
let nodes = []
let text = ''
for (
let node = this.hostElement.childNodes[0];
let node = this.textElement.childNodes[0];
node;
node = node.nextSibling
) {
@ -1285,7 +1173,8 @@ class BesPlainTextService extends BesService {
* 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; undefined otherwise.
* @returns {*} Result of grammar check if the element has already been grammar-checked;
* undefined otherwise.
*/
getProofing(range) {
return this.results.find(result =>
@ -1370,7 +1259,9 @@ class BesPlainTextService extends BesService {
*
* 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.
* @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
@ -1395,16 +1286,416 @@ class BesPlainTextService extends BesService {
}
BesPopup.hide()
}
/**
* Simple string compare.
*
* For performance reasons, this method compares only string beginnings and endings. Maximum one
* difference is reported.
*
* @param {String} x First string
* @param {String} y Second string
* @returns {Array} Array of string differences
*/
static diffStrings(x, y) {
let m = x.length,
n = y.length
for (let i = 0; ; ++i) {
if (i >= m && i >= n) return []
if (i >= m) return [{ type: '+', start: i, length: n - i }]
if (i >= n) return [{ type: '-', start: i, end: m }]
if (x.charAt(i) !== y.charAt(i)) {
for (;;) {
if (m <= i) return [{ type: '+', start: i, length: n - i }]
if (n <= i) return [{ type: '-', start: i, end: m }]
--m, --n
if (x.charAt(m) !== y.charAt(n))
return [{ type: '*', start: i, end: m + 1, length: n - i + 1 }]
}
}
}
}
}
/*************************************************************************
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides grammar-checking functionality to contenteditable="plaintext-only" HTML
* controls.
*
* Note: Chrome and Edge only, as Firefox reverts contenteditable="plaintext-only" to "false". The
* grammar mistakes will be highlighted nevertheless, but consider using BesDOMService on Firefox
* instead.
*
*************************************************************************************************/
class BesDOMPlainTextService extends BesPlainTextService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
*/
constructor(hostElement) {
super(hostElement, hostElement)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @returns {BesDOMPlainTextService} Grammar checking service instance
*/
static register(hostElement) {
let service = new BesDOMPlainTextService(hostElement)
service.proofAll()
return service
}
/**
* 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 diff = BesPlainTextService.diffStrings(textA, textB)
let ranges = []
for (
let i = 0, j = 0, nodeIdxB = 0, diffIdx = 0;
diffIdx < diff.length;
++diffIdx
) {
let length = diff[diffIdx].start - i
i = diff[diffIdx].start
j += length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < j) nodeIdxB++
let range = document.createRange()
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
switch (diff[diffIdx].type) {
case '-': {
// Suppose some text was deleted.
i = diff[diffIdx].end
range.setEnd(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
break
}
case '+': {
// Suppose some text was inserted.
let b = j + diff[diffIdx].length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
nodeIdxB++
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
break
}
case 'x': {
// Suppose some text was replaced.
i = diff[diffIdx].end
let b = j + diff[diffIdx].length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
nodeIdxB++
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
break
}
}
ranges.push(range)
}
return ranges
}
}
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides grammar-checking functionality to <textarea> HTML controls.
*
*************************************************************************************************/
class BesTAService extends BesPlainTextService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
*/
constructor(hostElement) {
super(hostElement, BesTAService.createTextElement(hostElement))
this.textElement.replaceChildren(
document.createTextNode(this.hostElement.value)
)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @returns {BesTAService} Grammar checking service instance
*/
static register(hostElement) {
let service = new BesTAService(hostElement)
service.proofAll()
return service
}
/**
* Creates a clone div element for the <textarea> element
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @returns The element in DOM tree that hosts text to proof. Same as hostElement, separate for
* <textarea> and <input> hosts.
*/
static createTextElement(hostElement) {
const textElement = document.createElement('div')
textElement.classList.add('bes-text-panel')
textElement.style.zIndex = hostElement.style.zIndex - 1
BesTAService.setTextElementSize(hostElement, textElement)
hostElement.parentNode.insertBefore(textElement, hostElement)
return textElement
}
/**
* Sets the size of the clone div element to match the <textarea> element
* TODO: This method should sync text element size only. Add another method for keeping styles in
* sync using MutationObserver.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
*/
static setTextElementSize(hostElement, textElement) {
const rect = hostElement.getBoundingClientRect()
const scrollTop = window.scrollY || document.documentElement.scrollTop
const scrollLeft = window.scrollX || document.documentElement.scrollLeft
const styles = window.getComputedStyle(hostElement)
textElement.style.fontSize = styles.fontSize
textElement.style.fontFamily = styles.fontFamily
textElement.style.lineHeight = styles.lineHeight
textElement.style.whiteSpace = styles.whiteSpace
textElement.style.whiteSpaceCollapse = styles.whiteSpaceCollapse
textElement.style.hyphens = styles.hyphens
textElement.style.margin = styles.margin
textElement.style.border = styles.border
textElement.style.borderRadius = styles.borderRadius
textElement.style.padding = styles.padding
textElement.style.height = styles.height
textElement.style.minHeight = styles.minHeight
textElement.style.maxHeight = styles.maxHeight
textElement.style.overflow = styles.overflow
textElement.style.overflowWrap = styles.overflowWrap
textElement.style.top = `${rect.top + scrollTop}px`
textElement.style.left = `${rect.left + scrollLeft}px`
textElement.style.width = styles.width
textElement.style.minWidth = styles.minWidth
textElement.style.maxWidth = styles.maxWidth
}
/**
* Called to report resizing
*/
onResize() {
BesTAService.setTextElementSize(this.hostElement, this.textElement)
super.onResize()
}
/**
* Called to report repositioning
*/
onReposition() {
BesTAService.setTextElementSize(this.hostElement, this.textElement)
super.onReposition()
}
/**
* Called to report the text is about to change
*
* @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()
}
/**
* Called to report <textarea> content change
*/
onInput(event) {
// Determine ranges of text that will change.
let { text, nodes } = this.getTextFromNodes()
let textA = text
let nodesA = nodes
let textB = this.hostElement.value
let diff = BesPlainTextService.diffStrings(textA, textB)
let changes = []
for (
let i = 0, j = 0, nodeIdxA = 0, diffIdx = 0;
diffIdx < diff.length;
++diffIdx
) {
let length = diff[diffIdx].start - i
i = diff[diffIdx].start
while (nodeIdxA < nodesA.length && nodesA[nodeIdxA].end < i) nodeIdxA++
let change = {
range: document.createRange()
}
change.range.setStart(nodesA[nodeIdxA].node, i - nodesA[nodeIdxA].start)
j += length
switch (diff[diffIdx].type) {
case '-': {
// Suppose some text was deleted.
while (
nodeIdxA < nodesA.length &&
nodesA[nodeIdxA].end < diff[diffIdx].end
)
nodeIdxA++
change.range.setEnd(
nodesA[nodeIdxA].node,
(i = diff[diffIdx].end) - nodesA[nodeIdxA].start
)
break
}
case '+': {
// Suppose some text was inserted.
let b = j + diff[diffIdx].length
change.range.setEnd(nodesA[nodeIdxA].node, i - nodesA[nodeIdxA].start)
change.replacement = textB.substring(j, b)
j = b
break
}
case 'x': {
// Suppose some text was replaced.
while (
nodeIdxA < nodesA.length &&
nodesA[nodeIdxA].end < diff[diffIdx].end
)
nodeIdxA++
change.range.setEnd(
nodesA[nodeIdxA].node,
(i = diff[diffIdx].end) - nodesA[nodeIdxA].start
)
let b = j + diff[diffIdx].length
change.replacement = textB.substring(j, b)
j = b
break
}
}
changes.push(change)
}
// Clear proofing for paragraphs that are about to change.
let paragraphRanges = new Set()
changes.forEach(change => {
this.results.forEach(result => {
if (
BesPlainTextService.isOverlappingParagraph(result.range, change.range)
)
paragraphRanges.add(result.range)
})
})
paragraphRanges.forEach(range => this.clearProofing(range))
// Sync changes between hostElement and textElement.
changes.forEach(change => {
change.range.deleteContents()
if (change.replacement)
change.range.insertNode(document.createTextNode(change.replacement))
})
// 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)
}
/**
* Checks if host element content is editable.
*
* @returns true if editable; false otherwise
*/
isContentEditable() {
return !this.hostElement.disabled && !this.hostElement.readOnly
}
/**
* Replaces grammar checking match with a suggestion provided by grammar checking service.
*
* @param {*} el Block element/paragraph containing grammar checking rule match
* @param {*} match Grammar checking rule match
* @param {String} replacement Text to replace grammar checking match with
*/
replaceText(el, match, replacement) {
super.replaceText(el, match, replacement)
let { text, nodes } = this.getTextFromNodes()
this.hostElement.value = text
}
}
/**************************************************************************************************
*
* Grammar mistake popup dialog
*
* This is internal class implementing the pop-up dialog user may invoke
* by clicking on a highlighted grammar mistake in text.
* This is internal class implementing the pop-up dialog user may invoke by clicking on a
* highlighted grammar mistake in text.
*
*************************************************************************/
*************************************************************************************************/
class BesPopup extends HTMLElement {
constructor() {
super()
@ -1509,7 +1800,8 @@ class BesPopup extends HTMLElement {
show(x, y) {
this.style.position = 'fixed'
// Element needs some initial placement for the browser to provide this.offsetWidth and this.offsetHeight measurements.
// Element needs some initial placement for the browser to provide this.offsetWidth and this.
// offsetHeight measurements.
// The fade-in effect on the popup window should prevent flicker.
this.style.left = `0px`
this.style.top = `0px`
@ -1547,7 +1839,8 @@ class BesPopup extends HTMLElement {
* @param {*} el Block element/paragraph containing the grammar mistake
* @param {*} match Grammar checking rule match
* @param {BesService} service Grammar checking service
* @param {Boolean} allowReplacements Host element is mutable and grammar mistake may be replaced by suggestion
* @param {Boolean} allowReplacements Host element is mutable and grammar mistake may be replaced
* by suggestion
*/
appendReplacements(el, match, service, allowReplacements) {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
@ -1789,7 +2082,7 @@ window.addEventListener('load', () => {
hostElement.getAttribute('contenteditable').toLowerCase() ===
'plaintext-only'
) {
BesPlainTextService.register(hostElement)
BesDOMPlainTextService.register(hostElement)
} else {
BesDOMService.register(hostElement)
}

View File

@ -71,6 +71,13 @@
background-image: url('images/mistake-svgrepo-com.svg');
}
.bes-text-panel {
position: absolute;
color: transparent;
border-color: transparent;
background: none;
}
/* TODO: Styles below should be removed after testing period is over */
.resizable {
overflow-x: scroll;