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('click', BesService.handleClick) hostElement.addEventListener('scroll', BesService.handleScroll) if (this.constructor === BesService) { hostElement.addEventListener( 'beforeinput', BesService.handleBeforeInput, false ) } 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 (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, isInitialCall = true) { 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 dataPromises = Array.from(node.childNodes).map(child => this.proof(child, false) ) let data = (await Promise.all(dataPromises)).flat() 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 + '/check', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(requestData) }) const signal = this.abortController.signal await 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 ) }) } if (isInitialCall) { // TODO: Check count in 'makrofinančno' case. 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.' } } return [{ text: '<' + node.tagName + '/>', node: node, markup: true }] } else { // Inline elements require no markup. Keep plain text only. let dataPromises = Array.from(node.childNodes).map(child => this.proof(child, false) ) let data = (await Promise.all(dataPromises)).flat() 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 => { if ( el === hostElement || Array.from(hostElement.childNodes).includes(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. // If users will experience performance issues, we can consider debouncing this function. // The lagginess becomes noticeable if the text is long and has many grammar mistakes. 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 }) const dataToChange = this let observer = new MutationObserver(function (mutations) { mutations.forEach(mutation => { if (mutation.type === 'characterData') { const el = dataToChange.children?.find( child => child.element === mutation.target ) if (el) el.isProofed = false } }) }) observer.observe(el, { characterData: true, subtree: true }) } /** * 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.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) { // The commented code below only works in cases where we only have one paragraph. // Until we find a better solution, we will use the textAreaEl.value approach. // let text = this.textAreaService.textAreaEl.value // this.textAreaService.textAreaEl.value = // text.substring(0, match.match.offset) + // replacement + // text.substring(match.match.offset + match.match.length) // this.textAreaService.textAreaEl.selectionStart = // this.textAreaService.textAreaEl.selectionEnd = match.match.offset this.textAreaService.textAreaEl.value = this.hostElement.outerText } 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 this.disableCKEditorSpellcheck(this.ckEditorInstance) hostElement.addEventListener( 'beforeinput', e => this.handleBeforeCKInput(e), false ) this.ckEditorInstance.model.document.on('change:data', () => { const differ = this.ckEditorInstance.model.document.differ const changes = Array.from(differ.getChanges()) // TODO: Repostion mistakes after image is inserted (need some further research on this topic) for (const entry of changes) { if (entry.type === 'insert' || entry.type === 'remove') { const insertedElement = entry.name if (['paragraph', 'blockQuote'].includes(insertedElement)) { if ( entry.attributes.has('listIndent') || entry.name === 'blockQuote' ) { this.clearAllMistakes(this.scrollPanel) setTimeout(() => { this.proof(hostElement) window.dispatchEvent(new Event('resize')) }, 500) return } } if (['table'].includes(insertedElement)) { setTimeout(() => { this.repositionMistakes() window.dispatchEvent(new Event('resize')) }, 500) } } } }) this.ckEditorInstance.commands.get('undo').on('execute', () => { this.clearAllMistakes(this.scrollPanel) setTimeout(() => { this.proof(hostElement) }, 500) }) this.ckEditorInstance.commands.get('redo').on('execute', () => { this.clearAllMistakes(this.scrollPanel) setTimeout(() => { this.proof(hostElement) }, 500) }) } /** * 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 } handleBeforeCKInput(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 => { if ( el === hostElement || Array.from(hostElement.childNodes).includes(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. // If users will experience performance issues, we can consider debouncing this function. // The lagginess becomes noticeable if the text is long and has many grammar mistakes. service.repositionMistakes() setTimeout(() => { window.dispatchEvent(new Event('resize')) }, 100) service.timer = setTimeout(function () { service.abortController = new AbortController() service.proof(hostElement) }, 1000) } clearAllMistakes(el) { if (el.children.length) { for (let i = 0; i < el.children.length; i++) { el.children[i].remove() } } } handleCKInput(hostElement, ckEditorInstance) { let service = besServices.find(e => e.hostElement === hostElement) if (!service) return if (service.timer) clearTimeout(service.timer) service.abortController.abort() const root = ckEditorInstance.model.document.getRoot() const blockElements = Array.from(root.getChildren()) blockElements.forEach(block => { const viewElement = this.ckEditorInstance.editing.mapper.toViewElement(block) const domElement = this.ckEditorInstance.editing.view.domConverter.mapViewToDom( viewElement ) service.clearMistakeMarkup(domElement) service.removeChild(domElement) }) setTimeout(() => { service.repositionMistakes() }, 0) service.timer = setTimeout(function () { service.abortController = new AbortController() service.proof(hostElement) }, 1000) } /** * This function disables the CKEditor spellcheck. * * @param {CKEditorInstance} ckInstance */ disableCKEditorSpellcheck(ckInstance) { ckInstance.editing.view.change(writer => { writer.setAttribute( 'spellcheck', 'false', ckInstance.editing.view.document.getRoot() ) }) } /** * 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) { setTimeout(() => { window.dispatchEvent(new Event('resize')) }, 100) } } /** * 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 }) }) // window.dispatchEvent(new Event('resize')) } // 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) this.clearMistakeMarkup(el) ckEditorInstance.model.change(writer => { const attributes = ckEditorInstance.model.document.selection.getAttributes() writer.remove(modelRange) writer.insertText(replacement, attributes, modelRange.start) }) // 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.copyTAContent() 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 }) this.textAreaEl.addEventListener('resize', () => { this.cloneDiv.style.width = this.textAreaEl.clientWidth + 'px' }) } /** * 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 const textAreaStyles = window.getComputedStyle(textAreaEl) cloneDiv.style.fontSize = textAreaStyles.fontSize cloneDiv.style.fontFamily = textAreaStyles.fontFamily cloneDiv.style.lineHeight = textAreaStyles.lineHeight cloneDiv.style.margin = textAreaStyles.margin cloneDiv.style.border = textAreaStyles.border cloneDiv.style.borderRadius = textAreaStyles.borderRadius const scrollbarWidth = textAreaEl.offsetWidth - textAreaEl.clientWidth cloneDiv.style.padding = textAreaStyles.padding cloneDiv.style.height = textAreaStyles.height cloneDiv.style.maxHeight = textAreaStyles.height cloneDiv.style.overflowY = 'auto' cloneDiv.style.position = 'absolute' cloneDiv.style.top = `${textAreaRect.top + scrollTop}px` cloneDiv.style.left = `${textAreaRect.left}px` cloneDiv.style.width = parseInt(textAreaStyles.width) + parseInt(scrollbarWidth) - 2 + 'px' textAreaEl.style.zIndex = 2 textAreaEl.style.position = 'relative' textAreaEl.parentNode.insertBefore(cloneDiv, textAreaEl) return cloneDiv } /** * This function copies the text from the textarea to the clone div * and creates div elements for each line of text in the textarea */ copyTAContent() { 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 function copies the text from the textarea to the clone div */ handleInput() { const customEvent = new InputEvent('beforeinput') this.copyTAContent() 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) { 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) } /** * 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 = `