This feature is not fully tested, there are still some bugs left, i.e., after scroll event the popup is unavailable
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
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 = []
|
|
const { correctionPanel, scrollPanel } = this.createCorrectionPanel(edit)
|
|
this.correctionPanel = correctionPanel
|
|
this.scrollPanel = scrollPanel
|
|
this.proof(edit)
|
|
edit.addEventListener('beforeinput', e => this.handleBeforeInput(e), false)
|
|
edit.addEventListener('click', e => this.handleClick(e))
|
|
edit.addEventListener('scroll', () =>
|
|
this.handleScrollEvent(this.el, this.scrollPanel)
|
|
)
|
|
}
|
|
|
|
// Register editor
|
|
static register(edit) {
|
|
let editor = new BesEditor(edit)
|
|
besEditors.push(editor)
|
|
return editor
|
|
}
|
|
|
|
// 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.clearMistakeMarkup(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
|
|
}
|
|
}
|
|
|
|
matches.push({
|
|
range: range,
|
|
rects: this.addMistakeMarkup(range, this.scrollPanel),
|
|
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 <tagName>...</tagName>.
|
|
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: '</' + el.tagName + '>',
|
|
markup: true
|
|
})
|
|
return data
|
|
}
|
|
|
|
default:
|
|
return [{ text: '<?' + el.nodeType + '>', el: el, markup: true }]
|
|
}
|
|
}
|
|
|
|
createCorrectionPanel(edit) {
|
|
const panelParent = document.createElement('div')
|
|
panelParent.classList.add('bes-correction-panel-parent')
|
|
const correctionPanel = document.createElement('div')
|
|
const scrollPanel = document.createElement('div')
|
|
this.setCorrectionPanelSize(edit, correctionPanel, scrollPanel)
|
|
correctionPanel.classList.add('bes-correction-panel')
|
|
scrollPanel.classList.add('bes-correction-panel-scroll')
|
|
|
|
correctionPanel.appendChild(scrollPanel)
|
|
panelParent.appendChild(correctionPanel)
|
|
edit.parentElement.insertBefore(panelParent, edit)
|
|
|
|
return { correctionPanel, scrollPanel }
|
|
}
|
|
|
|
// 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.
|
|
clearMistakeMarkup(el) {
|
|
let filteredChildren = this.children.filter(child => child.elements === el)
|
|
if (!filteredChildren.length) return
|
|
|
|
// TODO: Remove elements that are found in editor object, that way we can avoid looping through all elements.
|
|
filteredChildren[0].matches.forEach(match => {
|
|
for (const rect of match.rects) {
|
|
for (let child of this.scrollPanel.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
|
|
addMistakeMarkup(range, scrollPanel) {
|
|
// TODO: Consider using range.getClientRects() instead of range.getBoundingClientRect()
|
|
const clientRects = range.getClientRects()
|
|
const scrollPanelRect = scrollPanel.getBoundingClientRect()
|
|
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')
|
|
const topPosition = rect.top - scrollPanelRect.top
|
|
const leftPosition = rect.left - scrollPanelRect.left
|
|
highlight.style.left = `${leftPosition}px`
|
|
highlight.style.top = `${topPosition}px`
|
|
highlight.style.width = `${rect.width}px`
|
|
highlight.style.height = `${rect.height}px`
|
|
this.scrollPanel.appendChild(highlight)
|
|
}
|
|
return clientRects
|
|
}
|
|
|
|
// Tests if given element is block element.
|
|
static isBlockElement(el) {
|
|
switch (
|
|
document.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()
|
|
}
|
|
}
|
|
|
|
handleScrollEvent(editor, scrollPanel) {
|
|
scrollPanel.style.top = -editor.scrollTop + 'px'
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
setCorrectionPanelSize(editor, correctionPanel, scrollPanel) {
|
|
const styles = window.getComputedStyle(editor)
|
|
const totalWidth =
|
|
parseFloat(styles.width) +
|
|
parseFloat(styles.marginLeft) +
|
|
parseFloat(styles.marginRight) +
|
|
parseFloat(styles.paddingLeft) +
|
|
parseFloat(styles.paddingRight)
|
|
const totalHeight =
|
|
parseFloat(styles.height) +
|
|
parseFloat(styles.marginTop) +
|
|
parseFloat(styles.marginBottom) +
|
|
parseFloat(styles.paddingTop) +
|
|
parseFloat(styles.paddingBottom)
|
|
correctionPanel.style.width = totalWidth + 'px'
|
|
correctionPanel.style.height = totalHeight + 'px'
|
|
scrollPanel.style.height = editor.scrollHeight + 'px'
|
|
}
|
|
|
|
static isPointInRect(x, y, rect) {
|
|
return (
|
|
x >= rect.x &&
|
|
x < rect.x + rect.width &&
|
|
y >= rect.y &&
|
|
y < rect.y + rect.height
|
|
)
|
|
}
|
|
}
|
|
|
|
window.onload = () => {
|
|
// Search and prepare all our editors found in the document.
|
|
document
|
|
.querySelectorAll('.bes-online-editor')
|
|
.forEach(edit => BesEditor.register(edit))
|
|
}
|
|
|
|
window.onresize = () => {
|
|
besEditors.forEach(editor => {
|
|
editor.setCorrectionPanelSize(
|
|
editor.el,
|
|
editor.correctionPanel,
|
|
editor.scrollPanel
|
|
)
|
|
editor.children.forEach(child => {
|
|
editor.clearMistakeMarkup(child.elements)
|
|
child.matches.forEach(match => {
|
|
match.rects = editor.addMistakeMarkup(match.range, editor.scrollPanel)
|
|
})
|
|
})
|
|
})
|
|
}
|