const besUrl = 'http://localhost:225/api/v2/check' let besEditors = [] // Collection of all grammar checking services in the document class BesEditor { constructor(edit, isCkeditor) { this.el = edit this.timer = null this.children = [] const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit) this.correctionPanel = correctionPanel this.scrollPanel = scrollPanel this.offsetTop = null // TODO: consider simplfing ckeditor handling by using a single constructor parameter this.isCKeditor = !!isCkeditor this.CKEditorInstance = isCkeditor edit.classList.add('bes-online-editor') this.originalSpellcheck = edit.spellcheck edit.spellcheck = false this.proof(edit) edit.addEventListener('beforeinput', BesEditor.handleBeforeInput, false) edit.addEventListener('click', BesEditor.handleClick) edit.addEventListener('scroll', BesEditor.handleScroll) besEditors.push(this) } /** * Registers grammar checking service * * @param {Element} edit DOM element to register grammar checking service for * @param {Boolean} isCkeditor Enable CKEditor tweaks * @returns {BesEditor} Grammar checking service instance */ static register(edit, isCkeditor) { return new BesEditor(edit, isCkeditor) } /** * Unregisters grammar checking service */ unregister() { this.el.removeEventListener('scroll', BesEditor.handleScroll) this.el.removeEventListener('click', BesEditor.handleClick) this.el.removeEventListener( 'beforeinput', BesEditor.handleBeforeInput, false ) if (this.timer) clearTimeout(this.timer) besEditors = besEditors.filter(item => item !== this) this.el.spellcheck = this.originalSpellcheck this.el.classList.remove('bes-online-editor') this.correctionPanel.remove() this.scrollPanel.remove() } // TODO: add support for textarea elements /** * Recursively grammar-proofs a DOM tree. * * @param {Node} el DOM root node to proof * @returns {Array} Markup of text to proof using BesStr */ async proof(el) { // If first child is not a block element, add a dummy
...
around it. // This solution is still not fully tested and might need some improvements. if (el.classList?.contains('bes-online-editor')) { const firstChild = el.firstChild if ( firstChild && (firstChild.nodeType === Node.TEXT_NODE || !BesEditor.isBlockElement(firstChild)) ) { const divEl = document.createElement('div') if (firstChild.nodeType === Node.TEXT_NODE) { divEl.textContent = firstChild.textContent } else divEl.appendChild(firstChild.cloneNode(true)) el.insertBefore(divEl, firstChild) el.removeChild(firstChild) } } 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 } } const { clientRects, highlights } = this.addMistakeMarkup(range) matches.push({ rects: clientRects, highlights: highlights, range: range, match: match }) }) this.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.isCKeditor) { const resizeEvent = new Event('resize') window.dispatchEvent(resizeEvent) } }) .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 } } /** * 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 edit = event.target let editor = besEditors.find(e => e.el === edit) if (!editor) return if (editor.timer) clearTimeout(editor.timer) let blockElements = new Set() event.getTargetRanges().forEach(range => { BesEditor.getNodesInRange(range).forEach(el => blockElements.add(editor.getBlockParent(el)) ) }) blockElements.forEach(block => { editor.clearMistakeMarkup(block) editor.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(() => { editor.repositionMistakes() }, 0) editor.timer = setTimeout(function () { editor.proof(edit) }, 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.elements === 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, elements: el, // TODO: Rename "elements" to "el" - 1. It contains only single element (plural elements is misleading), 2. BesEditor also uses "el" named field for DOM matching. matches: matches }) } /** * 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.elements === el) if (!child) return child.isProofed = false child.matches.forEach(match => { 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.elements !== el) } /** * Updates grammar mistake markup positions. */ repositionMistakes() { this.children.forEach(child => { this.clearMistakeMarkup(child.elements) 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) { // TODO: Consider using range.getClientRects() instead of range.getBoundingClientRect() // In CKEditor case, the highlight element is not shown for some reason. But after resizing the window it is shown. 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 } } /** * Tests if given element is block element. * * @param {Element} el DOM element * @returns false if CSS display property is inline or inline-block; true otherwise. */ 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 of a node. * * @param {Node} el DOM node * @returns {Element} Innermost block element containing given node */ getBlockParent(el) { for (; el && el !== this.el; el = el.parentNode) { if (el.nodeType === Node.ELEMENT_NODE && BesEditor.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 = BesEditor.getParents(start) let endAncestors = BesEditor.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 = BesEditor.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 edit = BesEditor.findParent(event.target) let editor = besEditors.find(e => e.el === edit) if (!editor) return const target = editor.getBlockParent(event.target) editor.renderPopup(target, event.clientX, event.clientY) } /** * scroll event handler * * Syncs grammar mistake positions with editor scroll offset. * * @param {Event} event The event which takes place. */ static handleScroll(event) { const edit = event.target let editor = besEditors.find(e => e.el === edit) if (!editor) return editor.scrollPanel.style.top = -edit.scrollTop + 'px' editor.offsetTop = edit.scrollTop setTimeout(() => { editor.repositionMistakes() }, 300) // TODO: Move popup (if open) too. } /** * Finds the editor with grammar checking service a DOM node is child of. * * @param {Node} el DOM node * @returns {Element} Editor DOM element; null if DOM node is not a descendant of an editor. */ static findParent(el) { for (; el; el = el.parentNode) { if ( el.classList?.contains('bes-online-editor') || el.classList?.contains('ck-editor__editable') // Find a better way to handle CKEditor ) { 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.elements === el)?.matches if (matches) { for (let m of matches) { if (m.rects) { for (let r of m.rects) { if (BesEditor.isPointInRect(clientX, clientY, r)) { popup.changeText(m.match.message) m.match.replacements.forEach(replacement => { popup.appendReplacements( el, r, m.match, replacement.value, this ) }) popup.show(clientX, clientY) return } } } } } popup.hide() } // TODO: In rich HTML texts, match.offset has different value than in plain text. // This function should be able to handle both cases or find a way that works for both. static replaceText(el, rect, match, replacement, editor) { // const tags = this.getTagsAndText(el) const text = el.textContent const newText = text.substring(0, match.offset) + replacement + text.substring(match.offset + match.length) el.textContent = newText if (editor.isCKeditor) { const { CKEditorInstance } = editor CKEditorInstance.model.change(writer => { // Find the corresponding element in the model. const viewElement = CKEditorInstance.editing.view.domConverter.mapDomToView(el) const modelElement = CKEditorInstance.editing.mapper.toModelElement(viewElement) if (modelElement) { writer.remove(writer.createRangeIn(modelElement)) writer.insertText(newText, modelElement, 'end') } }) } 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) } // static getTagsAndText(node) { // if (node.nodeType === Node.TEXT_NODE) { // return node.textContent // } else if (node.nodeType === Node.ELEMENT_NODE) { // let tag = node.tagName.toLowerCase() // let content = Array.from(node.childNodes) // .map(BesEditor.getTagsAndText) // .join('') // return `<${tag}>${content}` // } else { // return '' // } // } // 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) { return ( x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height ) } } 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 => { const { clientRects, highlights } = editor.addMistakeMarkup(match.range) match.rects = clientRects match.highlights = highlights }) }) }) } // 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.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() } } hide() { this.classList.remove('show') } changeText(text) { this.clear() 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)