const besUrl = 'http://localhost:225/api/v2/check' let besEditors = [] // Collection of all editors on page class BesEditor { constructor(edit) { this.el = edit this.timer = null this.children = [] const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit) this.correctionPanel = correctionPanel this.scrollPanel = scrollPanel this.offsetTop = null this.proof(edit) edit.addEventListener('beforeinput', e => this.handleBeforeInput(e), false) edit.addEventListener('click', e => this.handleClick(e)) edit.addEventListener('scroll', () => this.handleScrollEvent(this.el, this.scrollPanel) ) this.observeDeletions(this.el) } // Register editor static register(edit) { let editor = new BesEditor(edit) besEditors.push(editor) return editor } // Recursively grammar-proofs one node. async proof(el) { switch (el.nodeType) { case Node.TEXT_NODE: return [{ text: el.textContent, el: el, markup: false }] case Node.ELEMENT_NODE: if (BesEditor.isBlockElement(el)) { // Block elements are grammar-proofed independently. if (this.isProofed(el)) { return [{ text: '<' + el.tagName + '/>', el: el, markup: true }] } this.clearMistakeMarkup(el) let data = [] for (const el2 of el.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: el.lang ? el.lang : 'sl', level: 'picky' } const request = new Request(besUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(requestData) }) fetch(request) .then(response => { if (!response.ok) { // TODO: Make connectivity and BesStr issues non-fatal. But show an error sign somewhere in the UI. 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].el, 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].el, endOffset - startingOffset) break } } matches.push({ range: range, rects: this.addMistakeMarkup(range, this.scrollPanel), match: match }) }) this.markProofed(el, matches) }) .catch(error => { // TODO: Make parsing issues non-fatal. But show an error sign somewhere in the UI. throw new Error( 'Parsing backend server response failed: ' + error ) }) } return [{ text: '<' + el.tagName + '/>', el: el, markup: true }] } else { // Surround inline element with dummy .... let data = [{ text: '<' + el.tagName + '>', el: el, markup: true }] for (const el2 of el.childNodes) { data = data.concat(await this.proof(el2)) } data.splice(data.length, 0, { text: '', markup: true }) return data } default: return [{ text: '', el: el, markup: true }] } } createCorrectionPanel(edit) { const panelParent = document.createElement('div') panelParent.classList.add('bes-correction-panel-parent') const correctionPanel = document.createElement('div') const scrollPanel = document.createElement('div') this.setCorrectionPanelSize(edit, correctionPanel, scrollPanel) correctionPanel.classList.add('bes-correction-panel') scrollPanel.classList.add('bes-correction-panel-scroll') correctionPanel.appendChild(scrollPanel) panelParent.appendChild(correctionPanel) edit.parentElement.insertBefore(panelParent, edit) return { correctionPanel, scrollPanel } } // Marks section of text that is about to change as not-yet-grammar-proofed. handleBeforeInput() { if (this.timer) clearTimeout(this.timer) let editor = this this.timer = setTimeout(function () { editor.proof(editor.el) }, 1000) } observeDeletions(editor) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'characterData') { this.clearProofed(this.getBlockParent(mutation.target)) } if ( mutation.type === 'childList' && mutation.removedNodes.length && !mutation.addedNodes.length ) { mutation.removedNodes.forEach(node => { // TODO: This is a temporary solution. We need to handle all cases such as

