Proposed solution was not complete, match.highlights is not an array without a good reason, so checking only match.highlights[0] is never a good idea. Should a grammar mistake wrap across more lines of text, match.highlights gets one element per each part of the _same_ grammar that spans one line of text. By not checking them all, popup failed to appear when clicking on a grammar mistake following on the next line of text. Furthermore, the solution only addressed BesTreeService.onClick(), while the same logic should be applied to BesPlainTextService.onClick() too.
1732 lines
53 KiB
JavaScript
1732 lines
53 KiB
JavaScript
// TODO: Implement <textarea> class
|
|
// TODO: Research if there is a way to disable languageTool & Grammarly extensions in CKEditor
|
|
|
|
/**
|
|
* Collection of all grammar checking services in the document
|
|
*
|
|
* We dispatch relevant window messages to all services registered here.
|
|
*/
|
|
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.
|
|
window.addEventListener('resize', () =>
|
|
besServices.forEach(service => service.onReposition())
|
|
)
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Base class for all grammar-checking services
|
|
*
|
|
*************************************************************************/
|
|
class BesService {
|
|
constructor(hostElement) {
|
|
this.hostElement = hostElement
|
|
this.results = [] // Results of grammar-checking, one per each block/paragraph of text
|
|
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 is coppied from https://stackoverflow.com/questions/37444906/how-to-stop-extensions-add-ons-like-grammarly-on-contenteditable-editors
|
|
this.originalDataGramm = this.hostElement.getAttribute('data-gramm')
|
|
this.originalDataGrammEditor =
|
|
this.hostElement.getAttribute('data-gramm_editor')
|
|
this.originalEnableGrammarly = this.hostElement.getAttribute(
|
|
'data-enable-grammarly'
|
|
)
|
|
this.hostElement.setAttribute('data-gramm', 'false')
|
|
this.hostElement.setAttribute('data-gramm_editor', 'false')
|
|
this.hostElement.setAttribute('data-enable-grammarly', 'false')
|
|
|
|
this.onScroll = this.onScroll.bind(this)
|
|
this.hostElement.addEventListener('scroll', this.onScroll)
|
|
|
|
this.resizeObserver = new ResizeObserver(this.onResize.bind(this))
|
|
this.resizeObserver.observe(this.hostElement)
|
|
|
|
besServices.push(this)
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
if (this.abortController) this.abortController.abort()
|
|
besServices = besServices.filter(item => item !== this)
|
|
this.resizeObserver.disconnect()
|
|
this.hostElement.removeEventListener('scroll', this.onScroll)
|
|
this.hostElement.setAttribute('spellcheck', this.originalSpellcheck)
|
|
this.hostElement.setAttribute('data-gramm', this.originalDataGramm)
|
|
this.hostElement.setAttribute(
|
|
'data-gramm_editor',
|
|
this.originalDataGrammEditor
|
|
)
|
|
this.hostElement.spellcheck = this.originalSpellcheck
|
|
this.clearCorrectionPanel()
|
|
}
|
|
|
|
/**
|
|
* Called initially when grammar-checking run is started
|
|
*/
|
|
onStartProofing() {
|
|
this.proofingCount = 1 // Ref-count how many grammar-checking blocks of text are active
|
|
this.proofingError = null // The first non-fatal error in grammar-checking run
|
|
this.proofingMatches = 0 // Number of grammar mistakes detected in entire grammar-checking run
|
|
this.updateStatusIcon('bes-status-loading', 'Besana preverja pravopis.')
|
|
this.abortController = new AbortController()
|
|
}
|
|
|
|
/**
|
|
* Called when grammar-checking starts proofing each block of text (typically paragraph)
|
|
*/
|
|
onProofing() {
|
|
this.proofingCount++
|
|
}
|
|
|
|
/**
|
|
* Called when grammar-checking failed (as 500 Internal server error, timeout, etc.)
|
|
*
|
|
* This error is fatal and proofing will not continue.
|
|
*
|
|
* @param {Response} response HTTP response
|
|
*/
|
|
onFailedProofing(response) {
|
|
delete this.abortController
|
|
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) {
|
|
if (error !== 'AbortError' && !this.proofingError)
|
|
this.proofingError = error
|
|
if (--this.proofingCount <= 0) this.onEndProofing()
|
|
}
|
|
|
|
/**
|
|
* 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.onEndProofing()
|
|
}
|
|
|
|
/**
|
|
* Called when grammar-checking run is ended
|
|
*/
|
|
onEndProofing() {
|
|
delete this.abortController
|
|
if (this.proofingError) {
|
|
this.updateStatusIcon(
|
|
'bes-status-error',
|
|
`Pri obdelavi odgovora pravopisnega strežnika je prišlo do napake: ${this.proofingError}`
|
|
)
|
|
} else 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`
|
|
|
|
// Why do we need to set left to -scrollLeft? It destroys the position of highlights
|
|
this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}px`
|
|
}
|
|
|
|
/**
|
|
* Called to report repositioning
|
|
*/
|
|
onReposition() {
|
|
this.setCorrectionPanelSize()
|
|
this.setStatusDivPosition()
|
|
}
|
|
|
|
/**
|
|
* Called to report resizing
|
|
*/
|
|
onResize() {
|
|
this.setCorrectionPanelSize()
|
|
this.setStatusDivPosition()
|
|
|
|
// When window is resized, host element might resize too.
|
|
// This may cause text to re-wrap requiring markup repositioning.
|
|
this.repositionAllMarkup()
|
|
}
|
|
|
|
/**
|
|
* Creates grammar mistake markup in DOM.
|
|
*
|
|
* @param {Range} range Grammar mistake range
|
|
* @returns {Array} Grammar mistake highlight elements
|
|
*/
|
|
addMistakeMarkup(range) {
|
|
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
|
|
let highlights = []
|
|
for (let rect of range.getClientRects()) {
|
|
const highlight = document.createElement('div')
|
|
highlight.classList.add('bes-typo-mistake')
|
|
highlight.style.left = `${rect.left - scrollPanelRect.left}px`
|
|
highlight.style.top = `${rect.top - scrollPanelRect.top}px`
|
|
highlight.style.width = `${rect.width}px`
|
|
highlight.style.height = `${rect.height}px`
|
|
this.scrollPanel.appendChild(highlight)
|
|
highlights.push(highlight)
|
|
}
|
|
return highlights
|
|
}
|
|
|
|
/**
|
|
* Tests if given coordinate is inside of a rectangle.
|
|
*
|
|
* @param {Number} x X coordinate
|
|
* @param {Number} y Y coordinate
|
|
* @param {DOMRect} rect Rectangle
|
|
* @returns
|
|
*/
|
|
static isPointInRect(x, y, rect) {
|
|
return rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
// document.body.appendChild(statusPopup)
|
|
// this.statusDiv.addEventListener('click', e =>
|
|
// statusPopup.show(e.clientX, e.clientY, this)
|
|
// )
|
|
}
|
|
|
|
/**
|
|
* 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.paddingLeft) +
|
|
parseFloat(styles.marginLeft) +
|
|
parseFloat(styles.width) +
|
|
parseFloat(styles.marginRight) +
|
|
parseFloat(styles.paddingRight)
|
|
const totalHeight =
|
|
parseFloat(styles.paddingTop) +
|
|
parseFloat(styles.marginTop) +
|
|
parseFloat(styles.height) +
|
|
parseFloat(styles.marginBottom) +
|
|
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`
|
|
}
|
|
|
|
/**
|
|
* Displays correction panel.
|
|
*
|
|
* @param {*} el Block element/paragraph containing grammar checking rule match
|
|
* @param {*} match Grammar checking rule match
|
|
* @param {PointerEvent} source Click event source
|
|
*/
|
|
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.show(source.clientX, source.clientY)
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
this.clearProofing(el)
|
|
match.range.deleteContents()
|
|
match.range.insertNode(document.createTextNode(replacement))
|
|
this.proofAll()
|
|
}
|
|
|
|
/**
|
|
* Repositions status DIV element.
|
|
*/
|
|
setStatusDivPosition() {
|
|
const rect = this.hostElement.getBoundingClientRect()
|
|
const scrollLeft = window.scrollX || document.documentElement.scrollLeft
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
|
this.statusDiv.style.left = `${rect.right - 40 + scrollLeft}px`
|
|
this.statusDiv.style.top = `${rect.bottom - 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
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Grammar-checking service base class for tree-organized editors
|
|
*
|
|
*************************************************************************/
|
|
class BesTreeService extends BesService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.onClick = this.onClick.bind(this)
|
|
this.hostElement.addEventListener('click', this.onClick)
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
this.hostElement.removeEventListener('click', this.onClick)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Recursively grammar-(re)checks our host DOM tree.
|
|
*/
|
|
proofAll() {
|
|
this.onStartProofing()
|
|
this.proofNode(this.hostElement, this.abortController)
|
|
this.onProofingProgress(0)
|
|
}
|
|
|
|
/**
|
|
* Recursively grammar-checks a DOM node.
|
|
*
|
|
* @param {Node} node DOM root node to check
|
|
* @param {AbortController} abortController Abort controller to cancel grammar-checking
|
|
* @returns {Array} Markup of text to check using BesStr
|
|
*/
|
|
proofNode(node, abortController) {
|
|
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.
|
|
let result = this.getProofing(node)
|
|
if (result) {
|
|
this.onProofing()
|
|
this.onProofingProgress(result.matches.length)
|
|
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
|
|
}
|
|
|
|
let data = []
|
|
for (const el2 of node.childNodes)
|
|
data = data.concat(this.proofNode(el2, abortController))
|
|
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
|
|
// Block element contains some text.
|
|
this.onProofing()
|
|
const signal = 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
|
|
}
|
|
}
|
|
|
|
matches.push({
|
|
highlights: this.addMistakeMarkup(range),
|
|
range: range,
|
|
match: match
|
|
})
|
|
})
|
|
this.markProofed(node, matches)
|
|
this.onProofingProgress(matches.length)
|
|
})
|
|
.catch(error => 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, abortController))
|
|
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 {*} Result of grammar check if the element has already been grammar-checked; undefined otherwise.
|
|
*/
|
|
getProofing(el) {
|
|
return this.results.find(result =>
|
|
BesTreeService.isSameParagraph(result.element, el)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Marks given block element as grammar-checked.
|
|
*
|
|
* @param {Element} el DOM element that was checked
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(el, matches) {
|
|
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 => !BesTreeService.isSameParagraph(result.element, el)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Updates all grammar mistake markup positions.
|
|
*/
|
|
repositionAllMarkup() {
|
|
this.results.forEach(result => {
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) match.highlights.forEach(h => h.remove())
|
|
match.highlights = this.addMistakeMarkup(match.range)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Clears given block element grammar mistake markup.
|
|
*
|
|
* @param {Element} el DOM element we want to clean markup for
|
|
*/
|
|
clearMarkup(el) {
|
|
this.results
|
|
.filter(result => BesTreeService.isSameParagraph(result.element, el))
|
|
.forEach(result =>
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) {
|
|
match.highlights.forEach(h => h.remove())
|
|
delete match.highlights
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given block elements represent the same block of text
|
|
*
|
|
* @param {Element} el1 DOM element
|
|
* @param {Element} el2 DOM element
|
|
* @returns {Boolean} true if block elements are the same
|
|
*/
|
|
static isSameParagraph(el1, el2) {
|
|
return el1 === el2
|
|
}
|
|
|
|
/**
|
|
* 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 = BesTreeService.getParents(start)
|
|
let endAncestors = BesTreeService.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 = BesTreeService.getNextNode(node)) {
|
|
nodes.push(node)
|
|
if (node === end) break
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
/**
|
|
* Called to report mouse click
|
|
*
|
|
* Displays or hides grammar mistake popup.
|
|
*
|
|
* @param {PointerEvent} event The event produced by a pointer such as the geometry of the contact point, the device type that generated the event, the amount of pressure that was applied on the contact surface, etc.
|
|
*/
|
|
onClick(event) {
|
|
const source = event?.detail !== 1 ? event?.detail : event
|
|
const el = this.getBlockParent(source.targetElement || source.target)
|
|
if (!el) return
|
|
|
|
for (let result of this.results) {
|
|
for (let m of result.matches) {
|
|
for (let h of m.highlights) {
|
|
if (
|
|
BesService.isPointInRect(
|
|
source.clientX,
|
|
source.clientY,
|
|
h.getBoundingClientRect()
|
|
)
|
|
) {
|
|
this.popupCorrectionPanel(el, m, source)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BesPopup.hide()
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* DOM grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesDOMService extends BesTreeService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.onBeforeInput = this.onBeforeInput.bind(this)
|
|
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
|
|
this.onInput = this.onInput.bind(this)
|
|
this.hostElement.addEventListener('input', this.onInput)
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service.
|
|
*
|
|
* @param {Element} hostElement DOM element to register grammar checking service for
|
|
* @returns {BesDOMService} 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.onInput)
|
|
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
|
|
if (this.timer) clearTimeout(this.timer)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Called to report the text is about to change
|
|
*
|
|
* Marks section of the text that is about to change as not-yet-grammar-checked.
|
|
*
|
|
* @param {InputEvent} event The event notifying the user of editable content changes
|
|
*/
|
|
onBeforeInput(event) {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
|
|
// 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 =>
|
|
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.proofAll()
|
|
delete this.timer
|
|
}, 1000)
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* CKEditor grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesCKService extends BesTreeService {
|
|
constructor(hostElement, ckEditorInstance) {
|
|
super(hostElement)
|
|
this.ckEditorInstance = ckEditorInstance
|
|
this.disableCKEditorSpellcheck()
|
|
this.onChangeData = this.onChangeData.bind(this)
|
|
this.ckEditorInstance.model.document.on('change:data', this.onChangeData)
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service.
|
|
*
|
|
* @param {Element} hostElement DOM element to register grammar checking service for
|
|
* @param {CKEditorInstance} ckEditorInstance Enable CKEditor tweaks
|
|
* @returns {BesCKService} Grammar checking service instance
|
|
*/
|
|
static register(hostElement, ckEditorInstance) {
|
|
let service = new BesCKService(hostElement, ckEditorInstance)
|
|
service.proofAll()
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
this.ckEditorInstance.model.document.off('change:data', this.onChangeData)
|
|
this.restoreCKEditorSpellcheck()
|
|
if (this.timer) clearTimeout(this.timer)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Called to report the text has changed
|
|
*/
|
|
onChangeData() {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
const differ = this.ckEditorInstance.model.document.differ
|
|
for (const entry of Array.from(differ.getChanges())) {
|
|
let element =
|
|
entry.type === 'attribute'
|
|
? entry.range.start.parent
|
|
: entry._element || entry.position.parent
|
|
const domElement = this.getDomElement(element)
|
|
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.
|
|
setTimeout(() => {
|
|
// 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)
|
|
}, 0)
|
|
}
|
|
|
|
/**
|
|
* This function converts a CKEditor element to a DOM element.
|
|
*
|
|
* @param {CKEditor} element
|
|
* @returns domElement
|
|
*/
|
|
getDomElement(element) {
|
|
const viewElement =
|
|
this.ckEditorInstance.editing.mapper.toViewElement(element)
|
|
const domElement =
|
|
this.ckEditorInstance.editing.view.domConverter.mapViewToDom(viewElement)
|
|
return domElement
|
|
}
|
|
|
|
/**
|
|
* Disables the CKEditor spellcheck.
|
|
*/
|
|
disableCKEditorSpellcheck() {
|
|
this.ckEditorInstance.editing.view.change(writer => {
|
|
const root = this.ckEditorInstance.editing.view.document.getRoot()
|
|
this.originalCKSpellcheck = root.getAttribute('spellcheck')
|
|
writer.setAttribute('spellcheck', false, root)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Restores the CKEditor spellcheck.
|
|
*/
|
|
restoreCKEditorSpellcheck() {
|
|
this.ckEditorInstance.editing.view.change(writer => {
|
|
writer.setAttribute(
|
|
'spellcheck',
|
|
this.originalCKSpellcheck,
|
|
this.ckEditorInstance.editing.view.document.getRoot()
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
this.clearProofing(el)
|
|
const viewRange =
|
|
this.ckEditorInstance.editing.view.domConverter.domRangeToView(
|
|
match.range
|
|
)
|
|
const modelRange =
|
|
this.ckEditorInstance.editing.mapper.toModelRange(viewRange)
|
|
this.ckEditorInstance.model.change(writer => {
|
|
const attributes =
|
|
this.ckEditorInstance.model.document.selection.getAttributes()
|
|
writer.remove(modelRange)
|
|
writer.insertText(replacement, attributes, modelRange.start)
|
|
})
|
|
this.proofAll()
|
|
}
|
|
|
|
/**
|
|
* Repositions status DIV element.
|
|
*/
|
|
setStatusDivPosition() {
|
|
this.statusDiv.style.right = `10px`
|
|
this.statusDiv.style.bottom = `10px`
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Plain-text grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesPlainTextService extends BesService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.reEOP = /(\r?\n){2,}/g
|
|
this.onBeforeInput = this.onBeforeInput.bind(this)
|
|
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
|
|
this.onInput = this.onInput.bind(this)
|
|
this.hostElement.addEventListener('input', this.onInput)
|
|
this.onClick = this.onClick.bind(this)
|
|
this.hostElement.addEventListener('click', this.onClick)
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service.
|
|
*
|
|
* @param {Element} hostElement DOM element to register grammar checking service for
|
|
* @returns {BesPlainTextService} Grammar checking service instance
|
|
*/
|
|
static register(hostElement) {
|
|
let service = new BesPlainTextService(hostElement)
|
|
service.proofAll()
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
this.hostElement.removeEventListener('click', this.onClick)
|
|
this.hostElement.removeEventListener('input', this.onInput)
|
|
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
|
|
if (this.timer) clearTimeout(this.timer)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Called to report the text is about to change
|
|
*
|
|
* Marks section of the text that is about to change as not-yet-grammar-checked.
|
|
*
|
|
* @param {InputEvent} event The event notifying the user of editable content changes
|
|
*/
|
|
onBeforeInput(event) {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
|
|
// Firefox does not support contenteditable="plaintext-only" at all, Chrome/Edge's InputEvent.getTargetRanges() return
|
|
// a useless empty array for contenteditable="plaintext-only". This makes tracking location of changes a pain.
|
|
// We need to save the text on beforeinput and compare it to the text on input event to do the this.clearProofing().
|
|
let { text } = this.getTextFromNodes()
|
|
this.textBeforeChange = text
|
|
// Continues in onInput...
|
|
}
|
|
|
|
/**
|
|
* Called to report the text has changed
|
|
*
|
|
* @param {InputEvent} event The event notifying the user of editable content changes
|
|
*/
|
|
onInput(event) {
|
|
// ...Continued from onBeforeInput: Remove markup of all paragraphs that changed.
|
|
// Use the offsets before change, as paragraph changes have not been updated yet.
|
|
let paragraphRanges = new Set()
|
|
this.getTargetRanges().forEach(range => {
|
|
this.results.forEach(result => {
|
|
if (BesPlainTextService.isOverlappingParagraph(result.range, range))
|
|
paragraphRanges.add(result.range)
|
|
})
|
|
})
|
|
paragraphRanges.forEach(range => this.clearProofing(range))
|
|
delete this.textBeforeChange
|
|
|
|
// Now that the text is done changing, we can correctly calculate markup position.
|
|
this.repositionAllMarkup()
|
|
|
|
// Defer grammar-checking to reduce stress on grammar-checking server.
|
|
this.timer = setTimeout(() => {
|
|
this.proofAll()
|
|
delete this.timer
|
|
}, 1000)
|
|
}
|
|
|
|
/**
|
|
* Returns an array of ranges that will be affected by a change to the DOM.
|
|
*
|
|
* This method attempts to fix the Chrome/Edge shortcoming in InputEvent.getTargetRanges()
|
|
* failing to return meaningful range array on beforeinput event.
|
|
*/
|
|
getTargetRanges() {
|
|
let textA = this.textBeforeChange
|
|
let { text, nodes } = this.getTextFromNodes()
|
|
let textB = text
|
|
let nodesB = nodes
|
|
|
|
let ranges = []
|
|
for (let i = 0, j = 0, nodeIdxB = 0; ; ) {
|
|
if (i >= textA.length && j >= textB.length) break
|
|
if (i >= textA.length) {
|
|
// Some text was appended.
|
|
let range = document.createRange()
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
|
ranges.push(range)
|
|
break
|
|
}
|
|
if (j >= textB.length) {
|
|
// Some text was deleted at the end.
|
|
let range = document.createRange()
|
|
// range.setStartAfter(nodesB[nodesB.length - 1].node) // This puts range start at the </div>:1???
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
|
ranges.push(range)
|
|
break
|
|
}
|
|
if (textA.charAt(i) != textB.charAt(j)) {
|
|
let range = document.createRange()
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
let a = textA.indexOf(textB.substr(j, 3), i)
|
|
if (a < 0) a = textA.length
|
|
let b = textB.indexOf(textA.substr(i, 3), j)
|
|
if (b < 0) b = textB.length
|
|
if (3 * (a - i) <= b - j) {
|
|
// Suppose some text was deleted.
|
|
i = a
|
|
range.setEnd(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
} else if (3 * (b - j) <= a - i) {
|
|
// Suppose some text was inserted.
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
|
nodeIdxB++
|
|
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
|
nodeIdxB++
|
|
} else {
|
|
// Suppose some text was replaced.
|
|
i = a
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
|
nodeIdxB++
|
|
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
|
nodeIdxB++
|
|
}
|
|
ranges.push(range)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
|
continue
|
|
}
|
|
i++
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
|
j++
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
/**
|
|
* Grammar-(re)checks the host element.
|
|
*/
|
|
proofAll() {
|
|
this.onStartProofing()
|
|
|
|
let { text, nodes } = this.getTextFromNodes()
|
|
let nextParagraphRange = document.createRange()
|
|
nextParagraphRange.setStartBefore(nodes[0].node)
|
|
for (
|
|
let start = 0, eop, end, nodeIdx = 0;
|
|
start < text.length && nodeIdx < nodes.length;
|
|
start = end
|
|
) {
|
|
this.reEOP.lastIndex = start
|
|
let match = this.reEOP.exec(text)
|
|
if (match) {
|
|
eop = match.index
|
|
end = this.reEOP.lastIndex
|
|
} else {
|
|
eop = end = text.length
|
|
}
|
|
|
|
let paragraphRange = nextParagraphRange
|
|
nextParagraphRange = document.createRange()
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < eop) nodeIdx++
|
|
nextParagraphRange.setStart(
|
|
nodes[nodeIdx].node,
|
|
eop - nodes[nodeIdx].start
|
|
)
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < end) nodeIdx++
|
|
paragraphRange.setEnd(nodes[nodeIdx].node, end - nodes[nodeIdx].start)
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end <= end) nodeIdx++
|
|
|
|
let result = this.getProofing(paragraphRange)
|
|
if (result) {
|
|
this.onProofing()
|
|
this.onProofingProgress(result.matches.length)
|
|
continue
|
|
}
|
|
let paragraphText = text.substring(start, end)
|
|
if (!/^\s*$/.test(paragraphText)) {
|
|
// Paragraph contains some text.
|
|
this.onProofing()
|
|
const signal = this.abortController.signal
|
|
fetch(
|
|
new Request(besUrl + '/check', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({
|
|
format: 'plain',
|
|
data: JSON.stringify({
|
|
annotation: [
|
|
{
|
|
text: paragraphText
|
|
}
|
|
]
|
|
}),
|
|
language: this.hostElement.lang ? this.hostElement.lang : 'sl',
|
|
level: 'picky'
|
|
})
|
|
}),
|
|
{ signal }
|
|
)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
this.onFailedProofing(response)
|
|
throw new Error('Unexpected BesStr server response')
|
|
}
|
|
return response.json()
|
|
})
|
|
.then(responseData => {
|
|
let matches = []
|
|
responseData.matches.forEach(match => {
|
|
let matchRange = document.createRange()
|
|
let nodeIdx = 0,
|
|
matchStart = start + match.offset
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchStart)
|
|
nodeIdx++
|
|
matchRange.setStart(
|
|
nodes[nodeIdx].node,
|
|
matchStart - nodes[nodeIdx].start
|
|
)
|
|
let matchEnd = matchStart + match.length
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchEnd)
|
|
nodeIdx++
|
|
matchRange.setEnd(
|
|
nodes[nodeIdx].node,
|
|
matchEnd - nodes[nodeIdx].start
|
|
)
|
|
matches.push({
|
|
highlights: this.addMistakeMarkup(matchRange),
|
|
range: matchRange,
|
|
match: match
|
|
})
|
|
})
|
|
this.markProofed(paragraphRange, matches)
|
|
this.onProofingProgress(matches.length)
|
|
})
|
|
.catch(error => this.onFailedProofingResult(error))
|
|
}
|
|
}
|
|
|
|
this.onProofingProgress(0)
|
|
}
|
|
|
|
/**
|
|
* Concatenates child text nodes
|
|
*
|
|
* @returns {Object} Concatenated text and array of nodes
|
|
*/
|
|
getTextFromNodes() {
|
|
let nodes = []
|
|
let text = ''
|
|
for (
|
|
let node = this.hostElement.childNodes[0];
|
|
node;
|
|
node = node.nextSibling
|
|
) {
|
|
nodes.push({
|
|
node: node,
|
|
start: text.length,
|
|
end: text.length + node.data.length
|
|
})
|
|
text += node.data
|
|
}
|
|
return { text, nodes }
|
|
}
|
|
|
|
/**
|
|
* Tests if given paragraph has already been grammar-checked.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
* @returns {*} Result of grammar check if the element has already been grammar-checked; undefined otherwise.
|
|
*/
|
|
getProofing(range) {
|
|
return this.results.find(result =>
|
|
BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Marks given paragraph as grammar-checked.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(range, matches) {
|
|
this.results.push({
|
|
range: range,
|
|
matches: matches
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Removes given paragraph from this.results array and clearing its markup.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
*/
|
|
clearProofing(range) {
|
|
this.clearMarkup(range)
|
|
this.results = this.results.filter(
|
|
result => !BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Updates all grammar mistake markup positions.
|
|
*/
|
|
repositionAllMarkup() {
|
|
this.results.forEach(result => {
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) match.highlights.forEach(h => h.remove())
|
|
match.highlights = this.addMistakeMarkup(match.range)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Clears given paragraph grammar mistake markup.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
*/
|
|
clearMarkup(range) {
|
|
this.results
|
|
.filter(result =>
|
|
BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
.forEach(result =>
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) {
|
|
match.highlights.forEach(h => h.remove())
|
|
delete match.highlights
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given ranges represent the same paragraph of text
|
|
*
|
|
* @param {Range} range1 Range 1
|
|
* @param {Range} range2 Range 2
|
|
* @returns {Boolean} true if ranges overlap
|
|
*/
|
|
static isSameParagraph(range1, range2) {
|
|
return (
|
|
range1.compareBoundaryPoints(Range.START_TO_START, range2) == 0 &&
|
|
range1.compareBoundaryPoints(Range.END_TO_END, range2) == 0
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given ranges overlap or are adjacent to each other
|
|
*
|
|
* @param {Range} range1 Range 1
|
|
* @param {Range} range2 Range 2
|
|
* @returns {Boolean} true if ranges overlap
|
|
*/
|
|
static isOverlappingParagraph(range1, range2) {
|
|
const a = range1.compareBoundaryPoints(Range.END_TO_START, range2)
|
|
const b = range1.compareBoundaryPoints(Range.START_TO_END, range2)
|
|
return a == 0 || b == 0 || (a < 0 && b > 0)
|
|
}
|
|
|
|
/**
|
|
* Called to report mouse click
|
|
*
|
|
* Displays or hides grammar mistake popup.
|
|
*
|
|
* @param {PointerEvent} event The event produced by a pointer such as the geometry of the contact point, the device type that generated the event, the amount of pressure that was applied on the contact surface, etc.
|
|
*/
|
|
onClick(event) {
|
|
const source = event?.detail !== 1 ? event?.detail : event
|
|
const el = source.targetElement || source.target || this.hostElement
|
|
if (!el) return
|
|
|
|
for (let result of this.results) {
|
|
for (let m of result.matches) {
|
|
for (let h of m.highlights) {
|
|
if (
|
|
BesService.isPointInRect(
|
|
source.clientX,
|
|
source.clientY,
|
|
h.getBoundingClientRect()
|
|
)
|
|
) {
|
|
this.popupCorrectionPanel(result.range, m, source)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BesPopup.hide()
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Grammar mistake popup dialog
|
|
*
|
|
*************************************************************************/
|
|
class BesPopup extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
this.attachShadow({ mode: 'open' })
|
|
}
|
|
|
|
/**
|
|
* Called each time the element is added to the document
|
|
*/
|
|
connectedCallback() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: none;
|
|
}
|
|
:host(.show){
|
|
z-index: 10;
|
|
display: block;
|
|
}
|
|
.popup-text {
|
|
max-width: 160px;
|
|
color: black;
|
|
text-align: center;
|
|
padding: 8px 0;
|
|
z-index: 1;
|
|
}
|
|
.bes-popup-container {
|
|
visibility: hidden;
|
|
min-width: 200px;
|
|
max-width: 300px;
|
|
background-color: rgb(241, 243, 249);
|
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
|
border-radius: 5px;
|
|
padding: 8px;
|
|
z-index: 1;
|
|
}
|
|
.bes-toolbar {
|
|
display: flex;
|
|
justify-content: end;
|
|
padding: 5px 2px;
|
|
}
|
|
.bes-toolbar button {
|
|
margin-right: 2px;
|
|
}
|
|
.bes-popup-title {
|
|
flex-grow: 1;
|
|
cursor: grab;
|
|
}
|
|
.bes-text-div{
|
|
background-color: white;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
}
|
|
.bes-replacement-btn{
|
|
margin: 4px 1px;
|
|
padding: 4px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
background-color: #239aff;
|
|
color: white;
|
|
cursor: pointer;
|
|
}
|
|
.bes-replacement-btn:hover{
|
|
background-color: #1976f0;
|
|
}
|
|
:host(.show) .bes-popup-container {
|
|
visibility: visible;
|
|
animation: fadeIn 1s;
|
|
}
|
|
@keyframes fadeIn {
|
|
from {opacity: 0;}
|
|
to {opacity:1 ;}
|
|
}
|
|
</style>
|
|
<div class="bes-popup-container">
|
|
<div class="bes-toolbar">
|
|
<div class="bes-popup-title">Besana</div>
|
|
<button class="bes-close-btn" onclick="BesPopup.hide()">X</button>
|
|
</div>
|
|
<div class="bes-text-div">
|
|
<span class="popup-text">
|
|
</span>
|
|
<div class="bes-replacement-div">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
this.addEventListener('mousedown', this.onMouseDown)
|
|
}
|
|
|
|
/**
|
|
* Shows popup window.
|
|
*
|
|
* @param {Number} x X location hint
|
|
* @param {Number} y Y location hint
|
|
*/
|
|
show(x, y) {
|
|
this.style.position = 'fixed'
|
|
|
|
// 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`
|
|
this.classList.add('show')
|
|
|
|
if (x + this.offsetWidth <= window.innerWidth) {
|
|
this.style.left = `${x}px`
|
|
} else if (this.offsetWidth <= x) {
|
|
this.style.left = `${x - this.offsetWidth}px`
|
|
} else {
|
|
this.style.left = `${x - this.offsetWidth / 2}px`
|
|
}
|
|
|
|
if (y + 20 + this.offsetHeight <= window.innerHeight) {
|
|
this.style.top = `${y + 20}px`
|
|
} else if (this.offsetHeight <= y) {
|
|
this.style.top = `${y - this.offsetHeight}px`
|
|
} else {
|
|
this.style.top = `${y - this.offsetHeight / 2}px`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears all grammar mistake suggestions.
|
|
*/
|
|
clearReplacements() {
|
|
Array.from(
|
|
this.shadowRoot.querySelector('.bes-replacement-div')?.children
|
|
).forEach(replacement => replacement.remove())
|
|
}
|
|
|
|
/**
|
|
* Adds a grammar mistake suggestion.
|
|
*
|
|
* @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
|
|
*/
|
|
appendReplacements(el, match, service, allowReplacements) {
|
|
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
|
|
match.match.replacements.forEach(replacement => {
|
|
const replacementBtn = document.createElement('button')
|
|
replacementBtn.classList.add('bes-replacement-btn')
|
|
replacementBtn.textContent = replacement.value
|
|
replacementBtn.addEventListener('click', () => {
|
|
if (allowReplacements) {
|
|
service.replaceText(el, match, replacement.value)
|
|
BesPopup.hide()
|
|
}
|
|
})
|
|
replacementDiv.appendChild(replacementBtn)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Sets grammar mistake description
|
|
*
|
|
* @param {String} text
|
|
*/
|
|
changeMessage(text) {
|
|
this.clearReplacements()
|
|
this.shadowRoot.querySelector('.popup-text').textContent = text
|
|
}
|
|
|
|
/**
|
|
* Handles the mousedown event.
|
|
*
|
|
* @param {MouseEvent} e Event
|
|
*/
|
|
onMouseDown(e) {
|
|
e.preventDefault()
|
|
this.initialMouseX = e.clientX
|
|
this.initialMouseY = e.clientY
|
|
this.handleMouseMove = this.onMouseMove.bind(this)
|
|
document.addEventListener('mousemove', this.handleMouseMove)
|
|
this.handleMouseUp = this.onMouseUp.bind(this)
|
|
document.addEventListener('mouseup', this.handleMouseUp)
|
|
}
|
|
|
|
/**
|
|
* Handles the mousemove event.
|
|
*
|
|
* @param {MouseEvent} e Event
|
|
*/
|
|
onMouseMove(e) {
|
|
e.preventDefault()
|
|
|
|
let diffX = this.initialMouseX - e.clientX
|
|
this.initialMouseX = e.clientX
|
|
let left = this.offsetLeft - diffX
|
|
left = Math.max(0, Math.min(left, window.innerWidth - this.offsetWidth))
|
|
this.style.left = `${left}px`
|
|
|
|
let diffY = this.initialMouseY - e.clientY
|
|
this.initialMouseY = e.clientY
|
|
let top = this.offsetTop - diffY
|
|
top = Math.max(0, Math.min(top, window.innerHeight - this.offsetHeight))
|
|
this.style.top = `${top}px`
|
|
}
|
|
|
|
/**
|
|
* Handles the mouseup event.
|
|
*
|
|
* @param {MouseEvent} e Event
|
|
*/
|
|
onMouseUp(e) {
|
|
document.removeEventListener('mouseup', this.handleMouseUp)
|
|
document.removeEventListener('mousemove', this.handleMouseMove)
|
|
}
|
|
|
|
/**
|
|
* Dismisses all the popups.
|
|
*/
|
|
static hide() {
|
|
document
|
|
.querySelectorAll('bes-popup-el')
|
|
.forEach(popup => popup.classList.remove('show'))
|
|
}
|
|
}
|
|
|
|
customElements.define('bes-popup-el', BesPopup)
|
|
|
|
// /*************************************************************************
|
|
// *
|
|
// * Status pop-up
|
|
// *
|
|
// *************************************************************************/
|
|
// class BesStatusPopup extends HTMLElement {
|
|
// constructor() {
|
|
// super()
|
|
// this.attachShadow({ mode: 'open' })
|
|
// }
|
|
|
|
// /**
|
|
// * Called each time the element is added to the document
|
|
// */
|
|
// connectedCallback() {
|
|
// this.shadowRoot.innerHTML = `
|
|
// <style>
|
|
// :host {
|
|
// display: none;
|
|
// }
|
|
// :host(.show){
|
|
// z-index: 10;
|
|
// }
|
|
// .popup-text {
|
|
// max-width: 160px;
|
|
// color: black;
|
|
// text-align: center;
|
|
// padding: 8px 0;
|
|
// z-index: 1;
|
|
// }
|
|
// .bes-popup-container {
|
|
// visibility: hidden;
|
|
// max-width: 300px;
|
|
// background-color: rgb(241, 243, 249);
|
|
// box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
|
// border-radius: 5px;
|
|
// padding: 8px;
|
|
// z-index: 1;
|
|
// }
|
|
// .bes-toolbar {
|
|
// display: flex;
|
|
// justify-content: end;
|
|
// padding: 5px 2px;
|
|
// }
|
|
// .bes-toolbar button {
|
|
// margin-right: 2px;
|
|
// }
|
|
// .bes-popup-title {
|
|
// flex-grow: 1;
|
|
// }
|
|
// .bes-text-div{
|
|
// background-color: white;
|
|
// padding: 10px;
|
|
// border-radius: 5px;
|
|
// }
|
|
// .bes-service-btn{
|
|
// background-color: none;
|
|
// width: 30px;
|
|
// height: 30px;
|
|
// display: flex;
|
|
// justify-content: center;
|
|
// align-items: center;
|
|
// }
|
|
// .bes-turn-off{
|
|
// width: 20px;
|
|
// height: 20px;
|
|
// }
|
|
// :host(.show) .bes-popup-container {
|
|
// visibility: visible;
|
|
// animation: fadeIn 1s;
|
|
// }
|
|
// @keyframes fadeIn {
|
|
// from {opacity: 0;}
|
|
// to {opacity:1 ;}
|
|
// }
|
|
// </style>
|
|
// <div class="bes-popup-container">
|
|
// <div class="bes-toolbar">
|
|
// <div class="bes-popup-title">Besana</div>
|
|
// <button class="bes-close-btn" onclick="BesStatusPopup.hide()">X</button>
|
|
// </div>
|
|
// <div class="bes-text-div">
|
|
// <span class="popup-text">
|
|
// Če želite izključiti preverjanje pravopisa, kliknite na gumb.
|
|
// </span>
|
|
// <button class="bes-service-btn">
|
|
// <img class="bes-turn-off" src="/images/turn-off-svgrepo-com.svg" alt="Izključi preverjanje pravopisa">
|
|
// </button>
|
|
// </div>
|
|
// </div>
|
|
// `
|
|
// }
|
|
|
|
// /**
|
|
// * Shows popup window.
|
|
// *
|
|
// * @param {Number} x X location hint
|
|
// * @param {Number} y Y location hint
|
|
// * @param {BesService} service Grammar checking service
|
|
// */
|
|
// show(x, y, service) {
|
|
// this.style.position = 'fixed'
|
|
// this.style.display = 'block'
|
|
|
|
// // 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`
|
|
// this.classList.add('show')
|
|
|
|
// if (x + this.offsetWidth <= window.innerWidth) {
|
|
// this.style.left = `${x}px`
|
|
// } else if (this.offsetWidth <= x) {
|
|
// this.style.left = `${x - this.offsetWidth}px`
|
|
// } else {
|
|
// this.style.left = `${x - this.offsetWidth / 2}px`
|
|
// }
|
|
|
|
// if (y + 20 + this.offsetHeight <= window.innerHeight) {
|
|
// this.style.top = `${y + 20}px`
|
|
// } else if (this.offsetHeight <= y) {
|
|
// this.style.top = `${y - this.offsetHeight}px`
|
|
// } else {
|
|
// this.style.top = `${y - this.offsetHeight / 2}px`
|
|
// }
|
|
|
|
// if (service) {
|
|
// const disableButton = this.shadowRoot.querySelector('.bes-service-btn')
|
|
// disableButton.onclick = () => {
|
|
// service.unregister()
|
|
// BesStatusPopup.hide()
|
|
// }
|
|
// }
|
|
// this.classList.add('show')
|
|
// }
|
|
|
|
// /**
|
|
// * Dismisses all the popups.
|
|
// */
|
|
// static hide() {
|
|
// const popup = document.querySelector('bes-popup-status.show')
|
|
// popup?.classList?.remove('show')
|
|
// }
|
|
// }
|
|
|
|
// customElements.define('bes-popup-status', BesStatusPopup)
|
|
|
|
// 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 if (
|
|
hostElement.getAttribute('contenteditable').toLowerCase() ===
|
|
'plaintext-only'
|
|
) {
|
|
BesPlainTextService.register(hostElement)
|
|
} else {
|
|
BesDOMService.register(hostElement)
|
|
}
|
|
})
|
|
})
|