const besUrl = 'http://localhost:225/api/v2/check' let besEditors = {} // Collection of all editors on page window.onload = () => { // Search and prepare all our editors found in the document. document.querySelectorAll('.online-editor').forEach(edit => { let editor = { timer: null } besEditors[edit.id] = editor besCheckText(edit) edit.addEventListener('beforeinput', e => besBeforeInput(edit.id, e), false) edit.addEventListener('click', e => { besHandleClick(e) }) }) } function besIntervalsOverlap(start1, end1, start2, end2) { return ( start2 <= start1 && start1 < end2 || start1 <= start2 && start2 < end1) } async function besCheckText(el) { switch (el.nodeType) { case Node.TEXT_NODE: return [{ text: el.textContent, el: el, markup: false }] case Node.ELEMENT_NODE: if (el.getAttribute('besChecked') === 'true') { return [{ text: '<'+el.tagName+'/>', el: el, markup: true }] } for (const el2 of el.childNodes) { if (el2.tagName === 'SPAN' && el2.classList.contains('typo-mistake')) { el2.replaceWith(...el2.childNodes) el2.remove() } } let data = [] for (const el2 of el.childNodes) { data = data.concat(await besCheckText(el2)) } let defaultView = document.defaultView; switch (defaultView.getComputedStyle(el, null).getPropertyValue('display').toLowerCase()) { case 'inline': case 'inline-block': data.splice(0, 0, { text: '<'+el.tagName+'>', el: el, markup: true }) data.splice(data.length, 0, { text: '', markup: true }) return data; default: let regAllWhitespace = /^\s*$/ if (data.some(x => !x.markup && !regAllWhitespace.test(x.text))) { const requestData = { format: 'plain', data: JSON.stringify({annotation: data.map(x => x.markup ? { markup: x.text } : { text: x.text })}), language: '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. throw new Error('Backend server response was not OK') } return response.json() }) .then(responseData => { if (!responseData.matches.length) return // Remove overlapping grammar mistakes for simplicity. for (let idx1 = 0; idx1 < responseData.matches.length - 1; ++idx1) { for (let idx2 = idx1 + 1; idx2 < responseData.matches.length;) { if (besIntervalsOverlap( responseData.matches[idx1].offset, responseData.matches[idx1].offset + responseData.matches[idx1].length, responseData.matches[idx2].offset, responseData.matches[idx2].offset + responseData.matches[idx2].length)) { responseData.matches.splice(idx2, 1) } else idx2++ } } // Reverse sort grammar mistakes for easier markup insertion later. // When we start inserting grammar mistakes at the back, indexes before that remain valid. responseData.matches.sort((a, b) => a.offset < b.offset ? +1 : a.offset > b.offset ? -1 : 0); 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 span = document.createElement('span') span.classList.add('typo-mistake') // Dirty hack, copied from: https://stackoverflow.com/questions/67634286/invalidstateerror-failed-to-execute-surroundcontents-on-range-the-range-ha span.appendChild(range.extractContents()); range.insertNode(span); // This is a better way to apply span elements, // but it doesnt work when the range has partially selected a non-Text node. // range.surroundContents(span) }) el.setAttribute('besChecked', 'true') }) .catch(error => { // TODO: Make parsing issues non-fatal. throw new Error('Parsing backend server response failed: ' + error) }) } return [{ text: '<'+el.tagName+'/>', el: el, markup: true }] } default: return [{ text: '', el: el, markup: true }] } } function besGetBlockParent(el, edit) { const defaultView = document.defaultView while (el && el !== edit) { switch (el.nodeType) { case Node.TEXT_NODE: el = el.parentNode continue case Node.ELEMENT_NODE: switch (defaultView.getComputedStyle(el, null).getPropertyValue('display').toLowerCase()) { case 'inline': case 'inline-block': el = el.parentNode continue default: return el } } } return el } function besBeforeInput(editorId, event) { let editor = besEditors[editorId] if (editor.timer) clearTimeout(editor.timer) editor.timer = setTimeout(function(){ besCheckText(edit) }, 1000) // No need to invalidate elements after range.startContainer since they will // get either deleted or replaced. let edit = document.getElementById(editorId) event.getTargetRanges().forEach(range => besGetBlockParent(range.startContainer, edit)?.removeAttribute('besChecked')) } function besHandleClick(e) { switch (e.target) { case e.target.closest('span'): const clicked = e.target.closest('span') const infoText = clicked?.dataset.info const myComponent = document.querySelector('my-component') myComponent.setAttribute('my-attribute', infoText) console.log(clicked) break } }