diff --git a/online-editor.js b/online-editor.js index a0a744f..4323265 100644 --- a/online-editor.js +++ b/online-editor.js @@ -1,23 +1,304 @@ 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 = [] + + this.proof(edit) + edit.addEventListener( + 'beforeinput', + e => this.handleBeforeInput(e), + false + ) + edit.addEventListener('click', e => this.handleClick(e)) + } + + // 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.clearAllMistakes(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 clientRect = BesEditor.addMistake(range, match) + matches.push({ + range: range, + rects: clientRect, + 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 }] + } + } + + // Marks section of text that is about to change as not-yet-grammar-proofed. + handleBeforeInput(event) { + if (this.timer) clearTimeout(this.timer) + let editor = this + this.timer = setTimeout(function () { + editor.proof(editor.el) + }, 1000) + + // No need to invalidate elements after range.startContainer since they will + // get either deleted or replaced. + event + .getTargetRanges() + .forEach(range => + this.clearProofed(this.getBlockParent(range.startContainer)) + ) + } + + // 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. + clearAllMistakes(el) { + let filteredChildren = this.children.filter(child => child.elements === el) + if (!filteredChildren.length) return + + const correctionPanel = document.getElementById('correction-panel') + filteredChildren[0].matches.forEach(match => { + for (const rect of match.rects) { + for (let child of correctionPanel.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 + static addMistake(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('bes-typo-mistake') + 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) + } + return clientRects + } + + // Tests if given element is block element. + static isBlockElement(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 + getBlockParent(el) { + for (; el && el !== this.el; el = el.parentNode) { + if (el.nodeType === Node.ELEMENT_NODE && BesEditor.isBlockElement(el)) return el + } + return el + } + + 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(matches, popup, e.clientX, e.clientY)) return + } else { + popup.hide() + } + } + + static renderPopup(matches, popup, clientX, clientY) { + 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( + replacement.value, + m.match.offset, + m.match.length + ) + }) + popup.show(clientX, clientY) + return true + } + } + } else { + popup.hide() + } + } + return false + } + + static isPointInRect(x, y, rect) { + return ( + x >= rect.x && + x < rect.x + rect.width && + y >= rect.y && + y < rect.y + rect.height + ) + } +} + +let besEditors = [] // Collection of all editors on page window.onload = () => { // Search and prepare all our editors found in the document. document.querySelectorAll('.bes-online-editor').forEach(edit => { - let editor = { - el: edit, - timer: null, - children: [] - } + let editor = new BesEditor(edit) besEditors[edit.id] = editor - besProof(editor, edit) - edit.addEventListener( - 'beforeinput', - e => besHandleBeforeInput(editor, e), - false - ) - edit.addEventListener('click', e => besHandleClick(editor, e)) }) } @@ -25,286 +306,11 @@ window.onresize = () => { Object.keys(besEditors).forEach(key => { let editor = besEditors[key] editor.children.forEach(child => { - besClearAllMistakes(editor, child?.elements) + editor.clearAllMistakes(child?.elements) child.matches.forEach(match => { - const clientRect = besAddMistake(match.range, match) + const clientRect = BesEditor.addMistake(match.range, match) match.rects = clientRect }) }) }) } - -// Recursively grammar-proofs one node. -async function besProof(editor, el) { - switch (el.nodeType) { - case Node.TEXT_NODE: - return [{ text: el.textContent, el: el, markup: false }] - - case Node.ELEMENT_NODE: - if (besIsBlockElement(el)) { - // Block elements are grammar-proofed independently. - if (besIsProofed(editor, el)) { - return [{ text: '<' + el.tagName + '/>', el: el, markup: true }] - } - besClearAllMistakes(editor, el) - let data = [] - for (const el2 of el.childNodes) { - data = data.concat(await besProof(editor, 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 clientRect = besAddMistake(range, match) - matches.push({ - range: range, - rects: clientRect, - match: match - }) - }) - - besMarkProofed(editor, 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 besProof(editor, el2)) - } - data.splice(data.length, 0, { - text: '', - markup: true - }) - return data - } - - default: - return [{ text: '', el: el, markup: true }] - } -} - -// Marks section of text that is about to change as not-yet-grammar-proofed. -function besHandleBeforeInput(editor, event) { - if (editor.timer) clearTimeout(editor.timer) - editor.timer = setTimeout(function () { - besProof(editor, editor.el) - }, 1000) - - // No need to invalidate elements after range.startContainer since they will - // get either deleted or replaced. - event - .getTargetRanges() - .forEach(range => - besClearProofed(editor, besGetBlockParent(editor, range.startContainer)) - ) -} - -// Test if given block element has already been grammar-proofed. -function besIsProofed(editor, el) { - let filteredChildren = editor?.children.filter(child => child.elements === el) - return filteredChildren[0]?.isProofed -} - -// Mark given block element as grammar-proofed. -function besMarkProofed(editor, el, matches) { - let newChild = { - isProofed: true, - elements: el, - matches: matches - } - - editor.children = editor.children.map(child => - child.elements === newChild.elements ? newChild : child - ) - if (!editor.children.some(child => child.elements === newChild.elements)) { - editor.children.push(newChild) - } -} - -// Mark given block element as not grammar-proofed. -function besClearProofed(editor, el) { - let filteredChildren = editor.children.filter(child => child.elements === el) - if (filteredChildren.length) filteredChildren[0].isProofed = false -} - -// Remove all grammar mistakes markup for given block element. -function besClearAllMistakes(editor, el) { - let filteredChildren = editor?.children.filter(child => child.elements === el) - if (!filteredChildren.length) return - - const correctionPanel = document.getElementById('correction-panel') - filteredChildren[0].matches.forEach(match => { - for (const rect of match.rects) { - for (let child of correctionPanel.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 -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('bes-typo-mistake') - 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) - } - return clientRects -} - -// 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(editor, el) { - for (; el && el !== editor.el; el = el.parentNode) { - if (el.nodeType === Node.ELEMENT_NODE && besIsBlockElement(el)) return el - } - return el -} - -function besHandleClick(editor, e) { - const targetEl = e.target - const popup = document.querySelector('bes-popup-el') - if (targetEl.tagName === 'DIV') { - const divIndex = editor.children.findIndex( - child => child.elements === targetEl - ) - const matches = editor.children[divIndex]?.matches - if (!matches) { - popup.hide() - return - } - if (besRenderPopup(matches, popup, e.clientX, e.clientY)) return - } else { - popup.hide() - } -} - -function besRenderPopup(matches, popup, clientX, clientY) { - for (let m of matches) { - if (m.rects) { - for (let r of m.rects) { - if (besIsPointInRect(clientX, clientY, r)) { - popup.changeText(m.match.message) - m.match.replacements.forEach(replacement => { - popup.appendReplacements( - replacement.value, - m.match.offset, - m.match.length - ) - }) - popup.show(clientX, clientY) - return true - } - } - } else { - popup.hide() - } - } - return false -} - -function besIsPointInRect(x, y, rect) { - return ( - x >= rect.x && - x < rect.x + rect.width && - y >= rect.y && - y < rect.y + rect.height - ) -}