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 ) this.lineChangeObserver(hostElement) } 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: 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) }) service.timer = setTimeout(function () { service.abortController = new AbortController() service.proof(hostElement) }, 1000) } /** * This function observes added/removed lines in the host element and repositions mistakes when needed. * It is used for contenteditable elements. The user can still experience lagginess when the text is long and * has many grammar mistakes and adds/removes lines. But it is far less noticeable than it was before. * * @param {Element} hostElement DOM element to observe */ lineChangeObserver(hostElement) { const lineChange = new MutationObserver(mutationsList => { let hasMutation = false for (let mutation of mutationsList) { if (mutation.type === 'childList') { if (mutation.addedNodes.length || mutation.removedNodes.length) { hasMutation = true } } } if (hasMutation) this.repositionMistakes() }) lineChange.observe(hostElement, { childList: true }) } /** * 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 }) if (!this.textAreaService) { 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 if (this.textAreaService) 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) { // 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()) 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.proofCKEditor() }) this.ckEditorInstance.commands.get('redo').on('execute', () => { this.proofCKEditor() }) this.ckEditorInstance.commands.get('bold').on('execute', () => { setTimeout(() => { this.repositionMistakes() window.dispatchEvent(new Event('resize')) }, 500) }) this.ckEditorInstance.commands.get('italic').on('execute', () => { setTimeout(() => { this.repositionMistakes() window.dispatchEvent(new Event('resize')) }, 500) }) this.ckEditorInstance.commands.get('indent').on('execute', () => { this.proofCKEditor() }) this.ckEditorInstance.commands.get('outdent').on('execute', () => { this.proofCKEditor() }) this.ckEditorInstance.commands.get('numberedList').on('execute', () => { this.proofCKEditor() }) this.ckEditorInstance.commands.get('bulletedList').on('execute', () => { this.proofCKEditor() }) } /** * 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 || hostElement?.contains(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) { while (el.firstChild) { el.removeChild(el.firstChild) } } clearChildren() { this.children = [] } proofCKEditor() { this.clearAllMistakes(this.scrollPanel) this.clearChildren() setTimeout(() => { this.proof(this.hostElement) }, 500) } /** * 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.proofCKEditor() } } /// /// 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.registerResizeObserver(this.textAreaEl) } /** * Creates a clone div element for the textarea element * * @param {Node} textAreaEl * @returns {Node} Clone div element */ createCloneDiv(textAreaEl) { const cloneDiv = document.createElement('div') this.setCloneDivSize(textAreaEl, cloneDiv) textAreaEl.style.zIndex = 2 textAreaEl.style.position = 'relative' textAreaEl.parentNode.insertBefore(cloneDiv, textAreaEl) return cloneDiv } /** * Sets the size of the clone div element to match the textarea element * * @param {Node} textAreaEl * @param {Node} cloneDiv * @returns {void} */ setCloneDivSize(textAreaEl, cloneDiv) { 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' } /** * 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 the resize observer for the textarea element * * @param {Node} textAreaEl * @returns {void} */ registerResizeObserver(textAreaEl) { const resizeObserver = new ResizeObserver(() => { this.setCloneDivSize(textAreaEl, this.cloneDiv) this.service.setCorrectionPanelSize( textAreaEl, this.service.correctionPanel, this.service.scrollPanel ) this.service.setStatusDivPosition(textAreaEl, this.service.statusDiv) this.service.children.forEach(child => { this.service.clearMistakeMarkup(child.element) child.matches.forEach(match => { const { clientRects, highlights } = this.service.addMistakeMarkup( match.range ) match.rects = clientRects match.highlights = highlights }) }) }) resizeObserver.observe(textAreaEl) } /** * 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 = `
Besana
` } 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 = `
Besana
Če želite izključiti preverjanje pravopisa, kliknite na gumb.
` } 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 => { if (service.textAreaService) { service.textAreaService.setCloneDivSize( service.textAreaService.textAreaEl, service.textAreaService.cloneDiv ) } 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)