Fix various issues

This commit is contained in:
Simon Rozman 2024-03-13 11:58:38 +01:00
parent 374dd0045a
commit ef6615941b
2 changed files with 75 additions and 129 deletions

View File

@ -22,21 +22,4 @@
}) })
</script> </script>
</body> </body>
<style>
.bes-online-editor {
width: 80%;
height: 300px;
margin: 0 auto;
padding: 20px;
border-radius: 10px;
background-color: #f5f5f5;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
line-height: 1.6;
white-space: pre-wrap;
overflow-y: auto;
font-family: Arial, Helvetica, sans-serif;
z-index: 2;
}
</style>
</html> </html>

View File

@ -2,9 +2,11 @@ const besUrl = 'http://localhost:225/api/v2/check'
let besEditors = [] // Collection of all grammar checking services in the document let besEditors = [] // Collection of all grammar checking services in the document
// TODO: Add support for <textarea>
class BesEditor { class BesEditor {
constructor(edit, CKEditorInstance) { constructor(edit, CKEditorInstance) {
this.el = edit this.editorElement = edit
this.timer = null this.timer = null
this.children = [] this.children = []
const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit) const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit)
@ -12,9 +14,9 @@ class BesEditor {
this.scrollPanel = scrollPanel this.scrollPanel = scrollPanel
this.offsetTop = null this.offsetTop = null
this.CKEditorInstance = CKEditorInstance this.CKEditorInstance = CKEditorInstance
if (!this.CKEditorInstance) edit.classList.add('bes-online-editor')
this.originalSpellcheck = edit.spellcheck this.originalSpellcheck = edit.spellcheck
edit.spellcheck = false edit.spellcheck = false
this.abortController = new AbortController()
this.proof(edit) this.proof(edit)
edit.addEventListener('beforeinput', BesEditor.handleBeforeInput, false) edit.addEventListener('beforeinput', BesEditor.handleBeforeInput, false)
edit.addEventListener('click', BesEditor.handleClick) edit.addEventListener('click', BesEditor.handleClick)
@ -37,60 +39,42 @@ class BesEditor {
* Unregisters grammar checking service * Unregisters grammar checking service
*/ */
unregister() { unregister() {
this.el.removeEventListener('scroll', BesEditor.handleScroll) this.editorElement.removeEventListener('scroll', BesEditor.handleScroll)
this.el.removeEventListener('click', BesEditor.handleClick) this.editorElement.removeEventListener('click', BesEditor.handleClick)
this.el.removeEventListener( this.editorElement.removeEventListener(
'beforeinput', 'beforeinput',
BesEditor.handleBeforeInput, BesEditor.handleBeforeInput,
false false
) )
if (this.timer) clearTimeout(this.timer) if (this.timer) clearTimeout(this.timer)
besEditors = besEditors.filter(item => item !== this) besEditors = besEditors.filter(item => item !== this)
this.el.spellcheck = this.originalSpellcheck this.editorElement.spellcheck = this.originalSpellcheck
this.el.classList.remove('bes-online-editor')
this.correctionPanel.remove() this.correctionPanel.remove()
this.scrollPanel.remove() this.scrollPanel.remove()
} }
// TODO: add support for textarea elements
/** /**
* Recursively grammar-proofs a DOM tree. * Recursively grammar-proofs a DOM tree.
* *
* @param {Node} el DOM root node to proof * @param {Node} node DOM root node to proof
* @returns {Array} Markup of text to proof using BesStr * @returns {Array} Markup of text to proof using BesStr
*/ */
async proof(el) { async proof(node) {
// If first child is not a block element, add a dummy <div>...</div> around it. switch (node.nodeType) {
// 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: case Node.TEXT_NODE:
return [{ text: el.textContent, el: el, markup: false }] return [{ text: node.textContent, node: node, markup: false }]
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
if (BesEditor.isBlockElement(el)) { if (BesEditor.isBlockElement(node)) {
// Block elements are grammar-proofed independently. // Block elements are grammar-proofed independently.
if (this.isProofed(el)) { if (this.isProofed(node)) {
return [{ text: '<' + el.tagName + '/>', el: el, markup: true }] return [
{ text: '<' + node.tagName + '/>', node: node, markup: true }
]
} }
this.clearMistakeMarkup(el) this.clearMistakeMarkup(node)
let data = [] let data = []
for (const el2 of el.childNodes) { for (const el2 of node.childNodes) {
data = data.concat(await this.proof(el2)) data = data.concat(await this.proof(el2))
} }
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) { if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
@ -101,7 +85,7 @@ class BesEditor {
x.markup ? { markup: x.text } : { text: x.text } x.markup ? { markup: x.text } : { text: x.text }
) )
}), }),
language: el.lang ? el.lang : 'sl', language: node.lang ? node.lang : 'sl',
level: 'picky' level: 'picky'
} }
const request = new Request(besUrl, { const request = new Request(besUrl, {
@ -109,7 +93,8 @@ class BesEditor {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(requestData) body: new URLSearchParams(requestData)
}) })
fetch(request) const signal = this.abortController.signal
fetch(request, { signal })
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
// TODO: Make connectivity and BesStr issues non-fatal. But show an error sign somewhere in the UI. // TODO: Make connectivity and BesStr issues non-fatal. But show an error sign somewhere in the UI.
@ -134,7 +119,7 @@ class BesEditor {
startingOffset + data[idx].text.length startingOffset + data[idx].text.length
) { ) {
range.setStart( range.setStart(
data[idx].el, data[idx].node,
match.offset - startingOffset match.offset - startingOffset
) )
break break
@ -153,7 +138,7 @@ class BesEditor {
/*startingOffset <= endOffset &&*/ endOffset <= /*startingOffset <= endOffset &&*/ endOffset <=
startingOffset + data[idx].text.length startingOffset + data[idx].text.length
) { ) {
range.setEnd(data[idx].el, endOffset - startingOffset) range.setEnd(data[idx].node, endOffset - startingOffset)
break break
} }
} }
@ -167,37 +152,34 @@ class BesEditor {
match: match match: match
}) })
}) })
this.markProofed(el, matches) this.markProofed(node, matches)
// This is a solution for displaying mistakes in CKEditor. It is not the best solution, but it works for now.
if (this.CKEditorInstance) {
const resizeEvent = new Event('resize')
window.dispatchEvent(resizeEvent)
}
}) })
.catch(error => { .catch(error => {
if (error.name === 'AbortError') return
// TODO: Make parsing issues non-fatal. But show an error sign somewhere in the UI. // TODO: Make parsing issues non-fatal. But show an error sign somewhere in the UI.
throw new Error( throw new Error(
'Parsing backend server response failed: ' + error 'Parsing backend server response failed: ' + error
) )
}) })
} }
return [{ text: '<' + el.tagName + '/>', el: el, markup: true }] return [{ text: '<' + node.tagName + '/>', node: node, markup: true }]
} else { } else {
// Surround inline element with dummy <tagName>...</tagName>. // Surround inline element with dummy <tagName>...</tagName>.
let data = [{ text: '<' + el.tagName + '>', el: el, markup: true }] let data = [
for (const el2 of el.childNodes) { { text: '<' + node.tagName + '>', node: node, markup: true }
]
for (const el2 of node.childNodes) {
data = data.concat(await this.proof(el2)) data = data.concat(await this.proof(el2))
} }
data.splice(data.length, 0, { data.splice(data.length, 0, {
text: '</' + el.tagName + '>', text: '</' + node.tagName + '>',
markup: true markup: true
}) })
return data return data
} }
default: default:
return [{ text: '<?' + el.nodeType + '>', el: el, markup: true }] return [{ text: '<?' + node.nodeType + '>', node: node, markup: true }]
} }
} }
@ -226,9 +208,10 @@ class BesEditor {
*/ */
static handleBeforeInput(event) { static handleBeforeInput(event) {
const edit = event.target const edit = event.target
let editor = besEditors.find(e => e.el === edit) let editor = besEditors.find(e => e.editorElement === edit)
if (!editor) return if (!editor) return
if (editor.timer) clearTimeout(editor.timer) if (editor.timer) clearTimeout(editor.timer)
editor.abortController.abort()
let blockElements = new Set() let blockElements = new Set()
event.getTargetRanges().forEach(range => { event.getTargetRanges().forEach(range => {
BesEditor.getNodesInRange(range).forEach(el => BesEditor.getNodesInRange(range).forEach(el =>
@ -244,6 +227,7 @@ class BesEditor {
editor.repositionMistakes() editor.repositionMistakes()
}, 0) }, 0)
editor.timer = setTimeout(function () { editor.timer = setTimeout(function () {
editor.abortController = new AbortController()
editor.proof(edit) editor.proof(edit)
}, 1000) }, 1000)
} }
@ -255,7 +239,7 @@ class BesEditor {
* @returns {Boolean} true if the element has already been grammar-proofed; false otherwise. * @returns {Boolean} true if the element has already been grammar-proofed; false otherwise.
*/ */
isProofed(el) { isProofed(el) {
return this.children.find(child => child.elements === el)?.isProofed return this.children.find(child => child.element === el)?.isProofed
} }
/** /**
@ -268,9 +252,12 @@ class BesEditor {
this.removeChild(el) this.removeChild(el)
this.children.push({ this.children.push({
isProofed: true, 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. element: el,
matches: matches matches: matches
}) })
// This is a solution for displaying mistakes in CKEditor. It is not the best solution, but it works for now.
if (this.CKEditorInstance) window.dispatchEvent(new Event('resize'))
} }
/** /**
@ -279,12 +266,14 @@ class BesEditor {
* @param {Element} el DOM element that we should re-grammar-proof * @param {Element} el DOM element that we should re-grammar-proof
*/ */
clearMistakeMarkup(el) { clearMistakeMarkup(el) {
let child = this.children.find(child => child.elements === el) let child = this.children.find(child => child.element === el)
if (!child) return if (!child) return
child.isProofed = false child.isProofed = false
child.matches.forEach(match => { child.matches.forEach(match => {
match?.highlights.forEach(h => h.remove()) if (match?.highlights) {
delete match.highlights match.highlights.forEach(h => h.remove())
delete match.highlights
}
}) })
} }
@ -294,7 +283,7 @@ class BesEditor {
* @param {Element} el DOM element for removal * @param {Element} el DOM element for removal
*/ */
removeChild(el) { removeChild(el) {
this.children = this.children.filter(child => child.elements !== el) this.children = this.children.filter(child => child.element !== el)
} }
/** /**
@ -302,7 +291,7 @@ class BesEditor {
*/ */
repositionMistakes() { repositionMistakes() {
this.children.forEach(child => { this.children.forEach(child => {
this.clearMistakeMarkup(child.elements) this.clearMistakeMarkup(child.element)
child.matches.forEach(match => { child.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range) const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects match.rects = clientRects
@ -318,7 +307,6 @@ class BesEditor {
* @returns {Object} Client rectangles and grammar mistake highlight elements * @returns {Object} Client rectangles and grammar mistake highlight elements
*/ */
addMistakeMarkup(range) { 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. // 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 clientRects = range.getClientRects()
const scrollPanelRect = this.scrollPanel.getBoundingClientRect() const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
@ -343,7 +331,7 @@ class BesEditor {
* Tests if given element is block element. * Tests if given element is block element.
* *
* @param {Element} el DOM element * @param {Element} el DOM element
* @returns false if CSS display property is inline or inline-block; true otherwise. * @returns false if CSS display property is inline; true otherwise.
*/ */
static isBlockElement(el) { static isBlockElement(el) {
switch ( switch (
@ -353,7 +341,6 @@ class BesEditor {
.toLowerCase() .toLowerCase()
) { ) {
case 'inline': case 'inline':
case 'inline-block':
return false return false
default: default:
return true return true
@ -367,7 +354,7 @@ class BesEditor {
* @returns {Element} Innermost block element containing given node * @returns {Element} Innermost block element containing given node
*/ */
getBlockParent(el) { getBlockParent(el) {
for (; el && el !== this.el; el = el.parentNode) { for (; el && el !== this.editorElement; el = el.parentNode) {
if (el.nodeType === Node.ELEMENT_NODE && BesEditor.isBlockElement(el)) if (el.nodeType === Node.ELEMENT_NODE && BesEditor.isBlockElement(el))
return el return el
} }
@ -454,7 +441,7 @@ class BesEditor {
*/ */
static handleClick(event) { static handleClick(event) {
const edit = BesEditor.findParent(event.target) const edit = BesEditor.findParent(event.target)
let editor = besEditors.find(e => e.el === edit) let editor = besEditors.find(e => e.editorElement === edit)
if (!editor) return if (!editor) return
const target = editor.getBlockParent(event.target) const target = editor.getBlockParent(event.target)
editor.renderPopup(target, event.clientX, event.clientY) editor.renderPopup(target, event.clientX, event.clientY)
@ -469,13 +456,13 @@ class BesEditor {
*/ */
static handleScroll(event) { static handleScroll(event) {
const edit = event.target const edit = event.target
let editor = besEditors.find(e => e.el === edit) let editor = besEditors.find(e => e.editorElement === edit)
if (!editor) return if (!editor) return
editor.scrollPanel.style.top = -edit.scrollTop + 'px' editor.scrollPanel.style.top = -edit.scrollTop + 'px'
editor.offsetTop = edit.scrollTop editor.offsetTop = edit.scrollTop
setTimeout(() => { setTimeout(() => {
editor.repositionMistakes() editor.repositionMistakes()
}, 300) }, 100)
// TODO: Move popup (if open) too. // TODO: Move popup (if open) too.
} }
@ -487,10 +474,7 @@ class BesEditor {
*/ */
static findParent(el) { static findParent(el) {
for (; el; el = el.parentNode) { for (; el; el = el.parentNode) {
if ( if (besEditors.find(editor => editor.editorElement === el)) {
el.classList?.contains('bes-online-editor') ||
el.classList?.contains('ck-editor__editable') // Find a better way to handle CKEditor
) {
return el return el
} }
} }
@ -506,7 +490,7 @@ class BesEditor {
*/ */
renderPopup(el, clientX, clientY) { renderPopup(el, clientX, clientY) {
const popup = document.querySelector('bes-popup-el') const popup = document.querySelector('bes-popup-el')
const matches = this.children.find(child => child.elements === el)?.matches const matches = this.children.find(child => child.element === el)?.matches
if (matches) { if (matches) {
for (let m of matches) { for (let m of matches) {
if (m.rects) { if (m.rects) {
@ -516,8 +500,7 @@ class BesEditor {
m.match.replacements.forEach(replacement => { m.match.replacements.forEach(replacement => {
popup.appendReplacements( popup.appendReplacements(
el, el,
r, m,
m.match,
replacement.value, replacement.value,
this this
) )
@ -534,17 +517,15 @@ class BesEditor {
// TODO: In rich HTML texts, match.offset has different value than in plain text. // 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. // This function should be able to handle both cases or find a way that works for both.
static replaceText(el, rect, match, replacement, editor) { replaceText(el, match, replacement) {
if (this.timer) clearTimeout(this.timer)
this.abortController.abort()
// const tags = this.getTagsAndText(el) // const tags = this.getTagsAndText(el)
if (!editor.CKEditorInstance) { if (!this.CKEditorInstance) {
const text = el.textContent match.range.deleteContents()
const newText = match.range.insertNode(document.createTextNode(replacement))
text.substring(0, match.offset) +
replacement +
text.substring(match.offset + match.length)
el.textContent = newText
} else { } else {
const { CKEditorInstance } = editor const { CKEditorInstance } = this
CKEditorInstance.model.change(writer => { CKEditorInstance.model.change(writer => {
const viewElement = const viewElement =
CKEditorInstance.editing.view.domConverter.mapDomToView(el) CKEditorInstance.editing.view.domConverter.mapDomToView(el)
@ -552,15 +533,15 @@ class BesEditor {
CKEditorInstance.editing.mapper.toModelElement(viewElement) CKEditorInstance.editing.mapper.toModelElement(viewElement)
if (modelElement) { if (modelElement) {
const elementRange = writer.createRangeIn(modelElement) const elementRange = writer.createRangeIn(modelElement)
// TODO: This logic should work once the HTML tags are removed from match.offset and match.length if is possible. // TODO: This logic should work once the HTML tags are removed from match.match.offset and match.match.length if is possible.
if ( if (
elementRange.start.offset <= match.offset && elementRange.start.offset <= match.match.offset &&
elementRange.end.offset >= match.offset + match.length elementRange.end.offset >= match.match.offset + match.match.length
) { ) {
const start = writer.createPositionAt(modelElement, match.offset) const start = writer.createPositionAt(modelElement, match.match.offset)
const end = writer.createPositionAt( const end = writer.createPositionAt(
modelElement, modelElement,
match.offset + match.length match.match.offset + match.match.length
) )
const range = writer.createRange(start, end) const range = writer.createRange(start, end)
writer.remove(range) writer.remove(range)
@ -569,11 +550,12 @@ class BesEditor {
} }
}) })
} }
BesEditor.clearSingleMistake(editor, el, rect) this.clearMistakeMarkup(el)
// In my opinion, this approach provides the most straightforward solution for repositioning mistakes after a change. // 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, // 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. // rather than re-evaluating the entire document or a larger set of elements.
editor.proof(el) this.abortController = new AbortController()
this.proof(el)
} }
// static getTagsAndText(node) { // static getTagsAndText(node) {
@ -590,25 +572,6 @@ class BesEditor {
// } // }
// } // }
// 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) { setCorrectionPanelSize(editor, correctionPanel, scrollPanel) {
const styles = window.getComputedStyle(editor) const styles = window.getComputedStyle(editor)
const totalWidth = const totalWidth =
@ -648,12 +611,12 @@ window.onload = () => {
window.onresize = () => { window.onresize = () => {
besEditors.forEach(editor => { besEditors.forEach(editor => {
editor.setCorrectionPanelSize( editor.setCorrectionPanelSize(
editor.el, editor.editorElement,
editor.correctionPanel, editor.correctionPanel,
editor.scrollPanel editor.scrollPanel
) )
editor.children.forEach(child => { editor.children.forEach(child => {
editor.clearMistakeMarkup(child.elements) editor.clearMistakeMarkup(child.element)
child.matches.forEach(match => { child.matches.forEach(match => {
const { clientRects, highlights } = editor.addMistakeMarkup(match.range) const { clientRects, highlights } = editor.addMistakeMarkup(match.range)
match.rects = clientRects match.rects = clientRects
@ -775,14 +738,14 @@ class BesPopupEl extends HTMLElement {
this.shadowRoot.querySelector('.popup-text').textContent = text this.shadowRoot.querySelector('.popup-text').textContent = text
} }
appendReplacements(el, rect, match, replacement, editor) { appendReplacements(el, match, replacement, editor) {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div') const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
const replacementBtn = document.createElement('button') const replacementBtn = document.createElement('button')
replacementBtn.classList.add('bes-replacement-btn') replacementBtn.classList.add('bes-replacement-btn')
replacementBtn.textContent = replacement replacementBtn.textContent = replacement
replacementBtn.classList.add('bes-replacement') replacementBtn.classList.add('bes-replacement')
replacementBtn.addEventListener('click', () => { replacementBtn.addEventListener('click', () => {
BesEditor.replaceText(el, rect, match, replacement, editor) editor.replaceText(el, match, replacement)
}) })
replacementDiv.appendChild(replacementBtn) replacementDiv.appendChild(replacementBtn)
} }