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: 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 * Collection of all grammar checking services in the document
@ -8,33 +10,38 @@
*/ */
let besServices = [] let besServices = []
// TODO: Window resize may cause host element(s) to move. That needs correction panel and status icon // TODO: Window resize may cause host element(s) to move. That needs correction panel and status
// repositioning. Also, should any parent element of our service host element move, we should reposition // icon repositioning. Also, should any parent element of our service host element move, we
// correction panel and status icon. How to do this? Alas there is no PlacementObserver to monitor host // should reposition correction panel and status icon. How to do this? Alas there is no
// element movements. // PlacementObserver to monitor host element movements. Switch to relative placement for our
// auxiliary <div>s?
window.addEventListener('resize', () => window.addEventListener('resize', () =>
besServices.forEach(service => service.onReposition()) besServices.forEach(service => service.onReposition())
) )
/************************************************************************* /**************************************************************************************************
* *
* Base class for all grammar-checking services * Base class for all grammar-checking services
* *
* This class provides properties and implementations of methods common to * This class provides properties and implementations of methods common to all types of HTML
* all types of HTML controls. * controls.
* *
* This is an intermediate class and may not be used directly in client * This is an intermediate class and may not be used directly in client code.
* code.
* *
*************************************************************************/ *************************************************************************************************/
class BesService { class BesService {
/** /**
* Constructs class. * 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.hostElement = hostElement
this.textElement = textElement
this.results = [] // Results of grammar-checking, one per each block/paragraph of text this.results = [] // Results of grammar-checking, one per each block/paragraph of text
this.createCorrectionPanel() this.createCorrectionPanel()
@ -159,6 +166,11 @@ class BesService {
// Scroll panel is "position: absolute", we need to keep it aligned with the host element. // 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.top = `${-this.hostElement.scrollTop}px`
this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}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) this.correctionPanel.appendChild(this.scrollPanel)
panelParent.appendChild(this.correctionPanel) 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 = document.createElement('div')
this.statusDiv.classList.add('bes-status-div') this.statusDiv.classList.add('bes-status-div')
@ -242,9 +254,9 @@ class BesService {
this.statusIcon.classList.add('bes-status-icon') this.statusIcon.classList.add('bes-status-icon')
this.statusDiv.appendChild(this.statusIcon) this.statusDiv.appendChild(this.statusIcon)
this.setStatusDivPosition() this.setStatusDivPosition()
this.hostElement.parentNode.insertBefore( this.textElement.parentNode.insertBefore(
this.statusDiv, this.statusDiv,
this.hostElement.nextSibling this.textElement.nextSibling
) )
// const statusPopup = document.createElement('bes-popup-status') // const statusPopup = document.createElement('bes-popup-status')
// document.body.appendChild(statusPopup) // document.body.appendChild(statusPopup)
@ -267,7 +279,7 @@ class BesService {
* Resizes correction and scroll panels to match host element size. * Resizes correction and scroll panels to match host element size.
*/ */
setCorrectionPanelSize() { setCorrectionPanelSize() {
const styles = window.getComputedStyle(this.hostElement) const styles = window.getComputedStyle(this.textElement)
const totalWidth = const totalWidth =
parseFloat(styles.paddingLeft) + parseFloat(styles.paddingLeft) +
parseFloat(styles.marginLeft) + parseFloat(styles.marginLeft) +
@ -286,7 +298,7 @@ class BesService {
this.correctionPanel.style.marginRight = styles.marginRight this.correctionPanel.style.marginRight = styles.marginRight
this.correctionPanel.style.paddingLeft = styles.paddingLeft this.correctionPanel.style.paddingLeft = styles.paddingLeft
this.correctionPanel.style.paddingRight = styles.paddingRight 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) { popupCorrectionPanel(el, match, source) {
const popup = document.querySelector('bes-popup-el') const popup = document.querySelector('bes-popup-el')
popup.changeMessage(match.match.message) popup.changeMessage(match.match.message)
popup.appendReplacements( popup.appendReplacements(el, match, this, this.isContentEditable())
el,
match,
this,
this.hostElement.contentEditable !== 'false'
)
popup.show(source.clientX, source.clientY) 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. * Updates all grammar mistake markup positions.
*/ */
@ -343,18 +359,13 @@ class BesService {
* Repositions status DIV element. * Repositions status DIV element.
*/ */
setStatusDivPosition() { setStatusDivPosition() {
const rect = this.hostElement.getBoundingClientRect() const rect = this.textElement.getBoundingClientRect()
const parentRect = this.hostElement.parentElement.getBoundingClientRect()
const scrollbarWidth = const scrollbarWidth =
this.hostElement.offsetWidth - this.hostElement.clientWidth this.textElement.offsetWidth - this.textElement.clientWidth
this.statusDiv.style.left = `${ this.statusDiv.style.left = `${rect.right - 40 - scrollbarWidth}px`
rect.right - parentRect.left - 40 - scrollbarWidth
}px`
const scrollbarHeight = const scrollbarHeight =
this.hostElement.offsetHeight - this.hostElement.clientHeight this.textElement.offsetHeight - this.textElement.clientHeight
this.statusDiv.style.top = `${ this.statusDiv.style.top = `${rect.bottom - 30 - scrollbarHeight}px`
rect.bottom - parentRect.top - 30 - scrollbarHeight
}px`
} }
/** /**
@ -378,36 +389,38 @@ class BesService {
} }
} }
/************************************************************************* /**************************************************************************************************
* *
* Grammar-checking service base class for tree-organized editors * Grammar-checking service base class for tree-organized editors
* *
* This class provides common properties and methods for HTML controls * This class provides common properties and methods for HTML controls where text content is
* where text content is organized as a DOM tree. The grammar is checked * organized as a DOM tree. The grammar is checked recursively and the DOM elements in tree specify
* recursively and the DOM elements in tree specify which parts of text * which parts of text represent same unit of text: block element => one standalone paragraph
* represent same unit of text: block element => one standalone paragraph
* *
* This is an intermediate class and may not be used directly in client * This is an intermediate class and may not be used directly in client code.
* code.
* *
*************************************************************************/ *************************************************************************************************/
class BesTreeService extends BesService { class BesTreeService extends BesService {
/** /**
* Constructs class. * 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) {
super(hostElement) super(hostElement, textElement)
this.onClick = this.onClick.bind(this) this.onClick = this.onClick.bind(this)
this.hostElement.addEventListener('click', this.onClick) this.textElement.addEventListener('click', this.onClick)
} }
/** /**
* Unregisters grammar checking service. * Unregisters grammar checking service.
*/ */
unregister() { unregister() {
this.hostElement.removeEventListener('click', this.onClick) this.textElement.removeEventListener('click', this.onClick)
super.unregister() super.unregister()
} }
@ -416,7 +429,7 @@ class BesTreeService extends BesService {
*/ */
proofAll() { proofAll() {
this.onStartProofing() this.onStartProofing()
this.proofNode(this.hostElement, this.abortController) this.proofNode(this.textElement, this.abortController)
this.onProofingProgress(0) this.onProofingProgress(0)
} }
@ -508,7 +521,7 @@ class BesTreeService extends BesService {
) { ) {
if ( if (
!data[idx].markup && !data[idx].markup &&
/*startingOffset <= endOffset ← not needed, kept for reference &&*/ endOffset <= /*startingOffset <= endOffset &&*/ endOffset <=
startingOffset + data[idx].text.length startingOffset + data[idx].text.length
) { ) {
range.setEnd(data[idx].node, endOffset - startingOffset) 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. * Tests if given block element has already been grammar-checked.
* *
* @param {Element} el DOM element to check * @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) { getProofing(el) {
return this.results.find(result => return this.results.find(result =>
@ -615,8 +629,9 @@ class BesTreeService extends BesService {
*/ */
isBlockElement(el) { isBlockElement(el) {
// Always treat our host element as block. // 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. // Otherwise, should one make it inline, proofing would not start on it misbelieving it's a
if (el === this.hostElement) return true // part of a bigger block of text.
if (el === this.textElement) return true
switch ( switch (
document.defaultView document.defaultView
.getComputedStyle(el, null) .getComputedStyle(el, null)
@ -720,7 +735,9 @@ class BesTreeService extends BesService {
* *
* Displays or hides grammar mistake popup. * 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) { onClick(event) {
const source = event?.detail !== 1 ? event?.detail : event const source = event?.detail !== 1 ? event?.detail : event
@ -747,26 +764,25 @@ class BesTreeService extends BesService {
} }
} }
/************************************************************************* /**************************************************************************************************
* *
* DOM grammar-checking service * DOM grammar-checking service
* *
* This class provides grammar-checking functionality to contenteditable= * This class provides grammar-checking functionality to contenteditable="true" HTML controls.
* "true" HTML controls.
* *
* May also be used on most of the HTML elements to highlight grammar * May also be used on most of the HTML elements to highlight grammar mistakes. Replacing text with
* mistakes. Replacing text with suggestions from the grammar-checker will * suggestions from the grammar-checker will not be possible when contenteditable is not "true".
* not be possible when contenteditable is not "true".
* *
*************************************************************************/ *************************************************************************************************/
class BesDOMService extends BesTreeService { class BesDOMService extends BesTreeService {
/** /**
* Constructs class. * 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) { constructor(hostElement) {
super(hostElement) super(hostElement, hostElement)
this.onBeforeInput = this.onBeforeInput.bind(this) this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput) this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
this.onInput = this.onInput.bind(this) this.onInput = this.onInput.bind(this)
@ -831,23 +847,23 @@ class BesDOMService extends BesTreeService {
} }
} }
/************************************************************************* /**************************************************************************************************
* *
* CKEditor grammar-checking service * CKEditor grammar-checking service
* *
* This class provides grammar-checking functionality to CKEditor * This class provides grammar-checking functionality to CKEditor controls.
* controls.
* *
*************************************************************************/ *************************************************************************************************/
class BesCKService extends BesTreeService { class BesCKService extends BesTreeService {
/** /**
* Constructs class. * 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 * @param {*} ckEditorInstance CKEditor instance
*/ */
constructor(hostElement, ckEditorInstance) { constructor(hostElement, ckEditorInstance) {
super(hostElement) super(hostElement, hostElement)
this.ckEditorInstance = ckEditorInstance this.ckEditorInstance = ckEditorInstance
this.disableCKEditorSpellcheck() this.disableCKEditorSpellcheck()
this.onChangeData = this.onChangeData.bind(this) this.onChangeData = this.onChangeData.bind(this)
@ -893,9 +909,10 @@ class BesCKService extends BesTreeService {
this.clearProofing(domElement) this.clearProofing(domElement)
} }
// TODO: Research if input event or any other event that is called *after* the change is completed // TODO: Research if input event or any other event that is called *after* the change is
// is possible with CKEditor, and move the code below this line there. // 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 is in fact necessary, because if we set specific height to the editable CKeditor
// element, it will not be updated immediately.
setTimeout(() => { setTimeout(() => {
// Now that the text is done changing, we can correctly calculate markup position. // Now that the text is done changing, we can correctly calculate markup position.
this.repositionAllMarkup() this.repositionAllMarkup()
@ -981,26 +998,26 @@ class BesCKService extends BesTreeService {
} }
} }
/************************************************************************* /**************************************************************************************************
* *
* Plain-text grammar-checking service * Plain-text grammar-checking service
* *
* This class provides grammar-checking functionality to contenteditable= * This class provides common properties and methods for plain-text-only HTML controls like
* "plaintext-only" HTML controls. * <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 { class BesPlainTextService extends BesService {
/** /**
* Constructs class. * 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) {
super(hostElement) super(hostElement, textElement)
this.reEOP = /(\r?\n){2,}/g this.reEOP = /(\r?\n){2,}/g
this.onBeforeInput = this.onBeforeInput.bind(this) this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput) this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
@ -1010,18 +1027,6 @@ class BesPlainTextService extends BesService {
this.hostElement.addEventListener('click', this.onClick) 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. * Unregisters grammar checking service.
*/ */
@ -1033,123 +1038,6 @@ class BesPlainTextService extends BesService {
super.unregister() 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. * Grammar-(re)checks the host element.
*/ */
@ -1267,7 +1155,7 @@ class BesPlainTextService extends BesService {
let nodes = [] let nodes = []
let text = '' let text = ''
for ( for (
let node = this.hostElement.childNodes[0]; let node = this.textElement.childNodes[0];
node; node;
node = node.nextSibling node = node.nextSibling
) { ) {
@ -1285,7 +1173,8 @@ class BesPlainTextService extends BesService {
* Tests if given paragraph has already been grammar-checked. * Tests if given paragraph has already been grammar-checked.
* *
* @param {Range} range Paragraph range * @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) { getProofing(range) {
return this.results.find(result => return this.results.find(result =>
@ -1370,7 +1259,9 @@ class BesPlainTextService extends BesService {
* *
* Displays or hides grammar mistake popup. * 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) { onClick(event) {
const source = event?.detail !== 1 ? event?.detail : event const source = event?.detail !== 1 ? event?.detail : event
@ -1395,16 +1286,416 @@ class BesPlainTextService extends BesService {
} }
BesPopup.hide() 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 * Grammar mistake popup dialog
* *
* This is internal class implementing the pop-up dialog user may invoke * This is internal class implementing the pop-up dialog user may invoke by clicking on a
* by clicking on a highlighted grammar mistake in text. * highlighted grammar mistake in text.
* *
*************************************************************************/ *************************************************************************************************/
class BesPopup extends HTMLElement { class BesPopup extends HTMLElement {
constructor() { constructor() {
super() super()
@ -1509,7 +1800,8 @@ class BesPopup extends HTMLElement {
show(x, y) { show(x, y) {
this.style.position = 'fixed' 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. // The fade-in effect on the popup window should prevent flicker.
this.style.left = `0px` this.style.left = `0px`
this.style.top = `0px` this.style.top = `0px`
@ -1547,7 +1839,8 @@ class BesPopup extends HTMLElement {
* @param {*} el Block element/paragraph containing the grammar mistake * @param {*} el Block element/paragraph containing the grammar mistake
* @param {*} match Grammar checking rule match * @param {*} match Grammar checking rule match
* @param {BesService} service Grammar checking service * @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) { appendReplacements(el, match, service, allowReplacements) {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div') const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
@ -1789,7 +2082,7 @@ window.addEventListener('load', () => {
hostElement.getAttribute('contenteditable').toLowerCase() === hostElement.getAttribute('contenteditable').toLowerCase() ===
'plaintext-only' 'plaintext-only'
) { ) {
BesPlainTextService.register(hostElement) BesDOMPlainTextService.register(hostElement)
} else { } else {
BesDOMService.register(hostElement) BesDOMService.register(hostElement)
} }

View File

@ -71,6 +71,13 @@
background-image: url('images/mistake-svgrepo-com.svg'); 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 */ /* TODO: Styles below should be removed after testing period is over */
.resizable { .resizable {
overflow-x: scroll; overflow-x: scroll;