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 { // Inline elements require no markup. Keep plain text only. let data = [] for (const el2 of node.childNodes) data = data.concat(await this.proof(el2)) 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 = `