1711 lines
52 KiB
JavaScript
1711 lines
52 KiB
JavaScript
// TODO: Test with contenteditable="plaintext-only"
|
|
// TODO: Implement <textarea> class
|
|
|
|
/**
|
|
* Collection of all grammar checking services in the document
|
|
*
|
|
* We dispatch relevant window messages to all services registered here.
|
|
*/
|
|
let besServices = []
|
|
|
|
window.addEventListener('scroll', () =>
|
|
besServices.forEach(service => service.onScroll())
|
|
)
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Base class for all grammar-checking services
|
|
*
|
|
*************************************************************************/
|
|
class BesService {
|
|
constructor(hostElement) {
|
|
this.hostElement = hostElement
|
|
this.results = [] // Results of grammar-checking, one per each block/paragraph of text
|
|
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)
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.onResize()
|
|
})
|
|
this.resizeObserver.observe(this.hostElement)
|
|
|
|
besServices.push(this)
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
if (this.abortController) this.abortController.abort()
|
|
besServices = besServices.filter(item => item !== this)
|
|
this.resizeObserver.disconnect()
|
|
this.hostElement.removeEventListener('scroll', this.onScroll)
|
|
this.hostElement.spellcheck = this.originalSpellcheck
|
|
this.clearCorrectionPanel()
|
|
}
|
|
|
|
/**
|
|
* Called initially when grammar-checking run is started
|
|
*/
|
|
onStartProofing() {
|
|
this.proofingCount = 1 // 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 (error !== 'AbortError' && !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`
|
|
|
|
// 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() {
|
|
this.setCorrectionPanelSize()
|
|
this.setStatusDivPosition()
|
|
|
|
// When window is resized, host element might resize too.
|
|
// This may cause text to re-wrap requiring markup repositioning.
|
|
this.repositionAllMarkup()
|
|
}
|
|
|
|
/**
|
|
* 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 rect of clientRects) {
|
|
const highlight = document.createElement('div')
|
|
highlight.classList.add('bes-typo-mistake')
|
|
highlight.style.left = `${rect.left - scrollPanelRect.left}px`
|
|
highlight.style.top = `${rect.top - scrollPanelRect.top}px`
|
|
highlight.style.width = `${rect.width}px`
|
|
highlight.style.height = `${rect.height}px`
|
|
this.scrollPanel.appendChild(highlight)
|
|
highlights.push(highlight)
|
|
}
|
|
return { clientRects, highlights }
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
// document.body.appendChild(statusPopup)
|
|
// this.statusDiv.addEventListener('click', e =>
|
|
// statusPopup.show(e.clientX, e.clientY, this)
|
|
// )
|
|
}
|
|
|
|
/**
|
|
* 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`
|
|
}
|
|
|
|
/**
|
|
* Displays correction panel.
|
|
*
|
|
* @param {*} el Block element/paragraph containing grammar checking rule match
|
|
* @param {*} match Grammar checking rule match
|
|
* @param {PointerEvent} source Click event source
|
|
*/
|
|
popupCorrectionPanel(el, match, source) {
|
|
const popup = document.querySelector('bes-popup-el')
|
|
popup.changeMessage(match.match.message)
|
|
popup.appendReplacements(
|
|
el,
|
|
match,
|
|
this,
|
|
this.hostElement.contentEditable !== 'false'
|
|
)
|
|
popup.show(source.clientX, source.clientY)
|
|
}
|
|
|
|
/**
|
|
* Replaces grammar checking match with a suggestion provided by grammar checking service.
|
|
*
|
|
* @param {*} el Block element/paragraph 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.proofAll()
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Grammar-checking service base class for tree-organized editors
|
|
*
|
|
*************************************************************************/
|
|
class BesTreeService extends BesService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.onClick = this.onClick.bind(this)
|
|
this.hostElement.addEventListener('click', this.onClick)
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
this.hostElement.removeEventListener('click', this.onClick)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Recursively grammar-(re)checks our host DOM tree.
|
|
*/
|
|
proofAll() {
|
|
this.onStartProofing()
|
|
this.proofNode(this.hostElement, this.abortController)
|
|
this.onProofingProgress(0)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
let result = this.getProofing(node)
|
|
if (result) {
|
|
this.onProofing()
|
|
this.onProofingProgress(result.matches.length)
|
|
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 => 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 {*} Result of grammar check if the element has already been grammar-checked; undefined otherwise.
|
|
*/
|
|
getProofing(el) {
|
|
return this.results.find(result =>
|
|
BesTreeService.isSameParagraph(result.element, el)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Marks given block element as grammar-checked.
|
|
*
|
|
* @param {Element} el DOM element that was checked
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(el, matches) {
|
|
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 => !BesTreeService.isSameParagraph(result.element, el)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this.results
|
|
.filter(result => BesTreeService.isSameParagraph(result.element, el))
|
|
.forEach(result =>
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) {
|
|
match.highlights.forEach(h => h.remove())
|
|
delete match.highlights
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given block elements represent the same block of text
|
|
*
|
|
* @param {Element} el1 DOM element
|
|
* @param {Element} el2 DOM element
|
|
* @returns {Boolean} true if block elements are the same
|
|
*/
|
|
static isSameParagraph(el1, el2) {
|
|
return el1 === el2
|
|
}
|
|
|
|
/**
|
|
* 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 = BesTreeService.getParents(start)
|
|
let endAncestors = BesTreeService.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 = BesTreeService.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 el = this.getBlockParent(source.targetElement || source.target)
|
|
if (!el) return
|
|
|
|
const result = this.results.find(child =>
|
|
BesTreeService.isSameParagraph(child.element, el)
|
|
)
|
|
if (result) {
|
|
for (let m of result.matches) {
|
|
for (let r of m.rects) {
|
|
if (BesService.isPointInRect(source.clientX, source.clientY, r)) {
|
|
this.popupCorrectionPanel(el, m, source)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BesPopup.hide()
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* DOM grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesDOMService extends BesTreeService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.onBeforeInput = this.onBeforeInput.bind(this)
|
|
this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
|
|
this.onInput = this.onInput.bind(this)
|
|
this.hostElement.addEventListener('input', this.onInput)
|
|
}
|
|
|
|
/**
|
|
* Registers grammar checking service.
|
|
*
|
|
* @param {Element} hostElement DOM element to register grammar checking service for
|
|
* @returns {BesDOMService} Grammar checking service instance
|
|
*/
|
|
static register(hostElement) {
|
|
let service = new BesDOMService(hostElement)
|
|
service.proofAll()
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
this.hostElement.removeEventListener('input', this.onInput)
|
|
this.hostElement.removeEventListener('beforeinput', this.onBeforeInput)
|
|
if (this.timer) clearTimeout(this.timer)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* 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 =>
|
|
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)
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* CKEditor grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesCKService extends BesTreeService {
|
|
constructor(hostElement, ckEditorInstance) {
|
|
super(hostElement)
|
|
this.ckEditorInstance = ckEditorInstance
|
|
this.disableCKEditorSpellcheck()
|
|
this.onChangeData = this.onChangeData.bind(this)
|
|
this.ckEditorInstance.model.document.on('change:data', this.onChangeData)
|
|
}
|
|
|
|
/**
|
|
* 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.proofAll()
|
|
return service
|
|
}
|
|
|
|
/**
|
|
* Unregisters grammar checking service.
|
|
*/
|
|
unregister() {
|
|
// TODO: Undo `this.ckEditorInstance.model.document.on('change:data', this.onChangeData)`.
|
|
this.restoreCKEditorSpellcheck()
|
|
if (this.timer) clearTimeout(this.timer)
|
|
super.unregister()
|
|
}
|
|
|
|
/**
|
|
* Called to report the text has changed
|
|
*/
|
|
onChangeData() {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
if (this.abortController) this.abortController.abort()
|
|
const differ = this.ckEditorInstance.model.document.differ
|
|
for (const entry of Array.from(differ.getChanges())) {
|
|
let element =
|
|
entry.type === 'attribute'
|
|
? entry.range.start.parent
|
|
: entry._element || entry.position.parent
|
|
const domElement = this.getDomElement(element)
|
|
this.clearProofing(domElement)
|
|
}
|
|
|
|
// TODO: Research if input event or any other event that is called *after* the change is completed
|
|
// is possible with CKEditor, and move the code below this line there.
|
|
setTimeout(() => {
|
|
// 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)
|
|
}, 0)
|
|
}
|
|
|
|
/**
|
|
* This function converts a CKEditor element to a DOM element.
|
|
*
|
|
* @param {CKEditor} element
|
|
* @returns domElement
|
|
*/
|
|
getDomElement(element) {
|
|
const viewElement =
|
|
this.ckEditorInstance.editing.mapper.toViewElement(element)
|
|
const domElement =
|
|
this.ckEditorInstance.editing.view.domConverter.mapViewToDom(viewElement)
|
|
return domElement
|
|
}
|
|
|
|
/**
|
|
* Disables the CKEditor spellcheck.
|
|
*/
|
|
disableCKEditorSpellcheck() {
|
|
this.ckEditorInstance.editing.view.change(writer => {
|
|
const root = this.ckEditorInstance.editing.view.document.getRoot()
|
|
// TODO: Get true original CKEditor spellcheck setting (writer.getAttribute('spellcheck', root)?).
|
|
this.originalCKSpellcheck = 'true'
|
|
writer.setAttribute('spellcheck', 'false', root)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Restores the CKEditor spellcheck.
|
|
*/
|
|
restoreCKEditorSpellcheck() {
|
|
this.ckEditorInstance.editing.view.change(writer => {
|
|
writer.setAttribute(
|
|
'spellcheck',
|
|
this.originalCKSpellcheck,
|
|
this.ckEditorInstance.editing.view.document.getRoot()
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Replaces grammar checking match with a suggestion provided by grammar checking service.
|
|
*
|
|
* @param {*} el Block element/paragraph 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)
|
|
const viewRange =
|
|
this.ckEditorInstance.editing.view.domConverter.domRangeToView(
|
|
match.range
|
|
)
|
|
const modelRange =
|
|
this.ckEditorInstance.editing.mapper.toModelRange(viewRange)
|
|
this.ckEditorInstance.model.change(writer => {
|
|
const attributes =
|
|
this.ckEditorInstance.model.document.selection.getAttributes()
|
|
writer.remove(modelRange)
|
|
writer.insertText(replacement, attributes, modelRange.start)
|
|
})
|
|
this.proofAll()
|
|
}
|
|
|
|
/**
|
|
* Repositions status DIV element.
|
|
*/
|
|
setStatusDivPosition() {
|
|
// TODO: Position is not correct (SR6, Edge).
|
|
const rect = this.hostElement.getBoundingClientRect()
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
|
this.statusDiv.style.left = `${rect.right - 50}px`
|
|
this.statusDiv.style.top = `${rect.top + rect.height - 100 + scrollTop}px`
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Plain-text grammar-checking service
|
|
*
|
|
*************************************************************************/
|
|
class BesPlainTextService extends BesService {
|
|
constructor(hostElement) {
|
|
super(hostElement)
|
|
this.reEOP = /(\r?\n){2,}/g
|
|
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 {BesPlainTextService} Grammar checking service instance
|
|
*/
|
|
static register(hostElement) {
|
|
let service = new BesPlainTextService(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 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()
|
|
|
|
// Firefox does not support contenteditable="plaintext-only" at all, Chrome/Edge's InputEvent.getTargetRanges() return
|
|
// a useless empty array for contenteditable="plaintext-only". This makes tracking location of changes a pain.
|
|
// We need to save the text on beforeinput and compare it to the text on input event to do the this.clearProofing().
|
|
let { text } = this.getTextFromNodes()
|
|
this.textBeforeChange = text
|
|
// Continues in onInput...
|
|
}
|
|
|
|
/**
|
|
* Called to report the text has changed
|
|
*
|
|
* @param {InputEvent} event The event notifying the user of editable content changes
|
|
*/
|
|
onInput(event) {
|
|
// ...Continued from onBeforeInput: Remove markup of all paragraphs that changed.
|
|
// Use the offsets before change, as paragraph changes have not been updated yet.
|
|
let paragraphRanges = new Set()
|
|
this.getTargetRanges().forEach(range => {
|
|
this.results.forEach(result => {
|
|
if (BesPlainTextService.isOverlappingParagraph(result.range, range))
|
|
paragraphRanges.add(result.range)
|
|
})
|
|
})
|
|
paragraphRanges.forEach(range => this.clearProofing(range))
|
|
delete this.textBeforeChange
|
|
|
|
// 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)
|
|
}
|
|
|
|
/**
|
|
* Returns an array of ranges that will be affected by a change to the DOM.
|
|
*
|
|
* This method attempts to fix the Chrome/Edge shortcoming in InputEvent.getTargetRanges()
|
|
* failing to return meaningful range array on beforeinput event.
|
|
*/
|
|
getTargetRanges() {
|
|
let textA = this.textBeforeChange
|
|
let { text, nodes } = this.getTextFromNodes()
|
|
let textB = text
|
|
let nodesB = nodes
|
|
|
|
let ranges = []
|
|
for (let i = 0, j = 0, nodeIdxB = 0; ; ) {
|
|
if (i >= textA.length && j >= textB.length) break
|
|
if (i >= textA.length) {
|
|
// Some text was appended.
|
|
let range = document.createRange()
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
|
ranges.push(range)
|
|
break
|
|
}
|
|
if (j >= textB.length) {
|
|
// Some text was deleted at the end.
|
|
let range = document.createRange()
|
|
// range.setStartAfter(nodesB[nodesB.length - 1].node) // This puts range start at the </div>:1???
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
range.setEndAfter(nodesB[nodesB.length - 1].node)
|
|
ranges.push(range)
|
|
break
|
|
}
|
|
if (textA.charAt(i) != textB.charAt(j)) {
|
|
let range = document.createRange()
|
|
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
let a = textA.indexOf(textB.substr(j, 3), i)
|
|
if (a < 0) a = textA.length
|
|
let b = textB.indexOf(textA.substr(i, 3), j)
|
|
if (b < 0) b = textB.length
|
|
if (3 * (a - i) <= b - j) {
|
|
// Suppose some text was deleted.
|
|
i = a
|
|
range.setEnd(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
|
|
} else if (3 * (b - j) <= a - i) {
|
|
// Suppose some text was inserted.
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
|
nodeIdxB++
|
|
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
|
nodeIdxB++
|
|
} else {
|
|
// Suppose some text was replaced.
|
|
i = a
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
|
|
nodeIdxB++
|
|
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= b)
|
|
nodeIdxB++
|
|
}
|
|
ranges.push(range)
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
|
continue
|
|
}
|
|
i++
|
|
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end <= j) nodeIdxB++
|
|
j++
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
/**
|
|
* Grammar-(re)checks the host element.
|
|
*/
|
|
proofAll() {
|
|
this.onStartProofing()
|
|
|
|
let { text, nodes } = this.getTextFromNodes()
|
|
let nextParagraphRange = document.createRange()
|
|
nextParagraphRange.setStartBefore(nodes[0].node)
|
|
for (
|
|
let start = 0, eop, end, nodeIdx = 0;
|
|
start < text.length && nodeIdx < nodes.length;
|
|
start = end
|
|
) {
|
|
this.reEOP.lastIndex = start
|
|
let match = this.reEOP.exec(text)
|
|
if (match) {
|
|
eop = match.index
|
|
end = this.reEOP.lastIndex
|
|
} else {
|
|
eop = end = text.length
|
|
}
|
|
|
|
let paragraphRange = nextParagraphRange
|
|
nextParagraphRange = document.createRange()
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < eop) nodeIdx++
|
|
nextParagraphRange.setStart(
|
|
nodes[nodeIdx].node,
|
|
eop - nodes[nodeIdx].start
|
|
)
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < end) nodeIdx++
|
|
paragraphRange.setEnd(nodes[nodeIdx].node, end - nodes[nodeIdx].start)
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end <= end) nodeIdx++
|
|
|
|
let result = this.getProofing(paragraphRange)
|
|
if (result) {
|
|
this.onProofing()
|
|
this.onProofingProgress(result.matches.length)
|
|
continue
|
|
}
|
|
let paragraphText = text.substring(start, end)
|
|
if (!/^\s*$/.test(paragraphText)) {
|
|
// Paragraph contains some text.
|
|
this.onProofing()
|
|
const signal = this.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: [
|
|
{
|
|
text: paragraphText
|
|
}
|
|
]
|
|
}),
|
|
language: this.hostElement.lang ? this.hostElement.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 matchRange = document.createRange()
|
|
let nodeIdx = 0,
|
|
matchStart = start + match.offset
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchStart)
|
|
nodeIdx++
|
|
matchRange.setStart(
|
|
nodes[nodeIdx].node,
|
|
matchStart - nodes[nodeIdx].start
|
|
)
|
|
let matchEnd = matchStart + match.length
|
|
while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchEnd)
|
|
nodeIdx++
|
|
matchRange.setEnd(
|
|
nodes[nodeIdx].node,
|
|
matchEnd - nodes[nodeIdx].start
|
|
)
|
|
const { clientRects, highlights } =
|
|
this.addMistakeMarkup(matchRange)
|
|
matches.push({
|
|
rects: clientRects,
|
|
highlights: highlights,
|
|
range: matchRange,
|
|
match: match
|
|
})
|
|
})
|
|
this.markProofed(paragraphRange, matches)
|
|
this.onProofingProgress(matches.length)
|
|
})
|
|
.catch(error => this.onFailedProofingResult(error))
|
|
}
|
|
}
|
|
|
|
this.onProofingProgress(0)
|
|
}
|
|
|
|
/**
|
|
* Concatenates child text nodes
|
|
*
|
|
* @returns {Object} Concatenated text and array of nodes
|
|
*/
|
|
getTextFromNodes() {
|
|
let nodes = []
|
|
let text = ''
|
|
for (
|
|
let node = this.hostElement.childNodes[0];
|
|
node;
|
|
node = node.nextSibling
|
|
) {
|
|
nodes.push({
|
|
node: node,
|
|
start: text.length,
|
|
end: text.length + node.data.length
|
|
})
|
|
text += node.data
|
|
}
|
|
return { text, nodes }
|
|
}
|
|
|
|
/**
|
|
* Tests if given paragraph has already been grammar-checked.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
* @returns {*} Result of grammar check if the element has already been grammar-checked; undefined otherwise.
|
|
*/
|
|
getProofing(range) {
|
|
return this.results.find(result =>
|
|
BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Marks given paragraph as grammar-checked.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
* @param {Array} matches Grammar mistakes
|
|
*/
|
|
markProofed(range, matches) {
|
|
this.results.push({
|
|
range: range,
|
|
matches: matches
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Removes given paragraph from this.results array and clearing its markup.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
*/
|
|
clearProofing(range) {
|
|
this.clearMarkup(range)
|
|
this.results = this.results.filter(
|
|
result => !BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 paragraph grammar mistake markup.
|
|
*
|
|
* @param {Range} range Paragraph range
|
|
*/
|
|
clearMarkup(range) {
|
|
this.results
|
|
.filter(result =>
|
|
BesPlainTextService.isSameParagraph(result.range, range)
|
|
)
|
|
.forEach(result =>
|
|
result.matches.forEach(match => {
|
|
if (match.highlights) {
|
|
match.highlights.forEach(h => h.remove())
|
|
delete match.highlights
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given ranges represent the same paragraph of text
|
|
*
|
|
* @param {Range} range1 Range 1
|
|
* @param {Range} range2 Range 2
|
|
* @returns {Boolean} true if ranges overlap
|
|
*/
|
|
static isSameParagraph(range1, range2) {
|
|
return (
|
|
range1.compareBoundaryPoints(Range.START_TO_START, range2) == 0 &&
|
|
range1.compareBoundaryPoints(Range.END_TO_END, range2) == 0
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Tests if given ranges overlap or are adjacent to each other
|
|
*
|
|
* @param {Range} range1 Range 1
|
|
* @param {Range} range2 Range 2
|
|
* @returns {Boolean} true if ranges overlap
|
|
*/
|
|
static isOverlappingParagraph(range1, range2) {
|
|
const a = range1.compareBoundaryPoints(Range.END_TO_START, range2)
|
|
const b = range1.compareBoundaryPoints(Range.START_TO_END, range2)
|
|
return a == 0 || b == 0 || (a < 0 && b > 0)
|
|
}
|
|
|
|
/**
|
|
* 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 el = source.targetElement || source.target || this.hostElement
|
|
if (!el) return
|
|
|
|
for (let result of this.results) {
|
|
for (let m of result.matches) {
|
|
for (let r of m.rects) {
|
|
if (BesService.isPointInRect(source.clientX, source.clientY, r)) {
|
|
this.popupCorrectionPanel(result.range, m, source)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BesPopup.hide()
|
|
}
|
|
}
|
|
|
|
/*************************************************************************
|
|
*
|
|
* Grammar mistake popup dialog
|
|
*
|
|
*************************************************************************/
|
|
class BesPopup extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
this.attachShadow({ mode: 'open' })
|
|
}
|
|
|
|
/**
|
|
* Called each time the element is added to the document
|
|
*/
|
|
connectedCallback() {
|
|
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="BesPopup.hide()">X</button>
|
|
</div>
|
|
<div class="bes-text-div">
|
|
<span class="popup-text">
|
|
</span>
|
|
<div class="bes-replacement-div">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
this.addEventListener('mousedown', this.onMouseDown)
|
|
}
|
|
|
|
/**
|
|
* Shows popup window.
|
|
*
|
|
* @param {Number} x X location hint
|
|
* @param {Number} 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 {*} el Block element/paragraph 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)
|
|
BesPopup.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)
|
|
}
|
|
|
|
/**
|
|
* Dismisses all the popups.
|
|
*/
|
|
static hide() {
|
|
document
|
|
.querySelectorAll('bes-popup-el')
|
|
.forEach(popup => popup.classList.remove('show'))
|
|
}
|
|
}
|
|
|
|
customElements.define('bes-popup-el', BesPopup)
|
|
|
|
// /*************************************************************************
|
|
// *
|
|
// * Status pop-up
|
|
// *
|
|
// *************************************************************************/
|
|
// class BesStatusPopup extends HTMLElement {
|
|
// constructor() {
|
|
// super()
|
|
// this.attachShadow({ mode: 'open' })
|
|
// }
|
|
|
|
// /**
|
|
// * Called each time the element is added to the document
|
|
// */
|
|
// connectedCallback() {
|
|
// 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>
|
|
// `
|
|
// }
|
|
|
|
// /**
|
|
// * Shows popup window.
|
|
// *
|
|
// * @param {Number} x X location hint
|
|
// * @param {Number} y Y location hint
|
|
// * @param {BesService} service Grammar checking service
|
|
// */
|
|
// show(x, y, service) {
|
|
// this.style.position = 'fixed'
|
|
// this.style.display = 'block'
|
|
|
|
// // 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`
|
|
// }
|
|
|
|
// if (service) {
|
|
// const disableButton = this.shadowRoot.querySelector('.bes-service-btn')
|
|
// disableButton.onclick = () => {
|
|
// service.unregister()
|
|
// BesStatusPopup.hide()
|
|
// }
|
|
// }
|
|
// this.classList.add('show')
|
|
// }
|
|
|
|
// /**
|
|
// * Dismisses all the popups.
|
|
// */
|
|
// static hide() {
|
|
// const popup = document.querySelector('bes-popup-status.show')
|
|
// popup?.classList?.remove('show')
|
|
// }
|
|
// }
|
|
|
|
// customElements.define('bes-popup-status', BesStatusPopup)
|
|
|
|
// Auto-register all elements with bes-service class.
|
|
window.addEventListener('load', () => {
|
|
document.querySelectorAll('.bes-service').forEach(hostElement => {
|
|
if (hostElement.tagName === 'TEXTAREA') {
|
|
BesTAService.register(hostElement)
|
|
} else if (
|
|
hostElement.getAttribute('contenteditable').toLowerCase() ===
|
|
'plaintext-only'
|
|
) {
|
|
BesPlainTextService.register(hostElement)
|
|
} else {
|
|
BesDOMService.register(hostElement)
|
|
}
|
|
})
|
|
})
|