,
, etc. if (node.nodeName === 'DIV') { this.children = this.children.filter( child => child.elements !== node ) } else console.log(node) }) this.children.forEach(child => { this.clearMistakeMarkup(child.elements) child.matches.forEach(match => { match.rects = this.addMistakeMarkup(match.range, this.scrollPanel) }) }) } }) }) observer.observe(editor, { childList: true, characterData: true, subtree: true }) } // Test if given block element has already been grammar-proofed. isProofed(el) { let filteredChildren = this.children.filter(child => child.elements === el) return filteredChildren[0]?.isProofed } // Mark given block element as grammar-proofed. markProofed(el, matches) { let newChild = { isProofed: true, elements: el, matches: matches } this.children = this.children.map(child => child.elements === newChild.elements ? newChild : child ) if (!this.children.some(child => child.elements === newChild.elements)) { this.children.push(newChild) } } // Mark given block element as not grammar-proofed. clearProofed(el) { let filteredChildren = this.children.filter(child => child.elements === el) if (filteredChildren.length) filteredChildren[0].isProofed = false } // Remove all grammar mistakes markup for given block element. clearMistakeMarkup(el) { let filteredChildren = this.children.filter(child => child.elements === el) if (!filteredChildren.length) return // TODO: Remove elements that are found in editor object, that way we can avoid looping through all elements. filteredChildren[0].matches.forEach(match => { for (const rect of match.rects) { for (let child of this.scrollPanel.children) { let childRect = child.getBoundingClientRect() const isWithinRect = childRect.left >= rect.left && childRect.right <= rect.right && childRect.top >= rect.top && childRect.bottom <= rect.bottom + 20 if (isWithinRect) { child.remove() } } } }) } // Adds grammar mistake markup addMistakeMarkup(range, scrollPanel) { // TODO: Consider using range.getClientRects() instead of range.getBoundingClientRect() const clientRects = range.getClientRects() const scrollPanelRect = scrollPanel.getBoundingClientRect() 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) } return clientRects } // Tests if given element is block element. static isBlockElement(el) { switch ( document.defaultView .getComputedStyle(el, null) .getPropertyValue('display') .toLowerCase() ) { case 'inline': case 'inline-block': return false default: return true } } // Returns first block parent element getBlockParent(el) { for (; el && el !== this.el; el = el.parentNode) { if (el.nodeType === Node.ELEMENT_NODE && BesEditor.isBlockElement(el)) return el } return el } // TODO: Improve this function to support copied rich html content from news sites, etc. handleClick(e) { const targetEl = e.target const popup = document.querySelector('bes-popup-el') if (targetEl.tagName === 'DIV') { const divIndex = this.children.findIndex( child => child.elements === targetEl ) const matches = this.children[divIndex]?.matches if (!matches) { popup.hide() return } if ( BesEditor.renderPopup( targetEl, matches, popup, e.clientX, e.clientY, this ) ) return else popup.hide() } else popup.hide() } handleScrollEvent(editor, scrollPanel) { scrollPanel.style.top = -editor.scrollTop + 'px' this.offsetTop = editor.scrollTop } static renderPopup(el, matches, popup, clientX, clientY, editor) { for (let m of matches) { if (m.rects) { for (let r of m.rects) { if (BesEditor.isPointInRect(clientX, clientY, r, editor.offsetTop)) { popup.changeText(m.match.message) m.match.replacements.forEach(replacement => { popup.appendReplacements( el, r, m.match, replacement.value, editor ) }) popup.show(clientX, clientY) return true } } } else { popup.hide() } } return false } static replaceText(el, rect, match, replacement, editor) { const text = el.textContent const newText = text.substring(0, match.offset) + replacement + text.substring(match.offset + match.length) el.textContent = newText BesEditor.clearSingleMistake(editor, el, rect) // 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. editor.proof(el) } // This function clears a single mistake static clearSingleMistake(editor, el, rect) { const childToDelete = editor.children.filter( child => child.elements === el )[0] childToDelete.isProofed = false childToDelete.matches = childToDelete.matches.filter( match => !BesEditor.isPointInRect(rect.left, rect.top, match.rects[0]) ) // TODO: find a better way to remove elements from the DOM Array.from(editor.scrollPanel.children) .filter(child => { const childRect = child.getBoundingClientRect() return BesEditor.isPointInRect(childRect.left, childRect.top, rect) }) .forEach(child => child.remove()) } setCorrectionPanelSize(editor, correctionPanel, scrollPanel) { const styles = window.getComputedStyle(editor) const totalWidth = parseFloat(styles.width) + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight) + parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight) 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' scrollPanel.style.height = editor.scrollHeight + 'px' } static isPointInRect(x, y, rect, offsetTop) { if (!offsetTop) { return ( x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height ) } else { return ( x >= rect.x && x < rect.x + rect.width && y >= rect.y - offsetTop && y < rect.y + rect.height - offsetTop ) } } } window.onload = () => { // Search and prepare all our editors found in the document. document .querySelectorAll('.bes-online-editor') .forEach(edit => BesEditor.register(edit)) } window.onresize = () => { besEditors.forEach(editor => { editor.setCorrectionPanelSize( editor.el, editor.correctionPanel, editor.scrollPanel ) editor.children.forEach(child => { editor.clearMistakeMarkup(child.elements) child.matches.forEach(match => { match.rects = editor.addMistakeMarkup(match.range, editor.scrollPanel) }) }) }) } // This is popup element class BesPopupEl extends HTMLElement { constructor() { super() this.attachShadow({ mode: 'open' }) } connectedCallback() { this.render() } render() { this.shadowRoot.innerHTML = `
` } show(x, y) { y = y + 20 this.style.position = 'fixed' this.style.left = `${x}px` this.style.top = `${y}px` this.clear() this.classList.add('show') } clear() { const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div') const replacements = replacementDiv.childNodes if (!replacements.length) return replacements.forEach(replacement => { replacementDiv.removeChild(replacement) }) } hide() { this.classList.remove('show') } changeText(text) { this.shadowRoot.querySelector('.popup-text').textContent = text } appendReplacements(el, rect, match, replacement, editor) { 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', () => { BesEditor.replaceText(el, rect, match, replacement, editor) }) replacementDiv.appendChild(replacementBtn) } } customElements.define('bes-popup-el', BesPopupEl)