1202 lines
37 KiB
JavaScript
1202 lines
37 KiB
JavaScript
let besServices = [] // Collection of all grammar checking services in the document
|
|
|
|
///
|
|
/// Grammar checking service base class
|
|
///
|
|
class BesService {
|
|
constructor(hostElement) {
|
|
this.hostElement = hostElement
|
|
this.timer = null
|
|
this.children = []
|
|
const { correctionPanel, scrollPanel, statusDiv, statusIcon } =
|
|
this.createCorrectionPanel(hostElement)
|
|
this.correctionPanel = correctionPanel
|
|
this.scrollPanel = scrollPanel
|
|
this.statusDiv = statusDiv
|
|
this.statusIcon = statusIcon
|
|
this.offsetTop = null
|
|
this.textAreaService = null
|
|
this.originalSpellcheck = hostElement.spellcheck
|
|
this.abortController = new AbortController()
|
|
hostElement.spellcheck = false
|
|
hostElement.addEventListener(
|
|
'beforeinput',
|
|
BesService.handleBeforeInput,
|
|
false
|
|
)
|
|
hostElement.addEventListener('click', BesService.handleClick)
|
|
hostElement.addEventListener('scroll', BesService.handleScroll)
|
|
besServices.push(this)
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service
|
|
*
|
|
* @param {Element} hostElement DOM element to register grammar checking service for
|
|
* @returns {BesService} Grammar checking service instance
|
|
*/
|
|
static register(hostElement, textAreaService) {
|
|
let service = new BesService(hostElement)
|
|
service.proof(hostElement)
|
|
if (service.statusIcon.classList.contains('bes-status-loading')) {
|
|
service.updateStatusIcon('bes-status-success')
|
|
service.statusDiv.title = 'BesService je registriran.'
|
|
}
|
|
if (textAreaService) service.textAreaService = textAreaService
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service
|
|
*/
|
|
unregister() {
|
|
this.hostElement.removeEventListener('scroll', BesService.handleScroll)
|
|
this.hostElement.removeEventListener('click', BesService.handleClick)
|
|
this.hostElement.removeEventListener(
|
|
'beforeinput',
|
|
BesService.handleBeforeInput,
|
|
false
|
|
)
|
|
if (this.timer) clearTimeout(this.timer)
|
|
this.abortController.abort()
|
|
besServices = besServices.filter(item => item !== this)
|
|
this.hostElement.spellcheck = this.originalSpellcheck
|
|
this.correctionPanel.remove()
|
|
this.scrollPanel.remove()
|
|
this.statusDiv.remove()
|
|
this.statusIcon.remove()
|
|
}
|
|
|
|
/**
|
|
* Recursively grammar-proofs a DOM tree.
|
|
*
|
|
* @param {Node} node DOM root node to proof
|
|
* @returns {Array} Markup of text to proof using BesStr
|
|
*/
|
|
async proof(node) {
|
|
this.updateStatusIcon('bes-status-loading')
|
|
this.statusDiv.title = 'BesService je v procesu preverjanja pravopisa.'
|
|
switch (node.nodeType) {
|
|
case Node.TEXT_NODE:
|
|
return [{ text: node.textContent, node: node, markup: false }]
|
|
|
|
case Node.ELEMENT_NODE:
|
|
if (BesService.isBlockElement(node)) {
|
|
// Block elements are grammar-proofed independently.
|
|
if (this.isProofed(node)) {
|
|
return [
|
|
{ text: '<' + node.tagName + '/>', node: node, markup: true }
|
|
]
|
|
}
|
|
this.clearMistakeMarkup(node)
|
|
let data = []
|
|
for (const el2 of node.childNodes) {
|
|
data = data.concat(await this.proof(el2))
|
|
}
|
|
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
|
|
const requestData = {
|
|
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'
|
|
}
|
|
const request = new Request(besUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(requestData)
|
|
})
|
|
const signal = this.abortController.signal
|
|
fetch(request, { signal })
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
this.updateStatusIcon('bes-status-error')
|
|
this.statusDiv.title = 'Napaka pri preverjanju pravopisa.'
|
|
throw new Error('Backend server response was not OK')
|
|
}
|
|
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 &&*/ 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)
|
|
})
|
|
.catch(error => {
|
|
if (error.name === 'AbortError') return
|
|
this.updateStatusIcon('bes-status-error')
|
|
this.statusDiv.title = 'Napaka pri preverjanju pravopisa.'
|
|
throw new Error(
|
|
'Parsing backend server response failed: ' + error
|
|
)
|
|
})
|
|
}
|
|
this.updateStatusIcon('bes-status-success')
|
|
this.statusDiv.title = 'BesService je registriran.'
|
|
return [{ text: '<' + node.tagName + '/>', node: node, markup: true }]
|
|
} else {
|
|
// Surround inline element with dummy <tagName>...</tagName>.
|
|
let data = [
|
|
{ text: '<' + node.tagName + '>', node: node, markup: true }
|
|
]
|
|
for (const el2 of node.childNodes) {
|
|
data = data.concat(await this.proof(el2))
|
|
}
|
|
data.splice(data.length, 0, {
|
|
text: '</' + node.tagName + '>',
|
|
markup: true
|
|
})
|
|
return data
|
|
}
|
|
|
|
default:
|
|
return [{ text: '<?' + node.nodeType + '>', node: node, markup: true }]
|
|
}
|
|
}
|
|
|
|
createCorrectionPanel(hostElement) {
|
|
const panelParent = document.createElement('div')
|
|
panelParent.classList.add('bes-correction-panel-parent')
|
|
const correctionPanel = document.createElement('div')
|
|
const scrollPanel = document.createElement('div')
|
|
this.setCorrectionPanelSize(hostElement, correctionPanel, scrollPanel)
|
|
correctionPanel.classList.add('bes-correction-panel')
|
|
scrollPanel.classList.add('bes-correction-panel-scroll')
|
|
|
|
correctionPanel.appendChild(scrollPanel)
|
|
panelParent.appendChild(correctionPanel)
|
|
hostElement.parentElement.insertBefore(panelParent, hostElement)
|
|
|
|
const statusDiv = document.createElement('div')
|
|
statusDiv.classList.add('bes-status-div')
|
|
const statusIcon = document.createElement('div')
|
|
statusIcon.classList.add('bes-status-icon')
|
|
statusDiv.appendChild(statusIcon)
|
|
this.setStatusDivPosition(hostElement, statusDiv)
|
|
hostElement.parentNode.insertBefore(statusDiv, hostElement.nextSibling)
|
|
const statusPopup = document.createElement('bes-popup-status-el')
|
|
document.body.appendChild(statusPopup)
|
|
statusDiv.addEventListener('click', e => {
|
|
this.handleStatusClick(e, statusPopup)
|
|
})
|
|
|
|
return { correctionPanel, scrollPanel, statusDiv, statusIcon }
|
|
}
|
|
|
|
/**
|
|
* beforeinput event handler
|
|
*
|
|
* Marks section of the text that is about to change as not-yet-grammar-proofed.
|
|
*
|
|
* @param {InputEvent} event The event notifying the user of editable content changes
|
|
*/
|
|
static handleBeforeInput(event) {
|
|
const hostElement = event.target
|
|
let service = besServices.find(e => e.hostElement === hostElement)
|
|
if (!service) return
|
|
if (service.timer) clearTimeout(service.timer)
|
|
service.abortController.abort()
|
|
let blockElements = new Set()
|
|
event.getTargetRanges().forEach(range => {
|
|
BesService.getNodesInRange(range).forEach(el =>
|
|
blockElements.add(service.getBlockParent(el))
|
|
)
|
|
})
|
|
blockElements.forEach(block => {
|
|
service.clearMistakeMarkup(block)
|
|
service.removeChild(block)
|
|
})
|
|
// Not a nice way to do it, but it works for now the repositionMistakes function is called before the DOM updates are finished.
|
|
setTimeout(() => {
|
|
service.repositionMistakes()
|
|
}, 0)
|
|
service.timer = setTimeout(function () {
|
|
service.abortController = new AbortController()
|
|
service.proof(hostElement)
|
|
}, 1000)
|
|
}
|
|
|
|
/**
|
|
* Tests if given block element has already been grammar-proofed.
|
|
*
|
|
* @param {Element} el DOM element to check
|
|
* @returns {Boolean} true if the element has already been grammar-proofed; false otherwise.
|
|
*/
|
|
isProofed(el) {
|
|
return this.children.find(child => child.element === el)?.isProofed
|
|
}
|
|
|
|
/**
|
|
* Marks given block element as grammar-proofed.
|
|
*
|
|
* @param {Element} el DOM element that was checked
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(el, matches) {
|
|
this.removeChild(el)
|
|
this.children.push({
|
|
isProofed: true,
|
|
element: el,
|
|
matches: matches
|
|
})
|
|
|
|
// TODO: This also shows the count of mistakes that are not visible, meaning that they are hidden behind the shown ones.
|
|
const count = this.children.reduce(
|
|
(total, child) => total + child.matches.length,
|
|
0
|
|
)
|
|
if (count > 0) {
|
|
this.updateStatusIcon('bes-status-mistakes')
|
|
this.statusDiv.title = 'Število napak: ' + count
|
|
} else {
|
|
this.updateStatusIcon('bes-status-success')
|
|
this.statusDiv.title = 'V besedilu ni napak.'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears given block element as not grammar-proofed and removes all its grammar mistakes.
|
|
*
|
|
* @param {Element} el DOM element that we should re-grammar-proof
|
|
*/
|
|
clearMistakeMarkup(el) {
|
|
let child = this.children.find(child => child.element === el)
|
|
if (!child) return
|
|
child.isProofed = false
|
|
child.matches.forEach(match => {
|
|
if (match?.highlights) {
|
|
match.highlights.forEach(h => h.remove())
|
|
delete match.highlights
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Removes given block element from this.children array
|
|
*
|
|
* @param {Element} el DOM element for removal
|
|
*/
|
|
removeChild(el) {
|
|
this.children = this.children.filter(child => child.element !== el)
|
|
}
|
|
|
|
/**
|
|
* Updates grammar mistake markup positions.
|
|
*/
|
|
repositionMistakes() {
|
|
this.children.forEach(child => {
|
|
this.clearMistakeMarkup(child.element)
|
|
child.matches.forEach(match => {
|
|
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
|
|
match.rects = clientRects
|
|
match.highlights = highlights
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Adds grammar mistake markup.
|
|
*
|
|
* @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 }
|
|
}
|
|
|
|
updateStatusIcon(status) {
|
|
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)
|
|
}
|
|
|
|
handleStatusClick(e, popup) {
|
|
popup.show(e.clientX, e.clientY, this)
|
|
}
|
|
|
|
/**
|
|
* Tests if given element is block element.
|
|
*
|
|
* @param {Element} el DOM element
|
|
* @returns false if CSS display property is inline; true otherwise.
|
|
*/
|
|
static isBlockElement(el) {
|
|
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} el DOM node
|
|
* @returns {Element} Innermost block element containing given node
|
|
*/
|
|
getBlockParent(el) {
|
|
for (; el && el !== this.hostElement; el = el.parentNode) {
|
|
if (el.nodeType === Node.ELEMENT_NODE && BesService.isBlockElement(el))
|
|
return el
|
|
}
|
|
return el
|
|
}
|
|
|
|
/**
|
|
* 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 in reverse order: node, immediate parent, ..., document
|
|
*/
|
|
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) {
|
|
var start = range.startContainer
|
|
var end = range.endContainer
|
|
|
|
let startAncestors = BesService.getParents(start)
|
|
let endAncestors = BesService.getParents(end)
|
|
let commonAncestor = null
|
|
for (
|
|
let i = 0;
|
|
i < startAncestors.length &&
|
|
i < endAncestors.length &&
|
|
startAncestors[i] === endAncestors[i];
|
|
++i
|
|
) {
|
|
commonAncestor = startAncestors[i]
|
|
}
|
|
|
|
var nodes = []
|
|
var 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 is found
|
|
for (node = start; node; node = BesService.getNextNode(node)) {
|
|
nodes.push(node)
|
|
if (node == end) break
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
/**
|
|
* click event handler
|
|
*
|
|
* 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.
|
|
*/
|
|
static handleClick(event) {
|
|
const source = event?.detail !== 1 ? event?.detail : event
|
|
const hostElement = BesService.findParent(
|
|
source.targetElement || source.target
|
|
)
|
|
let service = besServices.find(e => e.hostElement === hostElement)
|
|
if (!service) return
|
|
const target = service.getBlockParent(source.targetElement || source.target)
|
|
service.renderPopup(target, source.clientX, source.clientY)
|
|
}
|
|
|
|
/**
|
|
* scroll event handler
|
|
*
|
|
* Syncs grammar mistake positions with host element scroll offset.
|
|
*
|
|
* @param {Event} event The event which takes place.
|
|
*/
|
|
static handleScroll(event) {
|
|
const hostElement = event.target
|
|
let service = besServices.find(e => e.hostElement === hostElement)
|
|
if (!service) return
|
|
service.scrollPanel.style.top = -hostElement.scrollTop + 'px'
|
|
service.offsetTop = hostElement.scrollTop
|
|
|
|
if (service.scrollTimeout) clearTimeout(service.scrollTimeout)
|
|
service.scrollTimeout = setTimeout(() => {
|
|
service.repositionMistakes()
|
|
service.scrollTimeout = null
|
|
}, 500)
|
|
}
|
|
|
|
/**
|
|
* Finds the host element with grammar checking service a DOM node is child of.
|
|
*
|
|
* @param {Node} el DOM node
|
|
* @returns {Element} Host DOM element; null if DOM node is not a descendant of any registered host element.
|
|
*/
|
|
static findParent(el) {
|
|
for (; el; el = el.parentNode) {
|
|
if (besServices.find(service => service.hostElement === el)) {
|
|
return el
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Displays grammar mistake explanation popup.
|
|
*
|
|
* @param {*} el DOM element we have grammar proofing available for
|
|
* @param {*} clientX Client X coordinate of the pointer event
|
|
* @param {*} clientY Client Y coordinate of the pointer event
|
|
*/
|
|
renderPopup(el, clientX, clientY) {
|
|
const popup = document.querySelector('bes-popup-el')
|
|
const matches = this.children.find(child => child.element === el)?.matches
|
|
if (matches) {
|
|
for (let m of matches) {
|
|
if (m.rects) {
|
|
for (let r of m.rects) {
|
|
if (BesService.isPointInRect(clientX, clientY, r)) {
|
|
popup.changeMessage(m.match.message)
|
|
m.match.replacements.forEach(replacement => {
|
|
popup.appendReplacements(
|
|
el,
|
|
m,
|
|
replacement.value,
|
|
this,
|
|
this.hostElement.contentEditable !== 'false'
|
|
)
|
|
})
|
|
popup.show(clientX, clientY)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BesPopupEl.hide()
|
|
}
|
|
|
|
// This function should be able to handle both cases or find a way that works for both.
|
|
replaceText(el, match, replacement) {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
this.abortController.abort()
|
|
match.range.deleteContents()
|
|
match.range.insertNode(document.createTextNode(replacement))
|
|
if (this.textAreaService) {
|
|
this.textAreaService.handleReplacement(this.hostElement)
|
|
}
|
|
this.clearMistakeMarkup(el)
|
|
// In my opinion, this approach provides the most straightforward solution for repositioning mistakes after a change.
|
|
// It maintains reasonable performance as it only checks the block element that has been modified,
|
|
// rather than re-evaluating the entire document or a larger set of elements.
|
|
this.abortController = new AbortController()
|
|
this.proof(el)
|
|
}
|
|
|
|
setCorrectionPanelSize(hostElement, correctionPanel, scrollPanel) {
|
|
const styles = window.getComputedStyle(hostElement)
|
|
const totalWidth = parseFloat(styles.width)
|
|
const totalHeight =
|
|
parseFloat(styles.height) +
|
|
parseFloat(styles.marginTop) +
|
|
parseFloat(styles.marginBottom) +
|
|
parseFloat(styles.paddingTop) +
|
|
parseFloat(styles.paddingBottom)
|
|
correctionPanel.style.width = totalWidth + 'px'
|
|
correctionPanel.style.height = totalHeight + 'px'
|
|
correctionPanel.style.marginLeft = styles.marginLeft
|
|
correctionPanel.style.marginRight = styles.marginRight
|
|
correctionPanel.style.paddingLeft = styles.paddingLeft
|
|
correctionPanel.style.paddingRight = styles.paddingRight
|
|
scrollPanel.style.height = hostElement.scrollHeight + 'px'
|
|
}
|
|
|
|
setStatusDivPosition(hostElement, statusDiv) {
|
|
const hRects = hostElement.getBoundingClientRect()
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
|
statusDiv.style.left = hRects.right - 40 + 'px'
|
|
statusDiv.style.top = hRects.top + hRects.height - 30 + scrollTop + 'px'
|
|
}
|
|
|
|
static isPointInRect(x, y, rect) {
|
|
return (
|
|
x >= rect.x &&
|
|
x < rect.x + rect.width &&
|
|
y >= rect.y &&
|
|
y < rect.y + rect.height
|
|
)
|
|
}
|
|
}
|
|
|
|
///
|
|
/// Grammar checking service for CKEditor
|
|
///
|
|
class BesCKService extends BesService {
|
|
constructor(hostElement, ckEditorInstance) {
|
|
super(hostElement)
|
|
this.ckEditorInstance = ckEditorInstance
|
|
}
|
|
|
|
/**
|
|
* 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.proof(hostElement)
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Marks given block element as grammar-proofed.
|
|
*
|
|
* @param {Element} el DOM element that was checked
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(el, matches) {
|
|
super.markProofed(el, matches)
|
|
|
|
// This is a solution for displaying mistakes in CKEditor. It is not the best solution, but it works for now.
|
|
if (this.ckEditorInstance) window.dispatchEvent(new Event('resize'))
|
|
}
|
|
|
|
/**
|
|
* Removes given block element from this.children array
|
|
*
|
|
* @param {Element} el DOM element for removal
|
|
*/
|
|
removeChild(el) {
|
|
this.children = this.children.filter(child => child.element !== el)
|
|
}
|
|
|
|
/**
|
|
* Updates grammar mistake markup positions.
|
|
*/
|
|
// TODO: Implement a more efficient solution for repositioning mistakes after scrolling etc.
|
|
repositionMistakes() {
|
|
this.children.forEach(child => {
|
|
this.clearMistakeMarkup(child.element)
|
|
child.matches.forEach(match => {
|
|
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
|
|
match.rects = clientRects
|
|
match.highlights = highlights
|
|
})
|
|
})
|
|
}
|
|
|
|
// This function should be able to handle both cases or find a way that works for both.
|
|
replaceText(el, match, replacement) {
|
|
const { ckEditorInstance } = this
|
|
const viewRange = ckEditorInstance.editing.view.domConverter.domRangeToView(
|
|
match.range
|
|
)
|
|
const modelRange = ckEditorInstance.editing.mapper.toModelRange(viewRange)
|
|
ckEditorInstance.model.change(writer => {
|
|
const attributes =
|
|
ckEditorInstance.model.document.selection.getAttributes()
|
|
writer.remove(modelRange)
|
|
writer.insertText(replacement, attributes, modelRange.start)
|
|
})
|
|
this.clearMistakeMarkup(el)
|
|
// In my opinion, this approach provides the most straightforward solution for repositioning mistakes after a change.
|
|
// It maintains reasonable performance as it only checks the block element that has been modified,
|
|
// rather than re-evaluating the entire document or a larger set of elements.
|
|
this.abortController = new AbortController()
|
|
this.proof(el)
|
|
}
|
|
}
|
|
|
|
///
|
|
/// Grammar checking service for textarea element
|
|
///
|
|
class BesTAService {
|
|
constructor(textAreaEl) {
|
|
this.textAreaEl = textAreaEl
|
|
this.textAreaEl.spellcheck = false
|
|
this.cloneDiv = this.createCloneDiv(textAreaEl)
|
|
this.service = BesService.register(this.cloneDiv, this)
|
|
this.textAreaEl.addEventListener('input', () => this.handleInput())
|
|
this.textAreaEl.addEventListener('click', e => this.handleTAClick(e))
|
|
this.textAreaEl.addEventListener('scroll', () => {
|
|
this.cloneDiv.scrollTop = this.textAreaEl.scrollTop
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates a clone div element for the textarea element
|
|
*
|
|
* @param {Node} textAreaEl
|
|
* @returns {Node} Clone div element
|
|
*/
|
|
createCloneDiv(textAreaEl) {
|
|
const cloneDiv = document.createElement('div')
|
|
const textAreaRect = textAreaEl.getBoundingClientRect()
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
|
cloneDiv.style.top = `${textAreaRect.top + scrollTop}px`
|
|
cloneDiv.style.left = `${textAreaRect.left}px`
|
|
const textAreaStyles = window.getComputedStyle(textAreaEl)
|
|
cloneDiv.style.fontSize = textAreaStyles.fontSize
|
|
cloneDiv.style.fontFamily = textAreaStyles.fontFamily
|
|
cloneDiv.style.lineHeight = textAreaStyles.lineHeight
|
|
cloneDiv.style.width = textAreaStyles.width
|
|
cloneDiv.style.height = textAreaStyles.height
|
|
cloneDiv.style.maxHeight = textAreaStyles.height
|
|
cloneDiv.style.padding = textAreaStyles.padding
|
|
cloneDiv.style.margin = textAreaStyles.margin
|
|
cloneDiv.style.overflowY = 'auto'
|
|
cloneDiv.style.position = 'absolute'
|
|
textAreaEl.style.position = 'relative'
|
|
textAreaEl.style.zIndex = 2
|
|
|
|
textAreaEl.parentNode.insertBefore(cloneDiv, textAreaEl)
|
|
return cloneDiv
|
|
}
|
|
|
|
/**
|
|
* This function copies the text from the textarea to the clone div
|
|
*/
|
|
handleInput() {
|
|
const customEvent = new InputEvent('beforeinput')
|
|
|
|
const lines = this.textAreaEl.value.split('\n')
|
|
this.cloneDiv.innerHTML = ''
|
|
lines.forEach(line => {
|
|
const divEl = document.createElement('div')
|
|
divEl.textContent = line
|
|
if (line === '') divEl.innerHTML = ' '
|
|
this.cloneDiv.appendChild(divEl)
|
|
})
|
|
this.cloneDiv.dispatchEvent(customEvent)
|
|
}
|
|
|
|
/**
|
|
* This function handles the click event on the textarea element and finds the deepest div at the click position
|
|
*
|
|
* @param {Event} e Click event
|
|
*/
|
|
handleTAClick(e) {
|
|
//TODO: Consider adding some kind of proofing?
|
|
this.textAreaEl.style.visibility = 'hidden'
|
|
const deepestElement = document.elementFromPoint(e.clientX, e.clientY)
|
|
this.textAreaEl.style.visibility = 'visible'
|
|
|
|
const clickEvent = new CustomEvent('click', {
|
|
detail: {
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
targetElement: deepestElement
|
|
}
|
|
})
|
|
this.cloneDiv.dispatchEvent(clickEvent)
|
|
}
|
|
|
|
/**
|
|
* This function handles the replacement of the text in the textarea element
|
|
*
|
|
* @param {HTMLElement} el Element whose outerText will be used as a replacement
|
|
*/
|
|
handleReplacement(el) {
|
|
// TODO: think of a way to reposition the cursor after the replacement
|
|
this.textAreaEl.value = el.outerText
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service
|
|
*
|
|
* @param {Element} textAreaEl DOM element to register grammar checking service for
|
|
* @returns {BesTAService} Grammar checking service instance
|
|
*/
|
|
static register(textAreaEl) {
|
|
let service = new BesTAService(textAreaEl)
|
|
return service
|
|
}
|
|
}
|
|
|
|
///
|
|
/// Grammar mistake popup dialog
|
|
///
|
|
class BesPopupEl extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
this.attachShadow({ mode: 'open' })
|
|
// Variables to store the initial positions
|
|
this.initialMouseX = 0
|
|
this.initialMouseY = 0
|
|
this.currentMouseX = 0
|
|
this.currentMouseY = 0
|
|
|
|
this.isMouseDownRegistered = false
|
|
}
|
|
|
|
render() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host {
|
|
position: relative;
|
|
display: inline-block;
|
|
z-index: -1
|
|
}
|
|
: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;
|
|
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="BesPopupEl.hide()">X</button>
|
|
</div>
|
|
<div class="bes-text-div">
|
|
<span class="popup-text">
|
|
</span>
|
|
<div class="bes-replacement-div">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
show(x, y) {
|
|
y = y + 20
|
|
this.style.position = 'fixed'
|
|
this.style.left = `${x}px`
|
|
this.style.top = `${y}px`
|
|
|
|
const viewportWidth = window.innerWidth
|
|
const viewportHeight = window.innerHeight
|
|
const popupWidth = this.offsetWidth
|
|
const popupHeight = this.offsetHeight
|
|
const maxPositionX = viewportWidth - popupWidth
|
|
const maxPositionY = viewportHeight - popupHeight
|
|
const positionX = this.offsetLeft
|
|
const positionY = this.offsetTop
|
|
if (positionX > maxPositionX) {
|
|
this.style.left = maxPositionX + 'px'
|
|
}
|
|
if (positionY > maxPositionY) {
|
|
this.style.top = maxPositionY + 'px'
|
|
}
|
|
|
|
this.classList.add('show')
|
|
}
|
|
|
|
clear() {
|
|
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
|
|
const replacements = replacementDiv.children
|
|
if (!replacements.length) return
|
|
for (const replacement of Array.from(replacements)) {
|
|
replacement.remove()
|
|
}
|
|
}
|
|
|
|
changeMessage(text) {
|
|
this.clear()
|
|
this.shadowRoot.querySelector('.popup-text').textContent = text
|
|
}
|
|
|
|
appendReplacements(el, match, replacement, service, allowReplacements) {
|
|
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
|
|
const replacementBtn = document.createElement('button')
|
|
replacementBtn.classList.add('bes-replacement-btn')
|
|
replacementBtn.textContent = replacement
|
|
replacementBtn.addEventListener('click', () => {
|
|
if (allowReplacements) {
|
|
service.replaceText(el, match, replacement)
|
|
BesPopupEl.hide()
|
|
}
|
|
})
|
|
replacementDiv.appendChild(replacementBtn)
|
|
}
|
|
|
|
dragMouseDown(e) {
|
|
e.preventDefault()
|
|
this.initialMouseX = e.clientX
|
|
this.initialMouseY = e.clientY
|
|
document.onmousemove = this.elementDrag.bind(this)
|
|
document.onmouseup = this.closeDragElement.bind(this)
|
|
}
|
|
|
|
// Function to handle the mousemove event
|
|
elementDrag(e) {
|
|
e.preventDefault()
|
|
|
|
let diffX = this.initialMouseX - e.clientX
|
|
let diffY = this.initialMouseY - e.clientY
|
|
this.initialMouseX = e.clientX
|
|
this.initialMouseY = e.clientY
|
|
|
|
let newTop = this.offsetTop - diffY
|
|
let newLeft = this.offsetLeft - diffX
|
|
const popupWidth = this.offsetWidth
|
|
const popupHeight = this.offsetHeight
|
|
const viewportWidth = window.innerWidth
|
|
const viewportHeight = window.innerHeight
|
|
|
|
// Adjust the new position if it would place the popup outside the window
|
|
if (newTop < 0) {
|
|
newTop = 0
|
|
} else if (newTop + popupHeight > viewportHeight) {
|
|
newTop = viewportHeight - popupHeight
|
|
}
|
|
if (newLeft < 0) {
|
|
newLeft = 0
|
|
} else if (newLeft + popupWidth > viewportWidth) {
|
|
newLeft = viewportWidth - popupWidth
|
|
}
|
|
|
|
this.style.top = newTop + 'px'
|
|
this.style.left = newLeft + 'px'
|
|
}
|
|
|
|
closeDragElement() {
|
|
document.onmouseup = null
|
|
document.onmousemove = null
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.render()
|
|
if (!this.isMouseDownRegistered) {
|
|
this.onmousedown = this.dragMouseDown.bind(this)
|
|
this.isMouseDownRegistered = true
|
|
}
|
|
}
|
|
|
|
static hide() {
|
|
let popups = document.querySelectorAll('bes-popup-el')
|
|
popups.forEach(popup => {
|
|
popup.classList.remove('show')
|
|
})
|
|
}
|
|
}
|
|
|
|
class BesStatusPopup extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
this.attachShadow({ mode: 'open' })
|
|
}
|
|
|
|
render() {
|
|
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>
|
|
`
|
|
}
|
|
|
|
show(x, y, service) {
|
|
y = y + 20
|
|
this.style.position = 'fixed'
|
|
this.style.left = `${x}px`
|
|
this.style.top = `${y}px`
|
|
this.style.display = 'block'
|
|
|
|
const viewportWidth = window.innerWidth
|
|
const viewportHeight = window.innerHeight
|
|
const popupWidth = this.offsetWidth
|
|
const popupHeight = this.offsetHeight
|
|
const maxPositionX = viewportWidth - popupWidth
|
|
const maxPositionY = viewportHeight - popupHeight
|
|
const positionX = this.offsetLeft
|
|
const positionY = this.offsetTop
|
|
if (positionX > maxPositionX) {
|
|
this.style.left = maxPositionX + 'px'
|
|
}
|
|
if (positionY > maxPositionY) {
|
|
this.style.top = maxPositionY + 'px'
|
|
}
|
|
this.disableButton = this.shadowRoot.querySelector('.bes-service-btn')
|
|
if (service) {
|
|
this.disableButton.addEventListener('click', () => this.disable(service))
|
|
}
|
|
this.classList.add('show')
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.render()
|
|
}
|
|
|
|
disable(service) {
|
|
service.unregister()
|
|
BesStatusPopup.hide()
|
|
}
|
|
|
|
static hide() {
|
|
const popup = document.querySelector('bes-popup-status-el.show')
|
|
popup?.classList?.remove('show')
|
|
}
|
|
}
|
|
|
|
window.onload = () => {
|
|
document.querySelectorAll('.bes-service').forEach(hostElement => {
|
|
if (hostElement.tagName === 'TEXTAREA') BesTAService.register(hostElement)
|
|
else BesService.register(hostElement)
|
|
})
|
|
}
|
|
|
|
window.onresize = () => {
|
|
besServices.forEach(service => {
|
|
service.setCorrectionPanelSize(
|
|
service.hostElement,
|
|
service.correctionPanel,
|
|
service.scrollPanel
|
|
)
|
|
service.setStatusDivPosition(service.hostElement, service.statusDiv)
|
|
service.children.forEach(child => {
|
|
service.clearMistakeMarkup(child.element)
|
|
child.matches.forEach(match => {
|
|
const { clientRects, highlights } = service.addMistakeMarkup(
|
|
match.range
|
|
)
|
|
match.rects = clientRects
|
|
match.highlights = highlights
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
window.onscroll = () => {
|
|
besServices.forEach(service => {
|
|
service.scrollPanel.style.top = -service.hostElement.scrollTop + 'px'
|
|
service.offsetTop = service.hostElement.scrollTop
|
|
|
|
if (service.windowScrollTimeout) clearTimeout(service.windowScrollTimeout)
|
|
service.windowScrollTimeout = setTimeout(() => {
|
|
service.repositionMistakes()
|
|
service.windowScrollTimeout = null
|
|
}, 300)
|
|
})
|
|
}
|
|
|
|
customElements.define('bes-popup-el', BesPopupEl)
|
|
customElements.define('bes-popup-status-el', BesStatusPopup)
|