Fix various issues
This commit is contained in:
parent
374dd0045a
commit
ef6615941b
@ -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>
|
||||
|
185
online-editor.js
185
online-editor.js
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user