BesService/service.js
Aljaz Grilc eb1ec95749 Implement new popup for service unregistration.
The popup not only serves the immediate purpose of unregistering services but is also designed with future enhancements in mind. It can be used as a display element for showcasing all errors present in the current host element in upcoming versions of besService.
2024-03-28 14:08:44 +01:00

1198 lines
36 KiB
JavaScript

const besUrl = 'http://localhost:225/api/v2/check'
let besServices = [] // Collection of all grammar checking services in the document
///
/// Grammar checking service base class
///
class BesService {
constructor(hostElement) {
this.hostElement = hostElement
this.timer = null
this.children = []
const { correctionPanel, scrollPanel, statusDiv, statusIcon } =
this.createCorrectionPanel(hostElement)
this.correctionPanel = correctionPanel
this.scrollPanel = scrollPanel
this.statusDiv = statusDiv
this.statusIcon = statusIcon
this.offsetTop = null
this.textAreaService = null
this.originalSpellcheck = hostElement.spellcheck
this.abortController = new AbortController()
hostElement.spellcheck = false
hostElement.addEventListener(
'beforeinput',
BesService.handleBeforeInput,
false
)
hostElement.addEventListener('click', BesService.handleClick)
hostElement.addEventListener('scroll', BesService.handleScroll)
besServices.push(this)
}
/**
* Registers grammar checking service
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @returns {BesService} Grammar checking service instance
*/
static register(hostElement, textAreaService) {
let service = new BesService(hostElement)
service.proof(hostElement)
if (service.statusIcon.classList.contains('bes-status-loading')) {
service.updateStatusIcon('bes-status-success')
service.statusDiv.title = 'BesService je registriran.'
}
if (textAreaService) service.textAreaService = textAreaService
return service
}
/**
* Unregisters grammar checking service
*/
unregister() {
this.hostElement.removeEventListener('scroll', BesService.handleScroll)
this.hostElement.removeEventListener('click', BesService.handleClick)
this.hostElement.removeEventListener(
'beforeinput',
BesService.handleBeforeInput,
false
)
if (this.timer) clearTimeout(this.timer)
this.abortController.abort()
besServices = besServices.filter(item => item !== this)
this.hostElement.spellcheck = this.originalSpellcheck
this.correctionPanel.remove()
this.scrollPanel.remove()
this.statusDiv.remove()
this.statusIcon.remove()
}
/**
* Recursively grammar-proofs a DOM tree.
*
* @param {Node} node DOM root node to proof
* @returns {Array} Markup of text to proof using BesStr
*/
async proof(node) {
this.updateStatusIcon('bes-status-loading')
this.statusDiv.title = 'BesService je v procesu preverjanja pravopisa.'
switch (node.nodeType) {
case Node.TEXT_NODE:
return [{ text: node.textContent, node: node, markup: false }]
case Node.ELEMENT_NODE:
if (BesService.isBlockElement(node)) {
// Block elements are grammar-proofed independently.
if (this.isProofed(node)) {
return [
{ text: '<' + node.tagName + '/>', node: node, markup: true }
]
}
this.clearMistakeMarkup(node)
let data = []
for (const el2 of node.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: node.lang ? node.lang : 'sl',
level: 'picky'
}
const request = new Request(besUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(requestData)
})
const signal = this.abortController.signal
fetch(request, { signal })
.then(response => {
if (!response.ok) {
this.updateStatusIcon('bes-status-error')
this.statusDiv.title = 'Napaka pri preverjanju pravopisa.'
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].node,
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].node, endOffset - startingOffset)
break
}
}
const { clientRects, highlights } =
this.addMistakeMarkup(range)
matches.push({
rects: clientRects,
highlights: highlights,
range: range,
match: match
})
})
this.markProofed(node, matches)
})
.catch(error => {
if (error.name === 'AbortError') return
this.updateStatusIcon('bes-status-error')
this.statusDiv.title = 'Napaka pri preverjanju pravopisa.'
throw new Error(
'Parsing backend server response failed: ' + error
)
})
}
this.updateStatusIcon('bes-status-success')
this.statusDiv.title = 'BesService je registriran.'
return [{ text: '<' + node.tagName + '/>', node: node, markup: true }]
} else {
// Surround inline element with dummy <tagName>...</tagName>.
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: '</' + node.tagName + '>',
markup: true
})
return data
}
default:
return [{ text: '<?' + node.nodeType + '>', node: node, markup: true }]
}
}
createCorrectionPanel(hostElement) {
const panelParent = document.createElement('div')
panelParent.classList.add('bes-correction-panel-parent')
const correctionPanel = document.createElement('div')
const scrollPanel = document.createElement('div')
this.setCorrectionPanelSize(hostElement, correctionPanel, scrollPanel)
correctionPanel.classList.add('bes-correction-panel')
scrollPanel.classList.add('bes-correction-panel-scroll')
correctionPanel.appendChild(scrollPanel)
panelParent.appendChild(correctionPanel)
hostElement.parentElement.insertBefore(panelParent, hostElement)
const statusDiv = document.createElement('div')
statusDiv.classList.add('bes-status-div')
const statusIcon = document.createElement('div')
statusIcon.classList.add('bes-status-icon')
statusDiv.appendChild(statusIcon)
this.setStatusDivPosition(hostElement, statusDiv)
hostElement.parentNode.insertBefore(statusDiv, hostElement.nextSibling)
const statusPopup = document.createElement('bes-popup-status-el')
document.body.appendChild(statusPopup)
statusDiv.addEventListener('click', e => {
this.handleStatusClick(e, statusPopup)
})
return { correctionPanel, scrollPanel, statusDiv, statusIcon }
}
/**
* beforeinput event handler
*
* Marks section of the text that is about to change as not-yet-grammar-proofed.
*
* @param {InputEvent} event The event notifying the user of editable content changes
*/
static handleBeforeInput(event) {
const hostElement = event.target
let service = besServices.find(e => e.hostElement === hostElement)
if (!service) return
if (service.timer) clearTimeout(service.timer)
service.abortController.abort()
let blockElements = new Set()
event.getTargetRanges().forEach(range => {
BesService.getNodesInRange(range).forEach(el =>
blockElements.add(service.getBlockParent(el))
)
})
blockElements.forEach(block => {
service.clearMistakeMarkup(block)
service.removeChild(block)
})
// Not a nice way to do it, but it works for now the repositionMistakes function is called before the DOM updates are finished.
setTimeout(() => {
service.repositionMistakes()
}, 0)
service.timer = setTimeout(function () {
service.abortController = new AbortController()
service.proof(hostElement)
}, 1000)
}
/**
* Tests if given block element has already been grammar-proofed.
*
* @param {Element} el DOM element to check
* @returns {Boolean} true if the element has already been grammar-proofed; false otherwise.
*/
isProofed(el) {
return this.children.find(child => child.element === el)?.isProofed
}
/**
* Marks given block element as grammar-proofed.
*
* @param {Element} el DOM element that was checked
* @param {Array} matches Grammar mistakes
*/
markProofed(el, matches) {
this.removeChild(el)
this.children.push({
isProofed: true,
element: el,
matches: matches
})
// TODO: This also shows the count of mistakes that are not visible, meaning that they are hidden behind the shown ones.
const count = this.children.reduce(
(total, child) => total + child.matches.length,
0
)
if (count > 0) {
this.updateStatusIcon('bes-status-mistakes')
this.statusDiv.title = 'Število napak: ' + count
} else {
this.updateStatusIcon('bes-status-success')
this.statusDiv.title = 'V besedilu ni napak.'
}
}
/**
* Clears given block element as not grammar-proofed and removes all its grammar mistakes.
*
* @param {Element} el DOM element that we should re-grammar-proof
*/
clearMistakeMarkup(el) {
let child = this.children.find(child => child.element === el)
if (!child) return
child.isProofed = false
child.matches.forEach(match => {
if (match?.highlights) {
match.highlights.forEach(h => h.remove())
delete match.highlights
}
})
}
/**
* Removes given block element from this.children array
*
* @param {Element} el DOM element for removal
*/
removeChild(el) {
this.children = this.children.filter(child => child.element !== el)
}
/**
* Updates grammar mistake markup positions.
*/
repositionMistakes() {
this.children.forEach(child => {
this.clearMistakeMarkup(child.element)
child.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
match.highlights = highlights
})
})
}
/**
* Adds grammar mistake markup.
*
* @param {Range} range Grammar mistake range
* @returns {Object} Client rectangles and grammar mistake highlight elements
*/
addMistakeMarkup(range) {
const clientRects = range.getClientRects()
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
let highlights = []
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)
highlights.push(highlight)
}
return { clientRects, highlights }
}
updateStatusIcon(status) {
const statuses = [
'bes-status-loading',
'bes-status-success',
'bes-status-mistakes',
'bes-status-error'
]
statuses.forEach(statusClass => {
this.statusIcon.classList.remove(statusClass)
})
this.statusIcon.classList.add(status)
}
handleStatusClick(e, popup) {
popup.show(e.clientX, e.clientY, this)
}
/**
* Tests if given element is block element.
*
* @param {Element} el DOM element
* @returns false if CSS display property is inline; true otherwise.
*/
static isBlockElement(el) {
switch (
document.defaultView
.getComputedStyle(el, null)
.getPropertyValue('display')
.toLowerCase()
) {
case 'inline':
return false
default:
return true
}
}
/**
* Returns first block parent element of a node.
*
* @param {Node} el DOM node
* @returns {Element} Innermost block element containing given node
*/
getBlockParent(el) {
for (; el && el !== this.hostElement; el = el.parentNode) {
if (el.nodeType === Node.ELEMENT_NODE && BesService.isBlockElement(el))
return el
}
return el
}
/**
* Returns next node in the DOM text flow.
*
* @param {Node} node DOM node
* @returns {Node} Next node
*/
static getNextNode(node) {
if (node.firstChild) return node.firstChild
while (node) {
if (node.nextSibling) return node.nextSibling
node = node.parentNode
}
}
/**
* Returns all ancestors of a node.
*
* @param {Node} node DOM node
* @returns {Array} Array of all ancestors in reverse order: node, immediate parent, ..., document
*/
static getParents(node) {
let parents = []
do {
parents.push(node)
node = node.parentNode
} while (node)
return parents.reverse()
}
/**
* Returns all nodes marked by a range.
*
* @param {Range} range DOM range
* @returns {Array} Array of nodes
*/
static getNodesInRange(range) {
var start = range.startContainer
var end = range.endContainer
let startAncestors = BesService.getParents(start)
let endAncestors = BesService.getParents(end)
let commonAncestor = null
for (
let i = 0;
i < startAncestors.length &&
i < endAncestors.length &&
startAncestors[i] === endAncestors[i];
++i
) {
commonAncestor = startAncestors[i]
}
var nodes = []
var node
// walk parent nodes from start to common ancestor
for (node = start.parentNode; node; node = node.parentNode) {
nodes.push(node)
if (node == commonAncestor) break
}
nodes.reverse()
// walk children and siblings from start until end is found
for (node = start; node; node = BesService.getNextNode(node)) {
nodes.push(node)
if (node == end) break
}
return nodes
}
/**
* click event handler
*
* Displays or hides grammar mistake popup.
*
* @param {PointerEvent} event The event produced by a pointer such as the geometry of the contact point, the device type that generated the event, the amount of pressure that was applied on the contact surface, etc.
*/
static handleClick(event) {
const source = event?.detail !== 1 ? event?.detail : event
const hostElement = BesService.findParent(
source.targetElement || source.target
)
let service = besServices.find(e => e.hostElement === hostElement)
if (!service) return
const target = service.getBlockParent(source.targetElement || source.target)
service.renderPopup(target, source.clientX, source.clientY)
}
/**
* scroll event handler
*
* Syncs grammar mistake positions with host element scroll offset.
*
* @param {Event} event The event which takes place.
*/
static handleScroll(event) {
const hostElement = event.target
let service = besServices.find(e => e.hostElement === hostElement)
if (!service) return
service.scrollPanel.style.top = -hostElement.scrollTop + 'px'
service.offsetTop = hostElement.scrollTop
setTimeout(() => {
service.repositionMistakes()
}, 100)
// TODO: Move popup (if open) too.
}
/**
* Finds the host element with grammar checking service a DOM node is child of.
*
* @param {Node} el DOM node
* @returns {Element} Host DOM element; null if DOM node is not a descendant of any registered host element.
*/
static findParent(el) {
for (; el; el = el.parentNode) {
if (besServices.find(service => service.hostElement === el)) {
return el
}
}
return null
}
/**
* Displays grammar mistake explanation popup.
*
* @param {*} el DOM element we have grammar proofing available for
* @param {*} clientX Client X coordinate of the pointer event
* @param {*} clientY Client Y coordinate of the pointer event
*/
renderPopup(el, clientX, clientY) {
const popup = document.querySelector('bes-popup-el')
const matches = this.children.find(child => child.element === el)?.matches
if (matches) {
for (let m of matches) {
if (m.rects) {
for (let r of m.rects) {
if (BesService.isPointInRect(clientX, clientY, r)) {
popup.changeMessage(m.match.message)
m.match.replacements.forEach(replacement => {
popup.appendReplacements(
el,
m,
replacement.value,
this,
this.hostElement.contentEditable !== 'false'
)
})
popup.show(clientX, clientY)
return
}
}
}
}
}
BesPopupEl.hide()
}
// This function should be able to handle both cases or find a way that works for both.
replaceText(el, match, replacement) {
if (this.timer) clearTimeout(this.timer)
this.abortController.abort()
match.range.deleteContents()
match.range.insertNode(document.createTextNode(replacement))
if (this.textAreaService) {
this.textAreaService.handleReplacement(this.hostElement)
}
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.
this.abortController = new AbortController()
this.proof(el)
}
setCorrectionPanelSize(hostElement, correctionPanel, scrollPanel) {
const styles = window.getComputedStyle(hostElement)
const totalWidth = parseFloat(styles.width)
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'
correctionPanel.style.marginLeft = styles.marginLeft
correctionPanel.style.marginRight = styles.marginRight
correctionPanel.style.paddingLeft = styles.paddingLeft
correctionPanel.style.paddingRight = styles.paddingRight
scrollPanel.style.height = hostElement.scrollHeight + 'px'
}
setStatusDivPosition(hostElement, statusDiv) {
const hRects = hostElement.getBoundingClientRect()
const scrollTop = window.scrollY || document.documentElement.scrollTop
statusDiv.style.left = hRects.right - 40 + 'px'
statusDiv.style.top = hRects.top + hRects.height - 30 + scrollTop + 'px'
}
static isPointInRect(x, y, rect) {
return (
x >= rect.x &&
x < rect.x + rect.width &&
y >= rect.y &&
y < rect.y + rect.height
)
}
}
///
/// Grammar checking service for CKEditor
///
class BesCKService extends BesService {
constructor(hostElement, ckEditorInstance) {
super(hostElement)
this.ckEditorInstance = ckEditorInstance
}
/**
* Registers grammar checking service
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @param {CKEditorInstance} ckEditorInstance Enable CKEditor tweaks
* @returns {BesCKService} Grammar checking service instance
*/
static register(hostElement, ckEditorInstance) {
let service = new BesCKService(hostElement, ckEditorInstance)
service.proof(hostElement)
return service
}
/**
* Marks given block element as grammar-proofed.
*
* @param {Element} el DOM element that was checked
* @param {Array} matches Grammar mistakes
*/
markProofed(el, matches) {
super.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) window.dispatchEvent(new Event('resize'))
}
/**
* Removes given block element from this.children array
*
* @param {Element} el DOM element for removal
*/
removeChild(el) {
this.children = this.children.filter(child => child.element !== el)
}
/**
* Updates grammar mistake markup positions.
*/
repositionMistakes() {
this.children.forEach(child => {
this.clearMistakeMarkup(child.element)
child.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
match.highlights = highlights
})
})
}
// This function should be able to handle both cases or find a way that works for both.
replaceText(el, match, replacement) {
const { ckEditorInstance } = this
const viewRange = ckEditorInstance.editing.view.domConverter.domRangeToView(
match.range
)
const modelRange = ckEditorInstance.editing.mapper.toModelRange(viewRange)
ckEditorInstance.model.change(writer => {
const attributes =
ckEditorInstance.model.document.selection.getAttributes()
writer.remove(modelRange)
writer.insertText(replacement, attributes, modelRange.start)
})
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.
this.abortController = new AbortController()
this.proof(el)
}
}
///
/// Grammar checking service for textarea element
///
class BesTAService {
constructor(textAreaEl) {
this.textAreaEl = textAreaEl
this.textAreaEl.spellcheck = false
this.cloneDiv = this.createCloneDiv(textAreaEl)
this.service = BesService.register(this.cloneDiv, this)
this.textAreaEl.addEventListener('input', () => this.handleInput())
this.textAreaEl.addEventListener('click', e => this.handleTAClick(e))
this.textAreaEl.addEventListener('scroll', () => {
this.cloneDiv.scrollTop = this.textAreaEl.scrollTop
})
}
/**
* Creates a clone div element for the textarea element
*
* @param {Node} textAreaEl
* @returns {Node} Clone div element
*/
createCloneDiv(textAreaEl) {
const cloneDiv = document.createElement('div')
const textAreaRect = textAreaEl.getBoundingClientRect()
const scrollTop = window.scrollY || document.documentElement.scrollTop
cloneDiv.style.top = `${textAreaRect.top + scrollTop}px`
cloneDiv.style.left = `${textAreaRect.left}px`
const textAreaStyles = window.getComputedStyle(textAreaEl)
cloneDiv.style.fontSize = textAreaStyles.fontSize
cloneDiv.style.fontFamily = textAreaStyles.fontFamily
cloneDiv.style.lineHeight = textAreaStyles.lineHeight
cloneDiv.style.width = textAreaStyles.width
cloneDiv.style.height = textAreaStyles.height
cloneDiv.style.maxHeight = textAreaStyles.height
cloneDiv.style.padding = textAreaStyles.padding
cloneDiv.style.margin = textAreaStyles.margin
cloneDiv.style.overflowY = 'auto'
cloneDiv.style.position = 'absolute'
textAreaEl.style.position = 'relative'
textAreaEl.style.zIndex = 2
textAreaEl.parentNode.insertBefore(cloneDiv, textAreaEl)
return cloneDiv
}
/**
* This function copies the text from the textarea to the clone div
*/
handleInput() {
const customEvent = new InputEvent('beforeinput')
const lines = this.textAreaEl.value.split('\n')
this.cloneDiv.innerHTML = ''
lines.forEach(line => {
const divEl = document.createElement('div')
divEl.textContent = line
if (line === '') divEl.innerHTML = '&nbsp;'
this.cloneDiv.appendChild(divEl)
})
this.cloneDiv.dispatchEvent(customEvent)
}
/**
* This function handles the click event on the textarea element and finds the deepest div at the click position
*
* @param {Event} e Click event
*/
handleTAClick(e) {
//TODO: Consider adding some kind of proofing?
this.textAreaEl.style.visibility = 'hidden'
const deepestElement = document.elementFromPoint(e.clientX, e.clientY)
this.textAreaEl.style.visibility = 'visible'
const clickEvent = new CustomEvent('click', {
detail: {
clientX: e.clientX,
clientY: e.clientY,
targetElement: deepestElement
}
})
this.cloneDiv.dispatchEvent(clickEvent)
}
/**
* This function handles the replacement of the text in the textarea element
*
* @param {HTMLElement} el Element whose outerText will be used as a replacement
*/
handleReplacement(el) {
// TODO: think of a way to reposition the cursor after the replacement
this.textAreaEl.value = el.outerText
}
/**
* Registers grammar checking service
*
* @param {Element} textAreaEl DOM element to register grammar checking service for
* @returns {BesTAService} Grammar checking service instance
*/
static register(textAreaEl) {
let service = new BesTAService(textAreaEl)
return service
}
}
///
/// Grammar mistake popup dialog
///
class BesPopupEl extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
// Variables to store the initial positions
this.initialMouseX = 0
this.initialMouseY = 0
this.currentMouseX = 0
this.currentMouseY = 0
this.isMouseDownRegistered = false
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
position: relative;
display: inline-block;
z-index: -1
}
:host(.show){
z-index: 10;
}
.popup-text {
max-width: 160px;
color: black;
text-align: center;
padding: 8px 0;
z-index: 1;
}
.bes-popup-container {
visibility: hidden;
max-width: 300px;
background-color: rgb(241, 243, 249);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
border-radius: 5px;
padding: 8px;
z-index: 1;
}
.bes-toolbar {
display: flex;
justify-content: end;
padding: 5px 2px;
}
.bes-toolbar button {
margin-right: 2px;
}
.bes-popup-title {
flex-grow: 1;
cursor: grab;
}
.bes-text-div{
background-color: white;
padding: 10px;
border-radius: 5px;
}
.bes-replacement-btn{
margin: 4px 1px;
padding: 4px;
border: none;
border-radius: 5px;
background-color: #239aff;
color: white;
cursor: pointer;
}
.bes-replacement-btn:hover{
background-color: #1976f0;
}
:host(.show) .bes-popup-container {
visibility: visible;
animation: fadeIn 1s;
}
@keyframes fadeIn {
from {opacity: 0;}
to {opacity:1 ;}
}
</style>
<div class="bes-popup-container">
<div class="bes-toolbar">
<div class="bes-popup-title">Besana</div>
<button class="bes-close-btn" onclick="BesPopupEl.hide()">X</button>
</div>
<div class="bes-text-div">
<span class="popup-text">
</span>
<div class="bes-replacement-div">
</div>
</div>
</div>
`
}
show(x, y) {
y = y + 20
this.style.position = 'fixed'
this.style.left = `${x}px`
this.style.top = `${y}px`
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const popupWidth = this.offsetWidth
const popupHeight = this.offsetHeight
const maxPositionX = viewportWidth - popupWidth
const maxPositionY = viewportHeight - popupHeight
const positionX = this.offsetLeft
const positionY = this.offsetTop
if (positionX > maxPositionX) {
this.style.left = maxPositionX + 'px'
}
if (positionY > maxPositionY) {
this.style.top = maxPositionY + 'px'
}
this.classList.add('show')
}
clear() {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
const replacements = replacementDiv.children
if (!replacements.length) return
for (const replacement of Array.from(replacements)) {
replacement.remove()
}
}
changeMessage(text) {
this.clear()
this.shadowRoot.querySelector('.popup-text').textContent = text
}
appendReplacements(el, match, replacement, service, allowReplacements) {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
const replacementBtn = document.createElement('button')
replacementBtn.classList.add('bes-replacement-btn')
replacementBtn.textContent = replacement
replacementBtn.addEventListener('click', () => {
if (allowReplacements) {
service.replaceText(el, match, replacement)
BesPopupEl.hide()
}
})
replacementDiv.appendChild(replacementBtn)
}
dragMouseDown(e) {
e.preventDefault()
this.initialMouseX = e.clientX
this.initialMouseY = e.clientY
document.onmousemove = this.elementDrag.bind(this)
document.onmouseup = this.closeDragElement.bind(this)
}
// Function to handle the mousemove event
elementDrag(e) {
e.preventDefault()
let diffX = this.initialMouseX - e.clientX
let diffY = this.initialMouseY - e.clientY
this.initialMouseX = e.clientX
this.initialMouseY = e.clientY
let newTop = this.offsetTop - diffY
let newLeft = this.offsetLeft - diffX
const popupWidth = this.offsetWidth
const popupHeight = this.offsetHeight
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Adjust the new position if it would place the popup outside the window
if (newTop < 0) {
newTop = 0
} else if (newTop + popupHeight > viewportHeight) {
newTop = viewportHeight - popupHeight
}
if (newLeft < 0) {
newLeft = 0
} else if (newLeft + popupWidth > viewportWidth) {
newLeft = viewportWidth - popupWidth
}
this.style.top = newTop + 'px'
this.style.left = newLeft + 'px'
}
closeDragElement() {
document.onmouseup = null
document.onmousemove = null
}
connectedCallback() {
this.render()
if (!this.isMouseDownRegistered) {
this.onmousedown = this.dragMouseDown.bind(this)
this.isMouseDownRegistered = true
}
}
static hide() {
let popups = document.querySelectorAll('bes-popup-el')
popups.forEach(popup => {
popup.classList.remove('show')
})
}
}
class BesStatusPopup extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
}
:host(.show){
z-index: 10;
}
.popup-text {
max-width: 160px;
color: black;
text-align: center;
padding: 8px 0;
z-index: 1;
}
.bes-popup-container {
visibility: hidden;
max-width: 300px;
background-color: rgb(241, 243, 249);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
border-radius: 5px;
padding: 8px;
z-index: 1;
}
.bes-toolbar {
display: flex;
justify-content: end;
padding: 5px 2px;
}
.bes-toolbar button {
margin-right: 2px;
}
.bes-popup-title {
flex-grow: 1;
}
.bes-text-div{
background-color: white;
padding: 10px;
border-radius: 5px;
}
.bes-service-btn{
background-color: none;
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.bes-turn-off{
width: 20px;
height: 20px;
}
:host(.show) .bes-popup-container {
visibility: visible;
animation: fadeIn 1s;
}
@keyframes fadeIn {
from {opacity: 0;}
to {opacity:1 ;}
}
</style>
<div class="bes-popup-container">
<div class="bes-toolbar">
<div class="bes-popup-title">Besana</div>
<button class="bes-close-btn" onclick="BesStatusPopup.hide()">X</button>
</div>
<div class="bes-text-div">
<span class="popup-text">
Če želite izključiti preverjanje pravopisa, kliknite na gumb.
</span>
<button class="bes-service-btn">
<img class="bes-turn-off" src="/images/turn-off-svgrepo-com.svg" alt="Izključi preverjanje pravopisa">
</button>
</div>
</div>
`
}
show(x, y, service) {
y = y + 20
this.style.position = 'fixed'
this.style.left = `${x}px`
this.style.top = `${y}px`
this.style.display = 'block'
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const popupWidth = this.offsetWidth
const popupHeight = this.offsetHeight
const maxPositionX = viewportWidth - popupWidth
const maxPositionY = viewportHeight - popupHeight
const positionX = this.offsetLeft
const positionY = this.offsetTop
if (positionX > maxPositionX) {
this.style.left = maxPositionX + 'px'
}
if (positionY > maxPositionY) {
this.style.top = maxPositionY + 'px'
}
this.disableButton = this.shadowRoot.querySelector('.bes-service-btn')
if (service) {
this.disableButton.addEventListener('click', () => this.disable(service))
}
this.classList.add('show')
}
connectedCallback() {
this.render()
}
disable(service) {
service.unregister()
BesStatusPopup.hide()
}
static hide() {
const popup = document.querySelector('bes-popup-status-el.show')
popup?.classList?.remove('show')
}
}
window.onload = () => {
document.querySelectorAll('.bes-service').forEach(hostElement => {
if (hostElement.tagName === 'TEXTAREA') BesTAService.register(hostElement)
else BesService.register(hostElement)
})
}
window.onresize = () => {
besServices.forEach(service => {
service.setCorrectionPanelSize(
service.hostElement,
service.correctionPanel,
service.scrollPanel
)
service.setStatusDivPosition(service.hostElement, service.statusDiv)
service.children.forEach(child => {
service.clearMistakeMarkup(child.element)
child.matches.forEach(match => {
const { clientRects, highlights } = service.addMistakeMarkup(
match.range
)
match.rects = clientRects
match.highlights = highlights
})
})
})
}
window.onscroll = () => {
besServices.forEach(service => {
service.scrollPanel.style.top = -service.hostElement.scrollTop + 'px'
service.offsetTop = service.hostElement.scrollTop
setTimeout(() => {
service.repositionMistakes()
}, 100)
})
}
customElements.define('bes-popup-el', BesPopupEl)
customElements.define('bes-popup-status-el', BesStatusPopup)