This commit introduces the BesTAService class, which is designed to register a grammar checking service for textarea elements. The core logic has been written, although the class is not yet fully functional.
972 lines
30 KiB
JavaScript
972 lines
30 KiB
JavaScript
const besUrl = 'http://localhost:225/api/v2/check'
|
|
|
|
let besServices = [] // Collection of all grammar checking services in the document
|
|
|
|
// TODO: Add support for <textarea>
|
|
|
|
///
|
|
/// Grammar checking service base class
|
|
///
|
|
class BesService {
|
|
constructor(hostElement) {
|
|
this.hostElement = hostElement
|
|
this.timer = null
|
|
this.children = []
|
|
const { correctionPanel, scrollPanel, statusIcon } =
|
|
this.createCorrectionPanel(hostElement)
|
|
this.correctionPanel = correctionPanel
|
|
this.scrollPanel = scrollPanel
|
|
this.statusIcon = statusIcon
|
|
this.offsetTop = 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) {
|
|
let service = new BesService(hostElement)
|
|
service.proof(hostElement)
|
|
if (service.statusIcon.classList.contains('bes-status-loading')) {
|
|
service.updateStatusIcon('bes-status-success')
|
|
}
|
|
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)
|
|
service.abortController.abort()
|
|
besServices = besServices.filter(item => item !== this)
|
|
this.hostElement.spellcheck = this.originalSpellcheck
|
|
this.correctionPanel.remove()
|
|
this.scrollPanel.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')
|
|
let mistakesCounter = 0
|
|
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')
|
|
throw new Error('Backend server response was not OK')
|
|
}
|
|
return response.json()
|
|
})
|
|
.then(responseData => {
|
|
let matches = []
|
|
mistakesCounter += responseData.matches.length
|
|
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, mistakesCounter)
|
|
})
|
|
.catch(error => {
|
|
if (error.name === 'AbortError') return
|
|
this.updateStatusIcon('bes-status-error')
|
|
throw new Error(
|
|
'Parsing backend server response failed: ' + error
|
|
)
|
|
})
|
|
}
|
|
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 statusIcon = document.createElement('div')
|
|
statusIcon.classList.add('bes-status-icon')
|
|
correctionPanel.appendChild(statusIcon)
|
|
|
|
return { correctionPanel, scrollPanel, 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, mistakesCounter) {
|
|
this.removeChild(el)
|
|
this.children.push({
|
|
isProofed: true,
|
|
element: el,
|
|
matches: matches
|
|
})
|
|
|
|
if (mistakesCounter > 0) this.updateStatusIcon('bes-status-mistakes')
|
|
else this.updateStatusIcon('bes-status-success')
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* 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 hostElement = BesService.findParent(event.target)
|
|
let service = besServices.find(e => e.hostElement === hostElement)
|
|
if (!service) return
|
|
const target = service.getBlockParent(event.target)
|
|
service.renderPopup(target, event.clientX, event.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
|
|
setTimeout(() => {
|
|
service.repositionMistakes()
|
|
}, 100)
|
|
// TODO: Move popup (if open) too.
|
|
}
|
|
|
|
/**
|
|
* 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.changeText(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))
|
|
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'
|
|
}
|
|
|
|
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, mistakesCounter) {
|
|
super.markProofed(el, matches, mistakesCounter)
|
|
|
|
// 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.
|
|
*/
|
|
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)
|
|
}
|
|
}
|
|
|
|
class BesTAService {
|
|
constructor(textAreaEl) {
|
|
this.textAreaEl = textAreaEl
|
|
this.textAreaEl.spellcheck = false
|
|
this.cloneDiv = this.createCloneDiv(textAreaEl)
|
|
this.service = BesService.register(this.cloneDiv)
|
|
this.textAreaEl.addEventListener('input', () => this.handleInput())
|
|
}
|
|
|
|
createCloneDiv(textAreaEl) {
|
|
const cloneDiv = document.createElement('div')
|
|
const textAreaRect = textAreaEl.getBoundingClientRect()
|
|
cloneDiv.style.top = `${textAreaRect.top}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.padding = textAreaStyles.padding
|
|
cloneDiv.style.margin = textAreaStyles.margin
|
|
cloneDiv.style.position = 'absolute'
|
|
textAreaEl.style.position = 'relative'
|
|
textAreaEl.style.zIndex = 2
|
|
|
|
textAreaEl.parentNode.insertBefore(cloneDiv, textAreaEl)
|
|
return cloneDiv
|
|
}
|
|
|
|
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
|
|
this.cloneDiv.appendChild(divEl)
|
|
})
|
|
this.cloneDiv.dispatchEvent(customEvent)
|
|
}
|
|
|
|
/**
|
|
* 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-text-div{
|
|
background-color: white;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
}
|
|
.bes-replacement-btn{
|
|
margin: 5px 0;
|
|
padding: 5px;
|
|
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">
|
|
<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()
|
|
}
|
|
}
|
|
|
|
changeText(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.classList.add('bes-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')
|
|
})
|
|
}
|
|
}
|
|
|
|
window.onload = () => {
|
|
document
|
|
.querySelectorAll('.bes-service')
|
|
.forEach(hostElement => BesService.register(hostElement))
|
|
document.querySelectorAll('.bes-service-textarea').forEach(hostElement => {
|
|
BesTAService.register(hostElement)
|
|
})
|
|
}
|
|
|
|
window.onresize = () => {
|
|
besServices.forEach(service => {
|
|
service.setCorrectionPanelSize(
|
|
service.hostElement,
|
|
service.correctionPanel,
|
|
service.scrollPanel
|
|
)
|
|
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
|
|
setTimeout(() => {
|
|
service.repositionMistakes()
|
|
}, 100)
|
|
})
|
|
}
|
|
|
|
customElements.define('bes-popup-el', BesPopupEl)
|