BesService/service2.js
Simon Rozman 5e339566f3 service2.js: Move abortController management upstream
Generally, any gramar checking will be cancellable and will need this.
2024-05-15 12:28:15 +02:00

1023 lines
30 KiB
JavaScript

// TODO: Test with contenteditable="plaintext-only"
// TODO: Implement <textarea> class
// TODO: Port CKEditor class from service.js
/**
* Collection of all grammar checking services in the document
*
* We dispatch relevant window messages to all services registered here.
*/
let besServices = []
window.addEventListener('resize', () =>
besServices.forEach(service => service.onResize())
)
window.addEventListener('scroll', () =>
besServices.forEach(service => service.onScroll())
)
/*************************************************************************
*
* Base class for all grammar-checking services
*
*************************************************************************/
class BesService {
constructor(hostElement) {
this.hostElement = hostElement
this.createCorrectionPanel()
// Disable browser built-in spell-checker to prevent collision with our grammar markup.
this.originalSpellcheck = this.hostElement.spellcheck
this.hostElement.spellcheck = false
this.onScroll = this.onScroll.bind(this)
this.hostElement.addEventListener('scroll', this.onScroll)
besServices.push(this)
}
/**
* Unregisters grammar checking service.
*/
unregister() {
if (this.abortController) this.abortController.abort()
besServices = besServices.filter(item => item !== this)
this.hostElement.removeEventListener('scroll', this.onScroll)
this.hostElement.spellcheck = this.originalSpellcheck
this.clearCorrectionPanel()
}
/**
* Called initially when grammar-checking run is started
*/
onStartProofing() {
this.proofingCount = 0 // Ref-count how many grammar-checking blocks of text are active
this.proofingError = null // The first non-fatal error in grammar-checking run
this.proofingMatches = 0 // Number of grammar mistakes detected in entire grammar-checking run
this.updateStatusIcon('bes-status-loading', 'Besana preverja pravopis.')
this.abortController = new AbortController()
}
/**
* Called when grammar-checking starts proofing each block of text (typically paragraph)
*/
onProofing() {
this.proofingCount++
}
/**
* Called when grammar-checking failed (as 500 Internal server error, timeout, etc.)
*
* This error is fatal and proofing will not continue.
*
* @param {Response} response HTTP response
*/
onFailedProofing(response) {
delete this.abortController
this.updateStatusIcon(
'bes-status-error',
`Pri preverjanju pravopisa je prišlo do napake ${response.status} ${response.statusText}.`
)
}
/**
* Called when failed to parse result of a grammar-checking of a block of text
*
* @param {Error} error Error
*/
onFailedProofingResult(error) {
if (!this.proofingError) this.proofingError = error
if (--this.proofingCount <= 0) this.onEndProofing()
}
/**
* Called when one block of text finished grammar-checking
*
* @param {Number} numberOfMatches Number of grammar mistakes discovered
*/
onProofingProgress(numberOfMatches) {
this.proofingMatches += numberOfMatches
if (--this.proofingCount <= 0) this.onEndProofing()
}
/**
* Called when grammar-checking run is ended
*/
onEndProofing() {
delete this.abortController
if (this.proofingError) {
this.updateStatusIcon(
'bes-status-error',
`Pri obdelavi odgovora pravopisnega strežnika je prišlo do napake: ${this.proofingError}`
)
} else if (this.proofingMatches > 0)
this.updateStatusIcon(
'bes-status-mistakes',
`Število napak: ${this.proofingMatches}`
)
else this.updateStatusIcon('bes-status-success', 'V besedilu ni napak.')
}
/**
* Called to report scrolling
*/
onScroll() {
// Scroll panel is "position: absolute", we need to keep it aligned with the host element.
this.scrollPanel.style.top = `${-this.hostElement.scrollTop}px`
this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}px`
}
/**
* Called to report resizing
*/
onResize() {
this.setCorrectionPanelSize()
this.setStatusDivPosition()
}
/**
* Creates auxiliary DOM elements for text adornments.
*/
createCorrectionPanel() {
const panelParent = document.createElement('div')
panelParent.classList.add('bes-correction-panel-parent')
this.correctionPanel = document.createElement('div')
this.scrollPanel = document.createElement('div')
this.setCorrectionPanelSize()
this.correctionPanel.classList.add('bes-correction-panel')
this.scrollPanel.classList.add('bes-correction-panel-scroll')
this.correctionPanel.appendChild(this.scrollPanel)
panelParent.appendChild(this.correctionPanel)
this.hostElement.parentElement.insertBefore(panelParent, this.hostElement)
this.statusDiv = document.createElement('div')
this.statusDiv.classList.add('bes-status-div')
this.statusIcon = document.createElement('div')
this.statusIcon.classList.add('bes-status-icon')
this.statusDiv.appendChild(this.statusIcon)
this.setStatusDivPosition()
this.hostElement.parentNode.insertBefore(
this.statusDiv,
this.hostElement.nextSibling
)
const statusPopup = document.createElement('bes-popup-status-el')
document.body.appendChild(statusPopup)
this.statusDiv.addEventListener('click', e =>
this.handleStatusClick(e, statusPopup)
)
}
/**
* Clears auxiliary DOM elements for text adornments.
*/
clearCorrectionPanel() {
this.correctionPanel.remove()
this.scrollPanel.remove()
this.statusDiv.remove()
this.statusIcon.remove()
}
/**
* Resizes correction and scroll panels to match host element size.
*/
setCorrectionPanelSize() {
const styles = window.getComputedStyle(this.hostElement)
const totalWidth = parseFloat(styles.width)
const totalHeight =
parseFloat(styles.height) +
parseFloat(styles.marginTop) +
parseFloat(styles.marginBottom) +
parseFloat(styles.paddingTop) +
parseFloat(styles.paddingBottom)
this.correctionPanel.style.width = `${totalWidth}px`
this.correctionPanel.style.height = `${totalHeight}px`
this.correctionPanel.style.marginLeft = styles.marginLeft
this.correctionPanel.style.marginRight = styles.marginRight
this.correctionPanel.style.paddingLeft = styles.paddingLeft
this.correctionPanel.style.paddingRight = styles.paddingRight
this.scrollPanel.style.height = `${this.hostElement.scrollHeight}px`
}
/**
* Repositions status DIV element.
*/
setStatusDivPosition() {
const rect = this.hostElement.getBoundingClientRect()
const scrollTop = window.scrollY || document.documentElement.scrollTop
this.statusDiv.style.left = `${rect.right - 40}px`
this.statusDiv.style.top = `${rect.top + rect.height - 30 + scrollTop}px`
}
/**
* Sets status icon style and title.
*
* @param {String} status CSS class name to set status icon to
* @param {String} title Title of the status icon
*/
updateStatusIcon(status, title) {
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)
this.statusDiv.title = title
}
}
/*************************************************************************
*
* DOM grammar-checking service
*
*************************************************************************/
class BesDOMService extends BesService {
constructor(hostElement) {
super(hostElement)
this.results = [] // Results of grammar-checking, one per each block of text
this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
this.onInput = this.onInput.bind(this)
this.hostElement.addEventListener('input', this.onInput)
this.onClick = this.onClick.bind(this)
this.hostElement.addEventListener('click', this.onClick)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @returns {BesService} Grammar checking service instance
*/
static register(hostElement) {
let service = new BesDOMService(hostElement)
service.proofAll()
return service
}
/**
* Unregisters grammar checking service.
*/
unregister() {
this.hostElement.removeEventListener('click', this.onClick)
this.hostElement.removeEventListener('input', this.onInput)
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
if (this.timer) clearTimeout(this.timer)
super.unregister()
}
/**
* Called to report scrolling
*/
onScroll() {
super.onScroll()
// Markup is in a "position:absolute" <div> element requiring repositioning when scrolling host element or window.
// It is defered to reduce stress in a flood of scroll events.
// TODO: We could technically just update scrollTop and scrollLeft of all markup rects for even better performance?
if (this.scrollTimeout) clearTimeout(this.scrollTimeout)
this.scrollTimeout = setTimeout(() => {
this.repositionAllMarkup()
delete this.scrollTimeout
}, 500)
}
/**
* Called to report resizing
*/
onResize() {
super.onResize()
// When window is resized, host element might resize too.
// This may cause text to re-wrap requiring markup re
this.repositionAllMarkup()
}
/**
* Called to report the text is about to change
*
* Marks section of the text that is about to change as not-yet-grammar-checked.
*
* @param {InputEvent} event The event notifying the user of editable content changes
*/
onBeforeInput(event) {
if (this.timer) clearTimeout(this.timer)
if (this.abortController) this.abortController.abort()
// Remove markup of all blocks of text that are about to change.
let blockElements = new Set()
event.getTargetRanges().forEach(range => {
BesDOMService.getNodesInRange(range).forEach(el => {
if (
el === this.hostElement ||
Array.from(this.hostElement.childNodes).includes(el)
)
blockElements.add(this.getBlockParent(el))
})
})
blockElements.forEach(block => this.clearProofing(block))
}
/**
* Called to report the text has changed
*/
onInput() {
// Now that the text is done changing, we can correctly calculate markup position.
this.repositionAllMarkup()
// Defer grammar-checking to reduce stress on grammar-checking server.
this.timer = setTimeout(() => {
this.proofAll()
delete this.timer
}, 1000)
}
/**
* Recursively grammar-(re)checks our host DOM tree.
*/
proofAll() {
this.onStartProofing()
this.proofNode(this.hostElement, this.abortController)
if (this.proofingCount == 0) {
// No text blocks were discovered for proofing. onProofingProgress() will not be called
// and we need to notify manually.
this.onEndProofing()
}
}
/**
* Recursively grammar-checks a DOM node.
*
* @param {Node} node DOM root node to check
* @param {AbortController} abortController Abort controller to cancel grammar-checking
* @returns {Array} Markup of text to check using BesStr
*/
proofNode(node, abortController) {
switch (node.nodeType) {
case Node.TEXT_NODE:
return [{ text: node.textContent, node: node, markup: false }]
case Node.ELEMENT_NODE:
if (this.isBlockElement(node)) {
// Block elements are grammar-checked independently.
if (this.isProofed(node))
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
let data = []
for (const el2 of node.childNodes)
data = data.concat(this.proofNode(el2, abortController))
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
// Block element contains some text.
this.onProofing()
const signal = abortController.signal
fetch(
new Request(besUrl + '/check', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
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'
})
}),
{ signal }
)
.then(response => {
if (!response.ok) {
this.onFailedProofing(response)
throw new Error('Unexpected BesStr server response')
}
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 ← not needed, kept for reference &&*/ 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)
this.onProofingProgress(matches.length)
})
.catch(error => {
if (error.name === 'AbortError') return
this.onFailedProofingResult(error)
})
}
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
} else {
// Inline elements require no markup. Keep plain text only.
let data = []
for (const el2 of node.childNodes)
data = data.concat(this.proofNode(el2, abortController))
return data
}
default:
return [{ text: `<?${node.nodeType}>`, node: node, markup: true }]
}
}
/**
* Tests if given block element has already been grammar-checked.
*
* @param {Element} el DOM element to check
* @returns {Boolean} true if the element has already been grammar-checked; false otherwise.
*/
isProofed(el) {
return this.results?.find(result => result.element === el) != null
}
/**
* Marks given block element as grammar-checked.
*
* @param {Element} el DOM element that was checked
* @param {Array} matches Grammar mistakes
*/
markProofed(el, matches) {
this.clearProofing(el)
this.results.push({
element: el,
matches: matches
})
}
/**
* Removes given block element from this.results array and clearing its markup.
*
* @param {Element} el DOM element for removal
*/
clearProofing(el) {
this.clearMarkup(el)
this.results = this.results?.filter(result => result.element !== el)
}
/**
* Creates grammar mistake markup in DOM.
*
* @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 }
}
/**
* Updates grammar mistake markup positions.
*
* @param {Element} el DOM element we want to update markup for
*
* TODO: Unused
*/
repositionMarkup(el) {
let result = this.results?.find(result => result.element === el)
if (!result) return
result.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
if (match.highlights) match.highlights.forEach(h => h.remove())
match.highlights = highlights
})
}
/**
* Updates all grammar mistake markup positions.
*/
repositionAllMarkup() {
this.results.forEach(result => {
result.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
if (match.highlights) match.highlights.forEach(h => h.remove())
match.highlights = highlights
})
})
}
/**
* Clears given block element grammar mistake markup.
*
* @param {Element} el DOM element we want to clean markup for
*/
clearMarkup(el) {
let result = this.results?.find(result => result.element === el)
if (!result) return
result.matches.forEach(match => {
if (match.highlights) {
match.highlights.forEach(h => h.remove())
delete match.highlights
}
})
}
/**
* Clears all grammar mistake markup.
*
* TODO: Unused
*/
clearAllMarkup() {
this.results.forEach(result => {
result.matches.forEach(match => {
if (match.highlights) {
match.highlights.forEach(h => h.remove())
delete match.highlights
}
})
})
}
/**
* Tests if given element is block element.
*
* @param {Element} el DOM element
* @returns false if CSS display property is inline; true otherwise.
*/
isBlockElement(el) {
// Always treat our host element as block.
// Otherwise, should one make it inline, proofing would not start on it misbelieving it's a part of a bigger block of text.
if (el === this.hostElement) return true
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} node DOM node
* @returns {Element} Innermost block element containing given node
*/
getBlockParent(node) {
for (; node; node = node.parentNode) {
if (node.nodeType === Node.ELEMENT_NODE && this.isBlockElement(node))
return node
}
return node
}
/**
* 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 (document...node) describing DOM path
*/
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) {
let start = range.startContainer
let end = range.endContainer
// Common ancestor is the last element common to both elements' DOM path.
let startAncestors = BesDOMService.getParents(start)
let endAncestors = BesDOMService.getParents(end)
let commonAncestor = null
for (
let i = 0;
i < startAncestors.length &&
i < endAncestors.length &&
startAncestors[i] === endAncestors[i];
++i
)
commonAncestor = startAncestors[i]
let nodes = []
let 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 node is found.
for (node = start; node; node = BesDOMService.getNextNode(node)) {
nodes.push(node)
if (node == end) break
}
return nodes
}
/**
* Called to report mouse click
*
* 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.
*/
onClick(event) {
const source = event?.detail !== 1 ? event?.detail : event
const target = this.getBlockParent(source.targetElement || source.target)
if (!target) return
const matches = this.results?.find(
child => child.element === target
)?.matches
if (matches) {
const popup = document.querySelector('bes-popup-el')
for (let m of matches) {
if (m.rects) {
for (let r of m.rects) {
if (
BesDOMService.isPointInRect(source.clientX, source.clientY, r)
) {
popup.changeMessage(m.match.message)
popup.appendReplacements(
target,
m,
this,
this.hostElement.contentEditable !== 'false'
)
popup.show(source.clientX, source.clientY)
return
}
}
}
}
}
BesPopupEl.hide()
}
/**
* Tests if given coordinate is inside of a rectangle.
*
* @param {Number} x X coordinate
* @param {Number} y Y coordinate
* @param {DOMRect} rect Rectangle
* @returns
*/
static isPointInRect(x, y, rect) {
return rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom
}
/**
* Replaces grammar checking match with a suggestion provided by grammar checking service.
*
* @param {Element} el Block element containing grammar checking rule match
* @param {*} match Grammar checking rule match
* @param {String} replacement Text to replace grammar checking match with
*/
replaceText(el, match, replacement) {
if (this.timer) clearTimeout(this.timer)
if (this.abortController) this.abortController.abort()
this.clearProofing(el)
match.range.deleteContents()
match.range.insertNode(document.createTextNode(replacement))
this.onStartProofing()
this.proofNode(el, this.abortController)
if (this.proofingCount == 0) {
// No text blocks were discovered for proofing. onProofingProgress() will not be called
// and we need to notify manually.
this.onEndProofing()
}
}
}
/*************************************************************************
*
* Grammar mistake popup dialog
*
*/
class BesPopupEl extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
}
:host(.show){
z-index: 10;
display: block;
}
.popup-text {
max-width: 160px;
color: black;
text-align: center;
padding: 8px 0;
z-index: 1;
}
.bes-popup-container {
visibility: hidden;
min-width: 200px;
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>
`
}
/**
* Shows popup window.
*
* @param {*} x X location hint
* @param {*} y Y location hint
*/
show(x, y) {
this.style.position = 'fixed'
// Element needs some initial placement for the browser to provide this.offsetWidth and this.offsetHeight measurements.
// The fade-in effect on the popup window should prevent flicker.
this.style.left = `0px`
this.style.top = `0px`
this.classList.add('show')
if (x + this.offsetWidth <= window.innerWidth) {
this.style.left = `${x}px`
} else if (this.offsetWidth <= x) {
this.style.left = `${x - this.offsetWidth}px`
} else {
this.style.left = `${x - this.offsetWidth / 2}px`
}
if (y + 20 + this.offsetHeight <= window.innerHeight) {
this.style.top = `${y + 20}px`
} else if (this.offsetHeight <= y) {
this.style.top = `${y - this.offsetHeight}px`
} else {
this.style.top = `${y - this.offsetHeight / 2}px`
}
}
/**
* Clears all grammar mistake suggestions.
*/
clearReplacements() {
Array.from(
this.shadowRoot.querySelector('.bes-replacement-div')?.children
).forEach(replacement => replacement.remove())
}
/**
* Adds a grammar mistake suggestion.
*
* @param {Element} el Block element containing the grammar mistake
* @param {*} match Grammar checking rule match
* @param {BesService} service Grammar checking service
* @param {Boolean} allowReplacements Host element is mutable and grammar mistake may be replaced by suggestion
*/
appendReplacements(el, match, service, allowReplacements) {
const replacementDiv = this.shadowRoot.querySelector('.bes-replacement-div')
match.match.replacements.forEach(replacement => {
const replacementBtn = document.createElement('button')
replacementBtn.classList.add('bes-replacement-btn')
replacementBtn.textContent = replacement.value
replacementBtn.addEventListener('click', () => {
if (allowReplacements) {
service.replaceText(el, match, replacement.value)
BesPopupEl.hide()
}
})
replacementDiv.appendChild(replacementBtn)
})
}
/**
* Sets grammar mistake description
*
* @param {String} text
*/
changeMessage(text) {
this.clearReplacements()
this.shadowRoot.querySelector('.popup-text').textContent = text
}
/**
* Handles the mousedown event.
*
* @param {MouseEvent} e Event
*/
onMouseDown(e) {
e.preventDefault()
this.initialMouseX = e.clientX
this.initialMouseY = e.clientY
this.handleMouseMove = this.onMouseMove.bind(this)
document.addEventListener('mousemove', this.handleMouseMove)
this.handleMouseUp = this.onMouseUp.bind(this)
document.addEventListener('mouseup', this.handleMouseUp)
}
/**
* Handles the mousemove event.
*
* @param {MouseEvent} e Event
*/
onMouseMove(e) {
e.preventDefault()
let diffX = this.initialMouseX - e.clientX
this.initialMouseX = e.clientX
let left = this.offsetLeft - diffX
left = Math.max(0, Math.min(left, window.innerWidth - this.offsetWidth))
this.style.left = `${left}px`
let diffY = this.initialMouseY - e.clientY
this.initialMouseY = e.clientY
let top = this.offsetTop - diffY
top = Math.max(0, Math.min(top, window.innerHeight - this.offsetHeight))
this.style.top = `${top}px`
}
/**
* Handles the mouseup event.
*
* @param {MouseEvent} e Event
*/
onMouseUp(e) {
document.removeEventListener('mouseup', this.handleMouseUp)
document.removeEventListener('mousemove', this.handleMouseMove)
}
/**
* Called each time the element is added to the document
*/
connectedCallback() {
this.render()
this.addEventListener('mousedown', this.onMouseDown)
}
/**
* Dismisses all the popups.
*/
static hide() {
document
.querySelectorAll('bes-popup-el')
.forEach(popup => popup.classList.remove('show'))
}
}
customElements.define('bes-popup-el', BesPopupEl)
// Auto-register all elements with bes-service class.
window.addEventListener('load', () => {
document.querySelectorAll('.bes-service').forEach(hostElement => {
// TODO: Treat contenteditable="plaintext-only" separately. It requires manual paragraph splitting on \n\n.
if (hostElement.tagName === 'TEXTAREA') BesTAService.register(hostElement)
else BesDOMService.register(hostElement)
})
})