service2.js: Port popup from service.js

This commit is contained in:
2024-05-13 13:03:58 +02:00
parent c54366e95f
commit ff54607e7e
3 changed files with 320 additions and 10 deletions

View File

@@ -1,4 +1,3 @@
// TODO: Port popup dialog from service.js
// TODO: Test with contenteditable="plaintext-only"
// TODO: Implement <textarea> class
// TODO: Port CKEditor class from service.js
@@ -32,8 +31,8 @@ class BesService {
this.originalSpellcheck = this.hostElement.spellcheck
this.hostElement.spellcheck = false
this.handleScroll = () => this.onScroll()
this.hostElement.addEventListener('scroll', this.handleScroll)
this.onScroll = this.onScroll.bind(this)
this.hostElement.addEventListener('scroll', this.onScroll)
besServices.push(this)
}
@@ -43,7 +42,7 @@ class BesService {
*/
unregister() {
besServices = besServices.filter(item => item !== this)
this.hostElement.removeEventListener('scroll', this.handleScroll)
this.hostElement.removeEventListener('scroll', this.onScroll)
this.hostElement.spellcheck = this.originalSpellcheck
this.clearCorrectionPanel()
}
@@ -237,10 +236,12 @@ class BesDOMService extends BesService {
constructor(hostElement) {
super(hostElement)
this.results = [] // Results of grammar-checking, one per each block of text
this.handleBeforeInput = event => this.onBeforeInput(event)
this.hostElement.addEventListener('beforeinput', this.handleBeforeInput)
this.handleInput = () => this.onInput()
this.hostElement.addEventListener('input', this.handleInput)
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)
}
/**
@@ -259,8 +260,9 @@ class BesDOMService extends BesService {
* Unregisters grammar checking service.
*/
unregister() {
this.hostElement.removeEventListener('input', this.handleInput)
this.hostElement.removeEventListener('beforeinput', this.handleBeforeInput)
this.hostElement.removeEventListener('click', this.onClick)
this.hostElement.removeEventListener('input', this.onInput)
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
if (this.timer) clearTimeout(this.timer)
if (this.abortController) this.abortController.abort()
super.unregister()
@@ -727,8 +729,314 @@ class BesDOMService extends BesService {
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 => {