BesService/service.js
Simon Rozman b7315dd608 Expose the rest of the public classes
Keep this repo open for all use cases it is intended to provide proofing
services for. Even if there is no existing known client for it yet.
2024-11-25 14:58:23 +01:00

2244 lines
71 KiB
JavaScript

// TODO: Research if there is a way to disable languageTool & Grammarly extensions in CKEditor
/**
* 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.onReposition())
)
/**************************************************************************************************
*
* Base class for all grammar-checking services
*
* This class provides properties and implementations of methods common to all types of HTML
* controls.
*
* This is an intermediate class and may not be used directly in client code.
*
*************************************************************************************************/
class BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, textElement, eventSink) {
this.hostElement = hostElement
this.textElement = textElement
this.eventSink = eventSink
this.enabledRules = []
this.disabledRules = []
this.enabledCategories = []
this.disabledCategories = []
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 is coppied from https://stackoverflow.com/questions/37444906/how-to-stop-extensions-add-ons-like-grammarly-on-contenteditable-editors
this.originalDataGramm = this.hostElement.getAttribute('data-gramm')
this.originalDataGrammEditor =
this.hostElement.getAttribute('data-gramm_editor')
this.originalEnableGrammarly = this.hostElement.getAttribute(
'data-enable-grammarly'
)
this.hostElement.setAttribute('data-gramm', 'false')
this.hostElement.setAttribute('data-gramm_editor', 'false')
this.hostElement.setAttribute('data-enable-grammarly', 'false')
this.onScroll = this.onScroll.bind(this)
this.hostElement.addEventListener('scroll', this.onScroll)
this.hostBoundingClientRect = this.hostElement.getBoundingClientRect()
this.mutationObserver = new MutationObserver(this.onBodyMutate.bind(this))
this.mutationObserver.observe(document.body, {
attributes: true,
childList: true,
subtree: true
})
besServices.push(this)
}
/**
* Registers grammar checking service for given DOM element.
*
* Note: CKEditor controls are an exception that may not be registered using this method. Use
* BesCKService.register for that.
*
* @param {Element} hostElement Host element
* @param {*} eventSink Event sink for notifications
* @returns Grammar checking service registered for given DOM element; unfedined if no service
* registered.
*/
static registerByElement(hostElement, eventSink) {
if (hostElement.tagName === 'TEXTAREA') {
return BesTAService.register(hostElement, eventSink)
} else if (
hostElement.getAttribute('contenteditable')?.toLowerCase() ===
'plaintext-only'
) {
return BesDOMPlainTextService.register(hostElement, eventSink)
} else {
return BesDOMService.register(hostElement, eventSink)
}
}
/**
* Unregisters grammar checking service.
*/
unregister() {
if (this.abortController) this.abortController.abort()
besServices = besServices.filter(item => item !== this)
this.mutationObserver.disconnect()
this.hostElement.removeEventListener('scroll', this.onScroll)
this.hostElement.setAttribute('spellcheck', this.originalSpellcheck)
this.hostElement.setAttribute('data-gramm', this.originalDataGramm)
this.hostElement.setAttribute(
'data-gramm_editor',
this.originalDataGrammEditor
)
this.hostElement.spellcheck = this.originalSpellcheck
this.clearCorrectionPanel()
if (this.eventSink && 'unregister' in this.eventSink)
this.eventSink.unregister(this)
}
/**
* Returns grammar checking service registered for given DOM element
*
* @param {Element} hostElement Host element
* @returns Grammar checking service registered for given DOM element; unfedined if no service
* registered.
*/
static getServiceByElement(hostElement) {
return besServices.find(service => service.hostElement === hostElement)
}
/**
* Unregisters grammar checking service
*
* @param {Element} hostElement Host element
*/
static unregisterByElement(hostElement) {
BesService.getServiceByElement(hostElement)?.unregister()
}
/**
* Enables given grammar rule.
*
* @param {String} rule Rule ID. For the list of rule IDs, see /api/v2/configinfo output.
*/
enableRule(rule) {
this.enabledRules.push(rule)
this.disabledRules = this.disabledRules.filter(value => value !== rule)
this.scheduleProofing(10)
}
/**
* Disables given grammar rule.
*
* @param {String} rule Rule ID. For the list of rule IDs, see /api/v2/configinfo output.
*/
disableRule(rule) {
this.enabledRules = this.enabledRules.filter(value => value !== rule)
this.disabledRules.push(rule)
this.scheduleProofing(10)
return this
}
/**
* Enables all grammar rules of the given category.
*
* @param {String} cat Category ID. For the list of category IDs, see Readme.md.
*/
enableCategory(cat) {
this.enabledCategories.push(cat)
this.disabledCategories = this.disabledCategories.filter(
value => value !== cat
)
this.scheduleProofing(10)
}
/**
* Disables all grammar rules of the given category.
*
* @param {String} cat Category ID. For the list of category IDs, see Readme.md.
*/
disableCategory(cat) {
this.enabledCategories = this.enabledCategories.filter(
value => value !== cat
)
this.disabledCategories.push(cat)
this.scheduleProofing(10)
return this
}
/**
* Schedules proofing after given number of milliseconds.
*
* @param {Number} timeout Number of milliseconds to delay proofing start
*/
scheduleProofing(timeout) {
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.proofAll()
delete this.timer
}, timeout)
}
/**
* 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.abortController = new AbortController()
if (this.eventSink && 'startProofing' in this.eventSink)
this.eventSink.startProofing(this)
}
/**
* Called when grammar-checking starts proofing each block of text (typically paragraph)
*/
onProofing() {
this.proofingCount++
if (this.eventSink && 'proofing' in this.eventSink)
this.eventSink.proofing(this)
}
/**
* 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
console.log(
`Grammar checking failed: ${response.status} ${response.statusText}`
)
if (this.eventSink && 'failedProofing' in this.eventSink)
this.eventSink.failedProofing(this, response)
}
/**
* Called when failed to parse result of a grammar-checking of a block of text
*
* @param {Error} error Error
*/
onFailedProofingResult(error) {
if (error !== 'AbortError') {
if (!this.proofingError) this.proofingError = error
console.log(`Failed to parse grammar checking results: ${error}`)
}
if (this.eventSink && 'failedProofingResult' in this.eventSink)
this.eventSink.failedProofingResult(this, 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.eventSink && 'proofingProgress' in this.eventSink)
this.eventSink.proofingProgress(this)
if (--this.proofingCount <= 0) this.onEndProofing()
}
/**
* Called when grammar-checking run is ended
*/
onEndProofing() {
delete this.abortController
if (this.eventSink && 'endProofing' in this.eventSink)
this.eventSink.endProofing(this)
}
/**
* 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`
if (this.hostElement !== this.textElement) {
this.textElement.scrollTop = this.hostElement.scrollTop
this.textElement.scrollLeft = this.hostElement.scrollLeft
}
}
/**
* Called to report repositioning
*/
onReposition() {
this.setCorrectionPanelSize()
if (this.eventSink && 'reposition' in this.eventSink)
this.eventSink.reposition(this)
}
/**
* Called to report resizing
*/
onResize() {
this.setCorrectionPanelSize()
this.repositionAllMarkup()
if (this.eventSink && 'resize' in this.eventSink)
this.eventSink.resize(this)
}
/**
* Called to report document body change
*/
onBodyMutate() {
const rect = this.hostElement.getBoundingClientRect()
if (
rect.top !== this.hostBoundingClientRect.top ||
rect.left !== this.hostBoundingClientRect.left
)
this.onReposition()
if (
rect.width !== this.hostBoundingClientRect.width ||
rect.height !== this.hostBoundingClientRect.height
)
this.onResize()
this.hostBoundingClientRect = rect
}
/**
* Creates grammar mistake markup in DOM.
*
* @param {Range} range Grammar mistake range
* @param {String} ruleId Grammar mistake rule ID as reported by BesStr
* @returns {Array} Grammar mistake highlight elements
*/
addMistakeMarkup(range, ruleId) {
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
let highlights = []
for (let rect of range.getClientRects()) {
const highlight = document.createElement('div')
highlight.classList.add(
ruleId.startsWith('MORFOLOGIK_RULE')
? 'bes-spelling-mistake'
: 'bes-grammar-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 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.correctionPanel.classList.add('bes-correction-panel')
this.scrollPanel = document.createElement('div')
this.scrollPanel.classList.add('bes-correction-panel-scroll')
panelParent.appendChild(this.correctionPanel)
this.correctionPanel.appendChild(this.scrollPanel)
this.textElement.parentElement.insertBefore(panelParent, this.textElement)
this.setCorrectionPanelSize()
}
/**
* Clears auxiliary DOM elements for text adornments.
*/
clearCorrectionPanel() {
this.correctionPanel.remove()
this.scrollPanel.remove()
}
/**
* Resizes correction and scroll panels to match host element size.
*/
setCorrectionPanelSize() {
const styles = window.getComputedStyle(this.hostElement)
this.correctionPanel.style.marginLeft = styles.marginLeft
this.correctionPanel.style.marginTop = styles.marginTop
this.correctionPanel.style.marginRight = styles.marginRight
this.correctionPanel.style.marginBottom = styles.marginBottom
this.correctionPanel.style.paddingLeft = styles.paddingLeft
this.correctionPanel.style.paddingTop = styles.paddingTop
this.correctionPanel.style.paddingRight = styles.paddingRight
this.correctionPanel.style.paddingBottom = styles.paddingBottom
this.scrollPanel.style.width = `${this.hostElement.scrollWidth}px`
this.scrollPanel.style.height = `${this.hostElement.scrollHeight}px`
if (this.isHostElementInline()) {
const totalWidth =
parseFloat(styles.paddingLeft) +
parseFloat(styles.marginLeft) +
parseFloat(styles.width) +
parseFloat(styles.marginRight) +
parseFloat(styles.paddingRight)
this.correctionPanel.style.width = `${totalWidth}px`
this.correctionPanel.style.height = styles.height
} else {
this.correctionPanel.style.width = styles.width
this.correctionPanel.style.height = styles.height
}
}
/**
* 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.isContentEditable())
popup.show(source.clientX, source.clientY)
}
/**
* Checks if host element content is editable.
*
* @returns true if editable; false otherwise
*/
isContentEditable() {
switch (this.hostElement.contentEditable) {
case 'true':
case 'plaintext-only':
return true
}
return false
}
/**
* Updates all grammar mistake markup positions.
*/
repositionAllMarkup() {
this.results.forEach(result => {
result.matches.forEach(match => {
if (match.highlights) match.highlights.forEach(h => h.remove())
match.highlights = this.addMistakeMarkup(
match.range,
match.match.rule.id
)
})
})
}
/**
* 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()
}
/**
* Tests if host element is inline
*
* @returns true if CSS display property is inline; false otherwise.
*/
isHostElementInline() {
switch (
document.defaultView
.getComputedStyle(this.hostElement, null)
.getPropertyValue('display')
.toLowerCase()
) {
case 'inline':
return true
default:
return false
}
}
}
/**************************************************************************************************
*
* Grammar-checking service base class for tree-organized editors
*
* This class provides common properties and methods for HTML controls where text content is
* organized as a DOM tree. The grammar is checked recursively and the DOM elements in tree specify
* which parts of text represent same unit of text: block element => one standalone paragraph
*
* This is an intermediate class and may not be used directly in client code.
*
*************************************************************************************************/
class BesTreeService extends BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, textElement, eventSink) {
super(hostElement, textElement, eventSink)
this.onClick = this.onClick.bind(this)
this.textElement.addEventListener('click', this.onClick)
}
/**
* Unregisters grammar checking service.
*/
unregister() {
this.textElement.removeEventListener('click', this.onClick)
super.unregister()
}
/**
* Recursively grammar-(re)checks our host DOM tree.
*/
proofAll() {
this.onStartProofing()
this.proofNode(this.textElement, 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',
enabledRules: this.enabledRules.join(','),
disabledRules: this.disabledRules.join(','),
enabledCategories: this.enabledCategories.join(','),
disabledCategories: this.disabledCategories.join(','),
enabledOnly: 'false'
})
}),
{ 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 &&*/ endOffset <=
startingOffset + data[idx].text.length
) {
range.setEnd(data[idx].node, endOffset - startingOffset)
break
}
}
matches.push({
highlights: this.addMistakeMarkup(range, match.rule.id),
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)
)
}
/**
* 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.textElement) 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
for (let result of this.results) {
for (let m of result.matches) {
for (let h of m.highlights) {
if (
BesService.isPointInRect(
source.clientX,
source.clientY,
h.getBoundingClientRect()
)
) {
this.popupCorrectionPanel(el, m, source)
return
}
}
}
}
BesPopup.hide()
}
}
/**************************************************************************************************
*
* DOM grammar-checking service
*
* This class provides grammar-checking functionality to contenteditable="true" HTML controls.
*
* May also be used on most of the HTML elements to highlight grammar mistakes. Replacing text with
* suggestions from the grammar-checker will not be possible when contenteditable is not "true".
*
*************************************************************************************************/
class BesDOMService extends BesTreeService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, eventSink) {
super(hostElement, hostElement, eventSink)
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
* @param {*} eventSink Event sink for notifications
* @returns {BesDOMService} Grammar checking service instance
*/
static register(hostElement, eventSink) {
let service = BesService.getServiceByElement(hostElement)
if (service) return service
service = new BesDOMService(hostElement, eventSink)
if (service.eventSink && 'register' in service.eventSink)
service.eventSink.register(service)
// Defer proofing giving user a chance to configure the service.
service.scheduleProofing(10)
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.scheduleProofing(1000)
}
}
/**************************************************************************************************
*
* CKEditor grammar-checking service
*
* This class provides grammar-checking functionality to CKEditor controls.
*
*************************************************************************************************/
class BesCKService extends BesTreeService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} ckEditorInstance CKEditor instance
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, ckEditorInstance, eventSink) {
super(hostElement, hostElement, eventSink)
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
* @param {*} eventSink Event sink for notifications
* @returns {BesCKService} Grammar checking service instance
*/
static register(hostElement, ckEditorInstance, eventSink) {
let service = BesService.getServiceByElement(hostElement)
if (service) return service
service = new BesCKService(hostElement, ckEditorInstance, eventSink)
if (service.eventSink && 'register' in service.eventSink)
service.eventSink.register(service)
// Defer proofing giving user a chance to configure the service.
service.scheduleProofing(10)
return service
}
/**
* Unregisters grammar checking service.
*/
unregister() {
this.ckEditorInstance.model.document.off('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 is in fact necessary, because if we set specific height to the editable CKeditor
// element, it will not be updated immediately.
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.scheduleProofing(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()
this.originalCKSpellcheck = root.getAttribute('spellcheck')
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()
}
/**
* Tests if host element is inline
*
* @returns true as CKEditor host elements always behave this way.
*/
isHostElementInline() {
return true
}
}
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides common properties and methods for plain-text-only HTML controls like
* <input>, <textarea>, <div contenteditable="plaintext-only">...
*
*************************************************************************************************/
class BesPlainTextService extends BesService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, textElement, eventSink) {
super(hostElement, textElement, eventSink)
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)
}
/**
* 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()
}
/**
* 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',
enabledRules: this.enabledRules.join(','),
disabledRules: this.disabledRules.join(','),
enabledCategories: this.enabledCategories.join(','),
disabledCategories: this.disabledCategories.join(','),
enabledOnly: 'false'
})
}),
{ 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
)
matches.push({
highlights: this.addMistakeMarkup(matchRange, match.rule.id),
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.textElement.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)
)
}
/**
* 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 h of m.highlights) {
if (
BesService.isPointInRect(
source.clientX,
source.clientY,
h.getBoundingClientRect()
)
) {
this.popupCorrectionPanel(result.range, m, source)
return
}
}
}
}
BesPopup.hide()
}
/**
* Simple string compare.
*
* For performance reasons, this method compares only string beginnings and endings. Maximum one
* difference is reported.
*
* @param {String} x First string
* @param {String} y Second string
* @returns {Array} Array of string differences
*/
static diffStrings(x, y) {
let m = x.length,
n = y.length
for (let i = 0; ; ++i) {
if (i >= m && i >= n) return []
if (i >= m) return [{ type: '+', start: i, length: n - i }]
if (i >= n) return [{ type: '-', start: i, end: m }]
if (x.charAt(i) !== y.charAt(i)) {
for (;;) {
if (m <= i) return [{ type: '+', start: i, length: n - i }]
if (n <= i) return [{ type: '-', start: i, end: m }]
--m, --n
if (x.charAt(m) !== y.charAt(n))
return [{ type: '*', start: i, end: m + 1, length: n - i + 1 }]
}
}
}
}
}
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides grammar-checking functionality to contenteditable="plaintext-only" HTML
* controls.
*
* Note: Chrome and Edge only, as Firefox reverts contenteditable="plaintext-only" to "false". The
* grammar mistakes will be highlighted nevertheless, but consider using BesDOMService on Firefox
* instead.
*
*************************************************************************************************/
class BesDOMPlainTextService extends BesPlainTextService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, eventSink) {
super(hostElement, hostElement, eventSink)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @param {*} eventSink Event sink for notifications
* @returns {BesDOMPlainTextService} Grammar checking service instance
*/
static register(hostElement, eventSink) {
let service = BesService.getServiceByElement(hostElement)
if (service) return service
service = new BesDOMPlainTextService(hostElement, eventSink)
if (service.eventSink && 'register' in service.eventSink)
service.eventSink.register(service)
// Defer proofing giving user a chance to configure the service.
service.scheduleProofing(10)
return service
}
/**
* 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.scheduleProofing(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 diff = BesPlainTextService.diffStrings(textA, textB)
let ranges = []
for (
let i = 0, j = 0, nodeIdxB = 0, diffIdx = 0;
diffIdx < diff.length;
++diffIdx
) {
let length = diff[diffIdx].start - i
i = diff[diffIdx].start
j += length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < j) nodeIdxB++
let range = document.createRange()
range.setStart(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
switch (diff[diffIdx].type) {
case '-': {
// Suppose some text was deleted.
i = diff[diffIdx].end
range.setEnd(nodesB[nodeIdxB].node, j - nodesB[nodeIdxB].start)
break
}
case '+': {
// Suppose some text was inserted.
let b = j + diff[diffIdx].length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
nodeIdxB++
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
break
}
case 'x': {
// Suppose some text was replaced.
i = diff[diffIdx].end
let b = j + diff[diffIdx].length
while (nodeIdxB < nodesB.length && nodesB[nodeIdxB].end < b)
nodeIdxB++
range.setEnd(nodesB[nodeIdxB].node, (j = b) - nodesB[nodeIdxB].start)
break
}
}
ranges.push(range)
}
return ranges
}
}
/**************************************************************************************************
*
* Plain-text grammar-checking service
*
* This class provides grammar-checking functionality to <textarea> HTML controls.
*
*************************************************************************************************/
class BesTAService extends BesPlainTextService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, eventSink) {
super(hostElement, BesTAService.createTextElement(hostElement), eventSink)
this.textElement.replaceChildren(
document.createTextNode(this.hostElement.value)
)
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @param {*} eventSink Event sink for notifications
* @returns {BesTAService} Grammar checking service instance
*/
static register(hostElement, eventSink) {
let service = BesService.getServiceByElement(hostElement)
if (service) return service
service = new BesTAService(hostElement, eventSink)
if (service.eventSink && 'register' in service.eventSink)
service.eventSink.register(service)
// Defer proofing giving user a chance to configure the service.
service.scheduleProofing(10)
return service
}
/**
* Creates a clone div element for the <textarea> element
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @returns The element in DOM tree that hosts text to proof. Same as hostElement, separate for
* <textarea> and <input> hosts.
*/
static createTextElement(hostElement) {
const textElement = document.createElement('div')
textElement.classList.add('bes-text-panel')
BesTAService.setTextElementSize(hostElement, textElement)
hostElement.parentNode.insertBefore(textElement, hostElement)
return textElement
}
/**
* Sets the size of the clone div element to match the <textarea> element
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {Element} textElement The element in DOM tree that hosts coordinate-measurable clone of
* the text to proof. Same as hostElement for <div>, separate for
* <textarea> and <input> hosts.
*/
static setTextElementSize(hostElement, textElement) {
const rect = hostElement.getBoundingClientRect()
const scrollTop = window.scrollY || document.documentElement.scrollTop
const scrollLeft = window.scrollX || document.documentElement.scrollLeft
const styles = window.getComputedStyle(hostElement)
textElement.style.zIndex = hostElement.style.zIndex - 1
textElement.style.fontSize = styles.fontSize
textElement.style.fontFamily = styles.fontFamily
textElement.style.lineHeight = styles.lineHeight
textElement.style.whiteSpace = styles.whiteSpace
textElement.style.whiteSpaceCollapse = styles.whiteSpaceCollapse
textElement.style.hyphens = styles.hyphens
textElement.style.border = styles.border
textElement.style.borderRadius = styles.borderRadius
textElement.style.paddingLeft = styles.paddingLeft
textElement.style.paddingTop = styles.paddingTop
textElement.style.paddingRight = styles.paddingRight
textElement.style.paddingBottom = styles.paddingBottom
textElement.style.height = styles.height
textElement.style.minHeight = styles.minHeight
textElement.style.maxHeight = styles.maxHeight
textElement.style.overflow = styles.overflow
textElement.style.overflowWrap = styles.overflowWrap
textElement.style.top = `${rect.top + scrollTop}px`
textElement.style.left = `${rect.left + scrollLeft}px`
textElement.style.width = styles.width
textElement.style.minWidth = styles.minWidth
textElement.style.maxWidth = styles.maxWidth
}
/**
* Called to report resizing
*/
onResize() {
BesTAService.setTextElementSize(this.hostElement, this.textElement)
super.onResize()
}
/**
* Called to report repositioning
*/
onReposition() {
BesTAService.setTextElementSize(this.hostElement, this.textElement)
super.onReposition()
}
/**
* Called to report the text is about to change
*
* @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()
}
/**
* Called to report <textarea> content change
*/
onInput(event) {
// Determine ranges of text that will change.
let { text, nodes } = this.getTextFromNodes()
let textA = text
let nodesA = nodes
let textB = this.hostElement.value
let diff = BesPlainTextService.diffStrings(textA, textB)
let changes = []
for (
let i = 0, j = 0, nodeIdxA = 0, diffIdx = 0;
diffIdx < diff.length;
++diffIdx
) {
let length = diff[diffIdx].start - i
i = diff[diffIdx].start
while (nodeIdxA < nodesA.length && nodesA[nodeIdxA].end < i) nodeIdxA++
let change = {
range: document.createRange()
}
change.range.setStart(nodesA[nodeIdxA].node, i - nodesA[nodeIdxA].start)
j += length
switch (diff[diffIdx].type) {
case '-': {
// Suppose some text was deleted.
while (
nodeIdxA < nodesA.length &&
nodesA[nodeIdxA].end < diff[diffIdx].end
)
nodeIdxA++
change.range.setEnd(
nodesA[nodeIdxA].node,
(i = diff[diffIdx].end) - nodesA[nodeIdxA].start
)
break
}
case '+': {
// Suppose some text was inserted.
let b = j + diff[diffIdx].length
change.range.setEnd(nodesA[nodeIdxA].node, i - nodesA[nodeIdxA].start)
change.replacement = textB.substring(j, b)
j = b
break
}
case 'x': {
// Suppose some text was replaced.
while (
nodeIdxA < nodesA.length &&
nodesA[nodeIdxA].end < diff[diffIdx].end
)
nodeIdxA++
change.range.setEnd(
nodesA[nodeIdxA].node,
(i = diff[diffIdx].end) - nodesA[nodeIdxA].start
)
let b = j + diff[diffIdx].length
change.replacement = textB.substring(j, b)
j = b
break
}
}
changes.push(change)
}
// Clear proofing for paragraphs that are about to change.
let paragraphRanges = new Set()
changes.forEach(change => {
this.results.forEach(result => {
if (
BesPlainTextService.isOverlappingParagraph(result.range, change.range)
)
paragraphRanges.add(result.range)
})
})
paragraphRanges.forEach(range => this.clearProofing(range))
// Sync changes between hostElement and textElement.
changes.forEach(change => {
change.range.deleteContents()
if (change.replacement)
change.range.insertNode(document.createTextNode(change.replacement))
})
// 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.scheduleProofing(1000)
}
/**
* Checks if host element content is editable.
*
* @returns true if editable; false otherwise
*/
isContentEditable() {
return !this.hostElement.disabled && !this.hostElement.readOnly
}
/**
* 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) {
super.replaceText(el, match, replacement)
let { text, nodes } = this.getTextFromNodes()
this.hostElement.value = text
}
}
/**************************************************************************************************
*
* Grammar mistake popup dialog
*
* This is internal class implementing the pop-up dialog user may invoke by clicking on a
* highlighted grammar mistake in text.
*
*************************************************************************************************/
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 {
font-size: 0.93rem;
max-width: 160px;
color: #333;
text-align: center;
padding: 8px 0;
z-index: 1;
}
.bes-popup-container {
position: relative;
visibility: hidden;
min-width: 200px;
max-width: 350px;
padding: 8px;
z-index: 1;
font-family: Arial, sans-serif;
background-color: #f1f3f9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
border-radius: 10px;
}
.bes-toolbar {
display: flex;
justify-content: end;
padding: 5px 2px;
}
.bes-toolbar button {
margin-right: 2px;
}
.bes-popup-title {
color: #333;
flex-grow: 1;
cursor: grab;
}
.bes-text-div{
background-color: #eee;
padding: 10px;
border-radius: 5px;
}
.bes-replacement-btn{
margin: 4px 1px;
padding: 4px;
border: none;
border-radius: 5px;
background-color: #239aff;
color: #eee;
cursor: pointer;
}
.bes-replacement-btn:hover{
background-color: #1976f0;
}
.bes-replacement-div{
margin-top: 4px;
}
:host(.show) .bes-popup-container {
visibility: visible;
animation: fadeIn 1s;
}
@keyframes fadeIn {
from {opacity: 0;}
to {opacity:1 ;}
}
@media (prefers-color-scheme: dark) {
.popup-text {
font-weight: lighter;
color: #eee;
}
.bes-popup-container {
font-weight: lighter;
background-color: #57585A;
}
.bes-popup-title {
font-weight: lighter;
color: #eee;
}
.bes-text-div {
font-weight: lighter;
background-color: #333;
}
}
</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 = `${(window.innerWidth - 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 = `${(window.innerHeight - 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').innerText = 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: #333;
// text-align: center;
// padding: 8px 0;
// z-index: 1;
// }
// .bes-popup-container {
// visibility: hidden;
// max-width: 300px;
// background-color: #f1f3f9;
// 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: #eee;
// 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 => BesService.registerByElement(hostElement))
})
window.BesService = BesService
window.BesDOMService = BesDOMService
window.BesCKService = BesCKService
window.BesDOMPlainTextService = BesDOMPlainTextService
window.BesTAService = BesTAService