Fork service2.js
Due to intensive development, service.js grew complex and convoluted. With lessons learned we shall prepare a cleaner and leaner version of the code.
This commit is contained in:
parent
d15348ed50
commit
e499ad22f8
21
samples/div-contenteditable.html
Normal file
21
samples/div-contenteditable.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BesService 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><div contenteditable="true"></code> edit control. Edit the text, resize the control or browser window, scroll around...</p>
|
||||
<div class="my-block my-control bes-service" contenteditable="true">
|
||||
<p>Tukaj vpišite besedilo ki ga želite popraviti.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>Na mizo nisem položil knjigo.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
21
samples/static-content.html
Normal file
21
samples/static-content.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BesService 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 grammar-checking static HTML content. The below text contains proofing markup.</p>
|
||||
<div class="my-block my-control bes-service">
|
||||
<p>Tukaj vpišite besedilo ki ga želite popraviti.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>Na mizo nisem položil knjigo.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
16
samples/styles.css
Normal file
16
samples/styles.css
Normal file
@ -0,0 +1,16 @@
|
||||
.my-block {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-size: 1rem;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.my-control {
|
||||
overflow-y: auto;
|
||||
height: 300px;
|
||||
border-radius: 10px;
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
line-height: 20px;
|
||||
}
|
699
service2.js
Normal file
699
service2.js
Normal file
@ -0,0 +1,699 @@
|
||||
// TODO: Port popup dialog from service.js
|
||||
// TODO: Test with <div contenteditable="plaintext-only">
|
||||
// TODO: Implement <textarea> class
|
||||
// TODO: Port CKEditor class from service.js
|
||||
|
||||
/**
|
||||
* Collection of all grammar checking services in the document
|
||||
*
|
||||
* We dispatch all window messages to all services registered here.
|
||||
*/
|
||||
let besServices = []
|
||||
|
||||
window.addEventListener('resize', () =>
|
||||
besServices.forEach(service => service.onResize())
|
||||
)
|
||||
|
||||
window.addEventListener('scroll', () =>
|
||||
besServices.forEach(service => service.onScroll())
|
||||
)
|
||||
|
||||
/*************************************************************************
|
||||
*
|
||||
* Base class for all grammar-checking services
|
||||
*
|
||||
*************************************************************************/
|
||||
class BesService {
|
||||
constructor(hostElement) {
|
||||
this.hostElement = hostElement
|
||||
this.abortController = new AbortController()
|
||||
this.createCorrectionPanel()
|
||||
|
||||
// Disable browser built-in spell-checker to prevent collision with our grammar markup.
|
||||
this.originalSpellcheck = this.hostElement.spellcheck
|
||||
this.hostElement.spellcheck = false
|
||||
|
||||
this.handleScroll = () => this.onScroll()
|
||||
this.hostElement.addEventListener('scroll', this.handleScroll)
|
||||
|
||||
besServices.push(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters grammar checking service.
|
||||
*/
|
||||
unregister() {
|
||||
besServices = besServices.filter(item => item !== this)
|
||||
this.hostElement.removeEventListener('scroll', this.handleScroll)
|
||||
this.hostElement.spellcheck = this.originalSpellcheck
|
||||
this.clearCorrectionPanel()
|
||||
this.abortController.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called initially when grammar-checking run is started
|
||||
*/
|
||||
onStartProofing() {
|
||||
this.proofingCount = 0 // Ref-count how many grammar-checking blocks of text are active
|
||||
this.proofingMatches = 0 // Number of grammar mistakes detected in entire grammar-checking run
|
||||
this.updateStatusIcon('bes-status-loading', 'Besana preverja pravopis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when grammar-checking starts proofing each block of text (typically paragraph)
|
||||
*/
|
||||
onProofing() {
|
||||
this.proofingCount++
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when grammar-checking of a block of text failed (as 500 Internal server error, timeout, etc.)
|
||||
*
|
||||
* @param {Response} response HTTP response
|
||||
*/
|
||||
onFailedProofing(response) {
|
||||
this.updateStatusIcon(
|
||||
'bes-status-error',
|
||||
`Pri preverjanju pravopisa je prišlo do napake ${response.status} ${response.statusText}.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when failed to parse result of a grammar-checking of a block of text
|
||||
*
|
||||
* @param {Error} error Error
|
||||
*/
|
||||
onFailedProofingResult(error) {
|
||||
this.proofingCount--
|
||||
this.updateStatusIcon(
|
||||
'bes-status-error',
|
||||
`Pri obdelavi odgovora pravopisnega strežnika je prišlo do napake: ${error}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one block of text finished grammar-checking
|
||||
*
|
||||
* @param {Number} numberOfMatches Number of grammar mistakes discovered
|
||||
*/
|
||||
onProofingProgress(numberOfMatches) {
|
||||
this.proofingMatches += numberOfMatches
|
||||
if (--this.proofingCount <= 0) {
|
||||
// This was the last block of text in the run we were waiting for.
|
||||
// TODO: If onFailedProofingResult was called on a non-last block of text, the below will override the status icon error state.
|
||||
if (this.proofingMatches > 0)
|
||||
this.updateStatusIcon(
|
||||
'bes-status-mistakes',
|
||||
'Število napak: ' + this.proofingMatches
|
||||
)
|
||||
else this.updateStatusIcon('bes-status-success', 'V besedilu ni napak.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report scrolling
|
||||
*/
|
||||
onScroll() {
|
||||
// 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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report resizing
|
||||
*/
|
||||
onResize() {
|
||||
this.setCorrectionPanelSize()
|
||||
this.setStatusDivPosition()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates auxiliary DOM elements for text adornments.
|
||||
*/
|
||||
createCorrectionPanel() {
|
||||
const panelParent = document.createElement('div')
|
||||
panelParent.classList.add('bes-correction-panel-parent')
|
||||
this.correctionPanel = document.createElement('div')
|
||||
this.scrollPanel = document.createElement('div')
|
||||
this.setCorrectionPanelSize()
|
||||
this.correctionPanel.classList.add('bes-correction-panel')
|
||||
this.scrollPanel.classList.add('bes-correction-panel-scroll')
|
||||
|
||||
this.correctionPanel.appendChild(this.scrollPanel)
|
||||
panelParent.appendChild(this.correctionPanel)
|
||||
this.hostElement.parentElement.insertBefore(panelParent, this.hostElement)
|
||||
|
||||
this.statusDiv = document.createElement('div')
|
||||
this.statusDiv.classList.add('bes-status-div')
|
||||
this.statusIcon = document.createElement('div')
|
||||
this.statusIcon.classList.add('bes-status-icon')
|
||||
this.statusDiv.appendChild(this.statusIcon)
|
||||
this.setStatusDivPosition()
|
||||
this.hostElement.parentNode.insertBefore(
|
||||
this.statusDiv,
|
||||
this.hostElement.nextSibling
|
||||
)
|
||||
const statusPopup = document.createElement('bes-popup-status-el')
|
||||
document.body.appendChild(statusPopup)
|
||||
this.statusDiv.addEventListener('click', e =>
|
||||
this.handleStatusClick(e, statusPopup)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears auxiliary DOM elements for text adornments.
|
||||
*/
|
||||
clearCorrectionPanel() {
|
||||
this.correctionPanel.remove()
|
||||
this.scrollPanel.remove()
|
||||
this.statusDiv.remove()
|
||||
this.statusIcon.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes correction and scroll panels to match host element size.
|
||||
*/
|
||||
setCorrectionPanelSize() {
|
||||
const styles = window.getComputedStyle(this.hostElement)
|
||||
const totalWidth = parseFloat(styles.width)
|
||||
const totalHeight =
|
||||
parseFloat(styles.height) +
|
||||
parseFloat(styles.marginTop) +
|
||||
parseFloat(styles.marginBottom) +
|
||||
parseFloat(styles.paddingTop) +
|
||||
parseFloat(styles.paddingBottom)
|
||||
this.correctionPanel.style.width = `${totalWidth}px`
|
||||
this.correctionPanel.style.height = `${totalHeight}px`
|
||||
this.correctionPanel.style.marginLeft = styles.marginLeft
|
||||
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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions status DIV element.
|
||||
*/
|
||||
setStatusDivPosition() {
|
||||
const rect = this.hostElement.getBoundingClientRect()
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
||||
this.statusDiv.style.left = `${rect.right - 40}px`
|
||||
this.statusDiv.style.top = `${rect.top + rect.height - 30 + scrollTop}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets status icon style and title.
|
||||
*
|
||||
* @param {String} status CSS class name to set status icon to
|
||||
* @param {String} title Title of the status icon
|
||||
*/
|
||||
updateStatusIcon(status, title) {
|
||||
const statuses = [
|
||||
'bes-status-loading',
|
||||
'bes-status-success',
|
||||
'bes-status-mistakes',
|
||||
'bes-status-error'
|
||||
]
|
||||
statuses.forEach(statusClass => {
|
||||
this.statusIcon.classList.remove(statusClass)
|
||||
})
|
||||
this.statusIcon.classList.add(status)
|
||||
this.statusDiv.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
*
|
||||
* DOM grammar-checking service
|
||||
*
|
||||
*************************************************************************/
|
||||
class BesDOMService extends BesService {
|
||||
constructor(hostElement) {
|
||||
super(hostElement)
|
||||
this.results = [] // Results of grammar-checking, one per each block of text
|
||||
this.handleBeforeInput = event => this.onBeforeInput(event)
|
||||
this.hostElement.addEventListener('beforeinput', this.handleBeforeInput)
|
||||
this.handleInput = () => this.onInput()
|
||||
this.hostElement.addEventListener('input', this.handleInput)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 BesDOMService(hostElement)
|
||||
service.proofAll()
|
||||
return service
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters grammar checking service.
|
||||
*/
|
||||
unregister() {
|
||||
this.hostElement.removeEventListener('input', this.handleInput)
|
||||
this.hostElement.removeEventListener('beforeinput', this.handleBeforeInput)
|
||||
super.unregister()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report scrolling
|
||||
*/
|
||||
onScroll() {
|
||||
super.onScroll()
|
||||
|
||||
// Markup is in a "position:absolute" <div> element requiring repositioning when scrolling host element or window.
|
||||
// It is defered to reduce stress in a flood of scroll events.
|
||||
// TODO: We could technically just update scrollTop and scrollLeft of all markup rects for even better performance?
|
||||
if (this.scrollTimeout) clearTimeout(this.scrollTimeout)
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.repositionAllMarkup()
|
||||
delete this.scrollTimeout
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report resizing
|
||||
*/
|
||||
onResize() {
|
||||
super.onResize()
|
||||
|
||||
// When window is resized, host element might resize too.
|
||||
// This may cause text to re-wrap requiring markup re
|
||||
this.repositionAllMarkup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Abort running grammar checking ASAP.
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.abortController.abort()
|
||||
|
||||
// Remove markup of all blocks of text that are about to change.
|
||||
let blockElements = new Set()
|
||||
event.getTargetRanges().forEach(range => {
|
||||
BesDOMService.getNodesInRange(range).forEach(el => {
|
||||
if (
|
||||
el === this.hostElement ||
|
||||
Array.from(this.hostElement.childNodes).includes(el)
|
||||
)
|
||||
blockElements.add(this.getBlockParent(el))
|
||||
})
|
||||
})
|
||||
blockElements.forEach(block => this.clearProofing(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to report the text has changed
|
||||
*/
|
||||
onInput() {
|
||||
// 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.abortController = new AbortController()
|
||||
this.proofAll()
|
||||
delete this.timer
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively grammar-(re)checks our host DOM tree.
|
||||
*/
|
||||
proofAll() {
|
||||
this.onStartProofing()
|
||||
this.proofNode(this.hostElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively grammar-checks a DOM node.
|
||||
*
|
||||
* @param {Node} node DOM root node to check
|
||||
* @returns {Array} Markup of text to check using BesStr
|
||||
*/
|
||||
proofNode(node) {
|
||||
switch (node.nodeType) {
|
||||
case Node.TEXT_NODE:
|
||||
return [{ text: node.textContent, node: node, markup: false }]
|
||||
|
||||
case Node.ELEMENT_NODE:
|
||||
if (this.isBlockElement(node)) {
|
||||
// Block elements are grammar-checked independently.
|
||||
if (this.isProofed(node))
|
||||
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
|
||||
|
||||
let data = []
|
||||
for (const el2 of node.childNodes)
|
||||
data = data.concat(this.proofNode(el2))
|
||||
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
|
||||
// Block element contains some text.
|
||||
this.onProofing()
|
||||
// Save the abort signal reference. It will change on grammar-check re-start.
|
||||
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: data.map(x =>
|
||||
x.markup ? { markup: x.text } : { text: x.text }
|
||||
)
|
||||
}),
|
||||
language: node.lang ? node.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 range = document.createRange()
|
||||
|
||||
// Locate start of the grammar mistake.
|
||||
for (
|
||||
let idx = 0, startingOffset = 0;
|
||||
;
|
||||
startingOffset += data[idx++].text.length
|
||||
) {
|
||||
if (
|
||||
!data[idx].markup &&
|
||||
/*startingOffset <= match.offset &&*/ match.offset <
|
||||
startingOffset + data[idx].text.length
|
||||
) {
|
||||
range.setStart(
|
||||
data[idx].node,
|
||||
match.offset - startingOffset
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Locate end of the grammar mistake.
|
||||
let endOffset = match.offset + match.length
|
||||
for (
|
||||
let idx = 0, startingOffset = 0;
|
||||
;
|
||||
startingOffset += data[idx++].text.length
|
||||
) {
|
||||
if (
|
||||
!data[idx].markup &&
|
||||
/*startingOffset <= endOffset ← not needed, kept for reference &&*/ endOffset <=
|
||||
startingOffset + data[idx].text.length
|
||||
) {
|
||||
range.setEnd(data[idx].node, endOffset - startingOffset)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const { clientRects, highlights } =
|
||||
this.addMistakeMarkup(range)
|
||||
matches.push({
|
||||
rects: clientRects,
|
||||
highlights: highlights,
|
||||
range: range,
|
||||
match: match
|
||||
})
|
||||
})
|
||||
this.markProofed(node, matches)
|
||||
this.onProofingProgress(matches.length)
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') return
|
||||
this.onFailedProofingResult(error)
|
||||
})
|
||||
}
|
||||
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
|
||||
} else {
|
||||
// Inline elements require no markup. Keep plain text only.
|
||||
let data = []
|
||||
for (const el2 of node.childNodes)
|
||||
data = data.concat(this.proofNode(el2))
|
||||
return data
|
||||
}
|
||||
|
||||
default:
|
||||
return [{ text: `<?${node.nodeType}>`, node: node, markup: true }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if given block element has already been grammar-checked.
|
||||
*
|
||||
* @param {Element} el DOM element to check
|
||||
* @returns {Boolean} true if the element has already been grammar-checked; false otherwise.
|
||||
*/
|
||||
isProofed(el) {
|
||||
return this.results?.find(result => result.element === el) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks given block element as grammar-checked.
|
||||
*
|
||||
* @param {Element} el DOM element that was checked
|
||||
* @param {Array} matches Grammar mistakes
|
||||
*/
|
||||
markProofed(el, matches) {
|
||||
this.clearProofing(el)
|
||||
this.results.push({
|
||||
element: el,
|
||||
matches: matches
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes given block element from this.results array and clearing its markup.
|
||||
*
|
||||
* @param {Element} el DOM element for removal
|
||||
*/
|
||||
clearProofing(el) {
|
||||
this.clearMarkup(el)
|
||||
this.results = this.results?.filter(result => result.element !== el)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates grammar mistake markup in DOM.
|
||||
*
|
||||
* @param {Range} range Grammar mistake range
|
||||
* @returns {Object} Client rectangles and grammar mistake highlight elements
|
||||
*/
|
||||
addMistakeMarkup(range) {
|
||||
const clientRects = range.getClientRects()
|
||||
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
|
||||
let highlights = []
|
||||
for (let i = 0, n = clientRects.length; i < n; ++i) {
|
||||
const rect = clientRects[i]
|
||||
const highlight = document.createElement('div')
|
||||
highlight.classList.add('bes-typo-mistake')
|
||||
const topPosition = rect.top - scrollPanelRect.top
|
||||
const leftPosition = rect.left - scrollPanelRect.left
|
||||
highlight.style.left = `${leftPosition}px`
|
||||
highlight.style.top = `${topPosition}px`
|
||||
highlight.style.width = `${rect.width}px`
|
||||
highlight.style.height = `${rect.height}px`
|
||||
this.scrollPanel.appendChild(highlight)
|
||||
highlights.push(highlight)
|
||||
}
|
||||
return { clientRects, highlights }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates grammar mistake markup positions.
|
||||
*
|
||||
* @param {Element} el DOM element we want to update markup for
|
||||
*
|
||||
* TODO: Unused
|
||||
*/
|
||||
repositionMarkup(el) {
|
||||
let result = this.results?.find(result => result.element === el)
|
||||
if (!result) return
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 block element grammar mistake markup.
|
||||
*
|
||||
* @param {Element} el DOM element we want to clean markup for
|
||||
*/
|
||||
clearMarkup(el) {
|
||||
let result = this.results?.find(result => result.element === el)
|
||||
if (!result) return
|
||||
result.matches.forEach(match => {
|
||||
if (match.highlights) {
|
||||
match.highlights.forEach(h => h.remove())
|
||||
delete match.highlights
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all grammar mistake markup.
|
||||
*
|
||||
* TODO: Unused
|
||||
*/
|
||||
clearAllMarkup() {
|
||||
this.results.forEach(result => {
|
||||
result.matches.forEach(match => {
|
||||
if (match.highlights) {
|
||||
match.highlights.forEach(h => h.remove())
|
||||
delete match.highlights
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if given element is block element.
|
||||
*
|
||||
* @param {Element} el DOM element
|
||||
* @returns false if CSS display property is inline; true otherwise.
|
||||
*/
|
||||
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
|
||||
switch (
|
||||
document.defaultView
|
||||
.getComputedStyle(el, null)
|
||||
.getPropertyValue('display')
|
||||
.toLowerCase()
|
||||
) {
|
||||
case 'inline':
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first block parent element of a node.
|
||||
*
|
||||
* @param {Node} node DOM node
|
||||
* @returns {Element} Innermost block element containing given node
|
||||
*/
|
||||
getBlockParent(node) {
|
||||
for (; node; node = node.parentNode) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && this.isBlockElement(node))
|
||||
return node
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns next node in the DOM text flow.
|
||||
*
|
||||
* @param {Node} node DOM node
|
||||
* @returns {Node} Next node
|
||||
*/
|
||||
static getNextNode(node) {
|
||||
if (node.firstChild) return node.firstChild
|
||||
while (node) {
|
||||
if (node.nextSibling) return node.nextSibling
|
||||
node = node.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all ancestors of a node.
|
||||
*
|
||||
* @param {Node} node DOM node
|
||||
* @returns {Array} Array of all ancestors (document...node) describing DOM path
|
||||
*/
|
||||
static getParents(node) {
|
||||
let parents = []
|
||||
do {
|
||||
parents.push(node)
|
||||
node = node.parentNode
|
||||
} while (node)
|
||||
return parents.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all nodes marked by a range.
|
||||
*
|
||||
* @param {Range} range DOM range
|
||||
* @returns {Array} Array of nodes
|
||||
*/
|
||||
static getNodesInRange(range) {
|
||||
let start = range.startContainer
|
||||
let end = range.endContainer
|
||||
|
||||
// Common ancestor is the last element common to both elements' DOM path.
|
||||
let startAncestors = BesDOMService.getParents(start)
|
||||
let endAncestors = BesDOMService.getParents(end)
|
||||
let commonAncestor = null
|
||||
for (
|
||||
let i = 0;
|
||||
i < startAncestors.length &&
|
||||
i < endAncestors.length &&
|
||||
startAncestors[i] === endAncestors[i];
|
||||
++i
|
||||
)
|
||||
commonAncestor = startAncestors[i]
|
||||
|
||||
let nodes = []
|
||||
let node
|
||||
|
||||
// Walk parent nodes from start to common ancestor.
|
||||
for (node = start.parentNode; node; node = node.parentNode) {
|
||||
nodes.push(node)
|
||||
if (node == commonAncestor) break
|
||||
}
|
||||
nodes.reverse()
|
||||
|
||||
// Walk children and siblings from start until end node is found.
|
||||
for (node = start; node; node = BesDOMService.getNextNode(node)) {
|
||||
nodes.push(node)
|
||||
if (node == end) break
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-register all elements with bes-service class.
|
||||
window.addEventListener('load', () => {
|
||||
document.querySelectorAll('.bes-service').forEach(hostElement => {
|
||||
if (hostElement.tagName === 'TEXTAREA') BesTAService.register(hostElement)
|
||||
else BesDOMService.register(hostElement)
|
||||
})
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user