diff --git a/online-editor.js b/online-editor.js index d1ec829..e42905e 100644 --- a/online-editor.js +++ b/online-editor.js @@ -9,182 +9,196 @@ window.onload = () => { timer: null } besEditors[edit.id] = editor - besCheckText(edit) - + besProof(edit) edit.addEventListener('beforeinput', e => besBeforeInput(edit.id, e), false) - - edit.addEventListener('click', e => { - besHandleClick(e) - }) + 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) +// Recursively grammar-proofs one node. +async function besProof(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() + if (besIsBlockElement(el)) { + // Block elements are grammar-proofed independently. + if (besIsProofed(el)) { + return [{ text: '<'+el.tagName+'/>', el: el, markup: true }] } - } - 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) + besClearAllMistakes(el) + let data = [] + for (const el2 of el.childNodes) { + data = data.concat(await besProof(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: '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() }) - 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 + .then(responseData => { + responseData.matches.forEach(match => { + let range = document.createRange() - // 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++ + // 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 } } - // 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 } + } - // 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 = range.getClientRects() - const correctionPanel = document.getElementById('correction-panel'); - const rect = clientRects[0]; - const highlight = document.createElement("div"); - highlight.classList.add("typo-mistake"); - highlight.dataset.info = match.message; - highlight.style.left = `${rect.left}px`; - highlight.style.top = `${rect.top}px`; - highlight.style.width = `${rect.width}px`; - highlight.style.height = `${rect.height}px`; - correctionPanel.appendChild(highlight); - - // TODO: Find a solution to handle click events on the highlights - // const editor = document.querySelector('.online-editor') - // highlight.addEventListener("click", function(e) { - // console.log(e); - // editor.focus(); - // besHandleClick(e); - // return true; - // }); - }) - - el.setAttribute('besChecked', 'true') - }) - .catch(error => { - // TODO: Make parsing issues non-fatal. - throw new Error('Parsing backend server response failed: ' + error) + besAddMistake(range, match) }) + + besMarkProofed(el) + }) + .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 besProof(el2)) + } + data.splice(data.length, 0, { text: '', markup: true }) + return data; + } 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 -} - +// Marks section of text that is about to change as not-yet-grammar-proofed. function besBeforeInput(editorId, event) { let editor = besEditors[editorId] if (editor.timer) clearTimeout(editor.timer) - editor.timer = setTimeout(function(){ besCheckText(edit) }, 1000) + editor.timer = setTimeout(function(){ besProof(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')) + event.getTargetRanges().forEach(range => besClearProofed(besGetBlockParent(range.startContainer, edit))) +} + +// Test if given block element has already been grammar-proofed. +function besIsProofed(el) +{ + return el.getAttribute('besProofed') === 'true' +} + +// Mark given block element as grammar-proofed. +function besMarkProofed(el) +{ + el.setAttribute('besProofed', 'true') +} + +// Mark given block element as not grammar-proofed. +function besClearProofed(el) +{ + el?.removeAttribute('besProofed') +} + +// Remove all grammar mistakes markup for given block element. +function besClearAllMistakes(el) { + for (const el2 of el.childNodes) { + if (el2.tagName === 'SPAN' && el2.classList.contains('typo-mistake')) { + el2.replaceWith(...el2.childNodes) + el2.remove() + } + } +} + +// Adds grammar mistake markup +function besAddMistake(range, match) { + const correctionPanel = document.getElementById('correction-panel'); + const clientRects = range.getClientRects() + for (let i = 0, n = clientRects.length; i < n; ++i) { + const rect = clientRects[i] + const highlight = document.createElement("div"); + highlight.classList.add("typo-mistake"); + highlight.dataset.info = match.message; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${rect.width}px`; + highlight.style.height = `${rect.height}px`; + correctionPanel.appendChild(highlight); + + // TODO: Find a solution to handle click events on the highlights + // const editor = document.querySelector('.online-editor') + // highlight.addEventListener("click", function(e) { + // console.log(e); + // editor.focus(); + // besHandleClick(e); + // return true; + // }); + } +} + +// Tests if given element is block element. +function besIsBlockElement(el) +{ + const defaultView = document.defaultView + switch (defaultView.getComputedStyle(el, null).getPropertyValue('display').toLowerCase()) + { + case 'inline': + case 'inline-block': + return false; + default: + return true + } +} + +// Returns first block parent element +function besGetBlockParent(el, edit) +{ + while (el && el !== edit) { + switch (el.nodeType) { + case Node.TEXT_NODE: + el = el.parentNode + break + + case Node.ELEMENT_NODE: + if (besIsBlockElement(el)) { + return el + } + el = el.parentNode + } + } + return el } function besHandleClick(e) {