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>
</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>

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
// TODO: Add support for <textarea>
class BesEditor {
constructor(edit, CKEditorInstance) {
this.el = edit
this.editorElement = edit
this.timer = null
this.children = []
const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit)
@ -12,9 +14,9 @@ class BesEditor {
this.scrollPanel = scrollPanel
this.offsetTop = null
this.CKEditorInstance = CKEditorInstance
if (!this.CKEditorInstance) edit.classList.add('bes-online-editor')
this.originalSpellcheck = edit.spellcheck
edit.spellcheck = false
this.abortController = new AbortController()
this.proof(edit)
edit.addEventListener('beforeinput', BesEditor.handleBeforeInput, false)
edit.addEventListener('click', BesEditor.handleClick)
@ -37,60 +39,42 @@ class BesEditor {
* Unregisters grammar checking service
*/
unregister() {
this.el.removeEventListener('scroll', BesEditor.handleScroll)
this.el.removeEventListener('click', BesEditor.handleClick)
this.el.removeEventListener(
this.editorElement.removeEventListener('scroll', BesEditor.handleScroll)
this.editorElement.removeEventListener('click', BesEditor.handleClick)
this.editorElement.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.editorElement.spellcheck = this.originalSpellcheck
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
* @param {Node} node 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 <div>...</div> 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) {
async proof(node) {
switch (node.nodeType) {
case Node.TEXT_NODE:
return [{ text: el.textContent, el: el, markup: false }]
return [{ text: node.textContent, node: node, markup: false }]
case Node.ELEMENT_NODE:
if (BesEditor.isBlockElement(el)) {
if (BesEditor.isBlockElement(node)) {
// Block elements are grammar-proofed independently.
if (this.isProofed(el)) {
return [{ text: '<' + el.tagName + '/>', el: el, markup: true }]
if (this.isProofed(node)) {
return [
{ text: '<' + node.tagName + '/>', node: node, markup: true }
]
}
this.clearMistakeMarkup(el)
this.clearMistakeMarkup(node)
let data = []
for (const el2 of el.childNodes) {
for (const el2 of node.childNodes) {
data = data.concat(await this.proof(el2))
}
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
@ -101,7 +85,7 @@ class BesEditor {
x.markup ? { markup: x.text } : { text: x.text }
)
}),
language: el.lang ? el.lang : 'sl',
language: node.lang ? node.lang : 'sl',
level: 'picky'
}
const request = new Request(besUrl, {
@ -109,7 +93,8 @@ class BesEditor {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(requestData)
})
fetch(request)
const signal = this.abortController.signal
fetch(request, { signal })
.then(response => {
if (!response.ok) {
// 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
) {
range.setStart(
data[idx].el,
data[idx].node,
match.offset - startingOffset
)
break
@ -153,7 +138,7 @@ class BesEditor {
/*startingOffset <= endOffset &&*/ endOffset <=
startingOffset + data[idx].text.length
) {
range.setEnd(data[idx].el, endOffset - startingOffset)
range.setEnd(data[idx].node, endOffset - startingOffset)
break
}
}
@ -167,37 +152,34 @@ class BesEditor {
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.CKEditorInstance) {
const resizeEvent = new Event('resize')
window.dispatchEvent(resizeEvent)
}
this.markProofed(node, matches)
})
.catch(error => {
if (error.name === 'AbortError') return
// 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 }]
return [{ text: '<' + node.tagName + '/>', node: node, markup: true }]
} else {
// Surround inline element with dummy <tagName>...</tagName>.
let data = [{ text: '<' + el.tagName + '>', el: el, markup: true }]
for (const el2 of el.childNodes) {
let data = [
{ text: '<' + node.tagName + '>', node: node, markup: true }
]
for (const el2 of node.childNodes) {
data = data.concat(await this.proof(el2))
}
data.splice(data.length, 0, {
text: '</' + el.tagName + '>',
text: '</' + node.tagName + '>',
markup: true
})
return data
}
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) {
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.timer) clearTimeout(editor.timer)
editor.abortController.abort()
let blockElements = new Set()
event.getTargetRanges().forEach(range => {
BesEditor.getNodesInRange(range).forEach(el =>
@ -244,6 +227,7 @@ class BesEditor {
editor.repositionMistakes()
}, 0)
editor.timer = setTimeout(function () {
editor.abortController = new AbortController()
editor.proof(edit)
}, 1000)
}
@ -255,7 +239,7 @@ class BesEditor {
* @returns {Boolean} true if the element has already been grammar-proofed; false otherwise.
*/
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.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.
element: el,
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
*/
clearMistakeMarkup(el) {
let child = this.children.find(child => child.elements === el)
let child = this.children.find(child => child.element === el)
if (!child) return
child.isProofed = false
child.matches.forEach(match => {
match?.highlights.forEach(h => h.remove())
if (match?.highlights) {
match.highlights.forEach(h => h.remove())
delete match.highlights
}
})
}
@ -294,7 +283,7 @@ class BesEditor {
* @param {Element} el DOM element for removal
*/
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() {
this.children.forEach(child => {
this.clearMistakeMarkup(child.elements)
this.clearMistakeMarkup(child.element)
child.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
@ -318,7 +307,6 @@ class BesEditor {
* @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()
@ -343,7 +331,7 @@ class BesEditor {
* 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.
* @returns false if CSS display property is inline; true otherwise.
*/
static isBlockElement(el) {
switch (
@ -353,7 +341,6 @@ class BesEditor {
.toLowerCase()
) {
case 'inline':
case 'inline-block':
return false
default:
return true
@ -367,7 +354,7 @@ class BesEditor {
* @returns {Element} Innermost block element containing given node
*/
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))
return el
}
@ -454,7 +441,7 @@ class BesEditor {
*/
static handleClick(event) {
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
const target = editor.getBlockParent(event.target)
editor.renderPopup(target, event.clientX, event.clientY)
@ -469,13 +456,13 @@ class BesEditor {
*/
static handleScroll(event) {
const edit = event.target
let editor = besEditors.find(e => e.el === edit)
let editor = besEditors.find(e => e.editorElement === edit)
if (!editor) return
editor.scrollPanel.style.top = -edit.scrollTop + 'px'
editor.offsetTop = edit.scrollTop
setTimeout(() => {
editor.repositionMistakes()
}, 300)
}, 100)
// TODO: Move popup (if open) too.
}
@ -487,10 +474,7 @@ class BesEditor {
*/
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
) {
if (besEditors.find(editor => editor.editorElement === el)) {
return el
}
}
@ -506,7 +490,7 @@ class BesEditor {
*/
renderPopup(el, clientX, clientY) {
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) {
for (let m of matches) {
if (m.rects) {
@ -516,8 +500,7 @@ class BesEditor {
m.match.replacements.forEach(replacement => {
popup.appendReplacements(
el,
r,
m.match,
m,
replacement.value,
this
)
@ -534,17 +517,15 @@ class BesEditor {
// 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) {
replaceText(el, match, replacement) {
if (this.timer) clearTimeout(this.timer)
this.abortController.abort()
// const tags = this.getTagsAndText(el)
if (!editor.CKEditorInstance) {
const text = el.textContent
const newText =
text.substring(0, match.offset) +
replacement +
text.substring(match.offset + match.length)
el.textContent = newText
if (!this.CKEditorInstance) {
match.range.deleteContents()
match.range.insertNode(document.createTextNode(replacement))
} else {
const { CKEditorInstance } = editor
const { CKEditorInstance } = this
CKEditorInstance.model.change(writer => {
const viewElement =
CKEditorInstance.editing.view.domConverter.mapDomToView(el)
@ -552,15 +533,15 @@ class BesEditor {
CKEditorInstance.editing.mapper.toModelElement(viewElement)
if (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 (
elementRange.start.offset <= match.offset &&
elementRange.end.offset >= match.offset + match.length
elementRange.start.offset <= match.match.offset &&
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(
modelElement,
match.offset + match.length
match.match.offset + match.match.length
)
const range = writer.createRange(start, end)
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.
// 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)
this.abortController = new AbortController()
this.proof(el)
}
// 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) {
const styles = window.getComputedStyle(editor)
const totalWidth =
@ -648,12 +611,12 @@ window.onload = () => {
window.onresize = () => {
besEditors.forEach(editor => {
editor.setCorrectionPanelSize(
editor.el,
editor.editorElement,
editor.correctionPanel,
editor.scrollPanel
)
editor.children.forEach(child => {
editor.clearMistakeMarkup(child.elements)
editor.clearMistakeMarkup(child.element)
child.matches.forEach(match => {
const { clientRects, highlights } = editor.addMistakeMarkup(match.range)
match.rects = clientRects
@ -775,14 +738,14 @@ class BesPopupEl extends HTMLElement {
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 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)
editor.replaceText(el, match, replacement)
})
replacementDiv.appendChild(replacementBtn)
}