BesService/service.js
Simon Rozman b6c825cc83 Add special markup for missing period
Period is too small to use standard missing text approach. Besides,
period was offended, since comma had its own markup sign and period
didn't. It demanded own markup sign and period.
2025-02-28 14:27:06 +01:00

2847 lines
92 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.highlightElements = []
this.createCorrectionPanel()
this.markupStyle = 'underline'
// 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.textFont = window.getComputedStyle(this.hostElement).fontFamily
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)
// Initial sync the scroll as hostElement may be scrolled by non-(0, 0) at the time of BesService registration.
this.onScroll()
}
/**
* 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
}
/**
* Sets markup style.
*
* @param {String} style Can be one of the following values:
* 'underline' Underline parts of sentences where grammar mistake is detected (default)
* 'lector' Use lector signs to markup grammar mistakes
*/
setMarkupStyle(style) {
this.markupStyle = style
this.redrawAllMistakeMarkup()
}
/**
* 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)
}
/**
* Temporarily disables the mutation observer.
*/
disableMutationObserver() {
if (this.mutationObserver) this.mutationObserver.disconnect()
}
/**
* Re-enables the mutation observer.
*/
enableMutationObserver() {
if (this.mutationObserver) {
this.mutationObserver.observe(document.body, {
attributes: true,
childList: true,
subtree: true
})
}
}
/**
* Called to report scrolling
*/
onScroll() {
this.dismissPopup()
this.canvasPanel.style.top = `${-this.hostElement.scrollTop}px`
this.canvasPanel.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()
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()
this.onResize()
this.hostBoundingClientRect = rect
}
/**
* Draws grammar mistake markup on canvas and populates collection of highlight rectangles.
*
* @param {*} match Grammar checking rule match
*/
drawMistakeMarkup(match) {
const range = match.range
match.highlights = Array.from(range.getClientRects())
if (match.highlights.length === 0) return
const canvasPanelRect = this.canvasPanel.getBoundingClientRect()
for (let rect of match.highlights) {
rect.x -= canvasPanelRect.x
rect.y -= canvasPanelRect.y
}
const dpr = window.devicePixelRatio
this.ctx.lineWidth = 2 * dpr // Use 2 for clearer visibility
const ruleId = match.match.rule.id
this.ctx.strokeStyle = ruleId.startsWith('MORFOLOGIK_RULE')
? 'rgba(0, 123, 255, 0.8)'
: 'rgba(255, 115, 0, 0.8)'
this.ctx.fillStyle = ruleId.startsWith('MORFOLOGIK_RULE')
? 'rgba(0, 123, 255, 0.8)'
: 'rgba(255, 115, 0, 0.8)'
let markerY1, markerY2
switch (this.markupStyle) {
case 'lector':
if (ruleId === 'BESANA_6' /*PR_VNAP_V_STAVKU_MANJKA_VEJICA*/) {
// Thou we should draw ┘ after the word before match.match.offset, if there is a line break inbetween,
// the correction markup would be drawn in one line and the highlight rectangle for onClick would reside
// in another line, making a confusing UX.
markerY1 = match.highlights[0].top
markerY2 = match.highlights[0].bottom
const scale = (markerY2 - markerY1) / 18
const x = match.highlights[0].left
const y = match.highlights[0].bottom
this.drawMissingComma(x, y, scale, '?')
break
}
if (match.match.replacements && match.match.replacements.length === 1) {
const context = match.match.context.text.substr(
match.match.context.offset,
match.match.context.length
)
const replacement = match.match.replacements[0].value
const lengthDiff = replacement.length - context.length
if (
lengthDiff > 0 &&
replacement.substr(-context.length) === context
) {
// Something to insert before
const toInsert = replacement.substr(0, lengthDiff)
markerY1 = match.highlights[0].top
markerY2 = match.highlights[0].bottom
const scale = (markerY2 - markerY1) / 18
if (toInsert === ',') {
// Thou we should draw ┘ after the word before match.match.offset, if there is a line break inbetween,
// the correction markup would be drawn in one line and the highlight rectangle for onClick would reside
// in another line, making a confusing UX.
const x = match.highlights[0].left
const y = match.highlights[0].bottom
this.drawMissingComma(x, y, scale)
} else if (toInsert === '.') {
// Thou we should draw . after the word before match.match.offset, if there is a line break inbetween,
// the correction markup would be drawn in one line and the highlight rectangle for onClick would reside
// in another line, making a confusing UX.
const x = match.highlights[0].left
const y = match.highlights[0].bottom - 4 * scale
this.drawMissingPeriod(x, y, scale)
} else if (/^\s+$/.test(toInsert)) {
const x = match.highlights[0].left
const y1 = match.highlights[0].bottom - 2 * scale
const y2 = match.highlights[0].top + 2 * scale
this.drawWrongSpacing(x, y1, y2, scale)
} else {
const x = match.highlights[0].left - 1 * scale
const y1 = match.highlights[0].bottom
const y2 = match.highlights[0].top
this.drawMissingText(
x,
y1,
y2,
scale,
replacement.substr(lengthDiff).trim()
)
}
} else if (replacement.substr(0, context.length) === context) {
// Something to insert after
const toInsert = replacement.substr(-lengthDiff)
markerY1 = match.highlights.at(-1).top
markerY2 = match.highlights.at(-1).bottom
const scale = (markerY2 - markerY1) / 18
if (toInsert === ',') {
const x = match.highlights.at(-1).right
const y = match.highlights.at(-1).bottom
this.drawMissingComma(x, y, scale)
} else if (toInsert === '.') {
const x = match.highlights.at(-1).right + 3 * scale
const y = match.highlights.at(-1).bottom - 4 * scale
this.drawMissingPeriod(x, y, scale)
} else if (/^\s+$/.test(toInsert)) {
const x = match.highlights.at(-1).right
const y1 = match.highlights.at(-1).bottom - 2 * scale
const y2 = match.highlights.at(-1).top + 2 * scale
this.drawWrongSpacing(x, y1, y2, scale)
} else {
const x = match.highlights.at(-1).right + 1 * scale
const y1 = match.highlights.at(-1).bottom
const y2 = match.highlights.at(-1).top
this.drawMissingText(
x,
y1,
y2,
scale,
replacement.substr(-lengthDiff).trim()
)
}
} else if (
lengthDiff < 0 &&
context.substr(-replacement.length) === replacement
) {
// Something to remove before
const toRemove = context.substr(0, -lengthDiff)
markerY1 = match.highlights[0].top
markerY2 = match.highlights[0].bottom
const scale = (markerY2 - markerY1) / 18
if (/^\s+$/.test(toRemove)) {
const rect = this.makeRange(
match.data,
match.match.offset,
match.match.offset - lengthDiff
)?.getClientRects()[0]
const x = (rect.left + rect.right) / 2
const y1 = rect.top
const y2 = rect.bottom
this.drawWrongSpacing(x, y1, y2, scale)
} else {
for (let rect of this.makeRange(
match.data,
match.match.offset,
match.match.offset - lengthDiff
)?.getClientRects())
this.drawExcessiveText(
rect.left,
rect.bottom,
rect.right,
rect.top
)
}
} else if (context.substr(0, replacement.length) === replacement) {
// Something to remove after
const toRemove = context.substr(lengthDiff)
markerY1 = match.highlights.at(-1).top
markerY2 = match.highlights.at(-1).bottom
const scale = (markerY2 - markerY1) / 18
if (/^\s+$/.test(toRemove)) {
const rect = this.makeRange(
match.data,
match.match.offset + match.match.length + lengthDiff,
match.match.offset + match.match.length
)?.getClientRects()[0]
const x = (rect.left + rect.right) / 2
const y1 = rect.top
const y2 = rect.bottom
this.drawWrongSpacing(x, y1, y2, scale)
} else {
for (let rect of this.makeRange(
match.data,
match.match.offset + match.match.length + lengthDiff,
match.match.offset + match.match.length
)?.getClientRects())
this.drawExcessiveText(
rect.left,
rect.bottom,
rect.right,
rect.top
)
}
} else {
// Sugesstion and context are different.
const lengthL = BesService.commonPrefixLength(context, replacement)
const lengthR = BesService.commonSuffixLength(context, replacement)
if (
lengthL + lengthR <
Math.min(context.length, replacement.length) * 0.6
) {
// Replace everything.
markerY1 = Math.min(...match.highlights.map(rect => rect.top))
markerY2 = Math.max(...match.highlights.map(rect => rect.bottom))
const scale =
(match.highlights[0].bottom - match.highlights[0].top) / 18
let first = true
for (let rect of match.highlights) {
if (first) {
this.drawWrongText(
rect.left,
rect.bottom,
rect.right,
rect.top,
scale,
replacement
)
first = false
} else {
this.drawExcessiveText(
rect.left,
rect.bottom,
rect.right,
rect.top
)
}
}
} else {
// Patch differences.
const rects = Array.from(
this.makeRange(
match.data,
match.match.offset + lengthL,
match.match.offset + match.match.length - lengthR
)?.getClientRects()
)
markerY1 = Math.min(...rects.map(rect => rect.top))
markerY2 = Math.max(...rects.map(rect => rect.bottom))
if (lengthL + lengthR === context.length) {
// Something to insert
const toInsert = replacement.substring(
lengthL,
replacement.length - lengthR
)
const scale = (rects[0].bottom - rects[0].top) / 18
const x = rects[0].left
if (/^\s+$/.test(toInsert)) {
const y1 = rects[0].bottom - 2 * scale
const y2 = rects[0].top + 2 * scale
this.drawWrongSpacing(x, y1, y2, scale)
} else {
const y1 = rects[0].bottom
const y2 = rects[0].top
this.drawMissingText(x, y1, y2, scale, toInsert.trim())
}
} else if (lengthL + lengthR === replacement.length) {
// Something to remove
const toRemove = context.substring(
lengthL,
replacement.length - lengthR
)
const scale = (rects[0].bottom - rects[0].top) / 18
if (/^\s+$/.test(toRemove)) {
const x = (rects[0].left + rects[0].right) / 2
const y1 = rects[0].top
const y2 = rects[0].bottom
this.drawWrongSpacing(x, y1, y2, scale)
} else {
for (let rect of rects)
this.drawExcessiveText(
rect.left,
rect.bottom,
rect.right,
rect.top
)
}
} else {
// Something to replace
const toReplace = replacement.substring(
lengthL,
replacement.length - lengthR
)
const scale = (rects[0].bottom - rects[0].top) / 18
let first = true
for (let rect of rects) {
if (first) {
this.drawWrongText(
rect.left,
rect.bottom,
rect.right,
rect.top,
scale,
toReplace
)
first = false
} else {
this.drawExcessiveText(
rect.left,
rect.bottom,
rect.right,
rect.top
)
}
}
}
}
}
break
}
default:
for (let rect of match.highlights) {
const x1 = rect.left
const x2 = rect.right
const y = rect.bottom
const scale = (rect.bottom - rect.top) / 18
this.drawAttentionRequired(x1, x2, y, scale)
}
markerY1 = Math.min(...match.highlights.map(rect => rect.top))
markerY2 = Math.max(...match.highlights.map(rect => rect.bottom))
}
this.drawSideMarker(markerY1, markerY2)
}
/**
* Draws the marker that helps visualize lines of text where grammar mistakes were detected
*
* @param {Number} y1 Marker top [px]
* @param {Number} y2 Marker bottom [px]
*/
drawSideMarker(y1, y2) {
const dpr = window.devicePixelRatio
const markerX = this.canvasPanel.width - 5 * dpr
this.ctx.beginPath()
this.ctx.moveTo(markerX, y1 * dpr)
this.ctx.lineTo(markerX, y2 * dpr)
this.ctx.stroke()
}
/**
* Draws the missing comma sign
*
* @param {Number} x Sign center [px]
* @param {Number} y Sign bottom [px]
* @param {Number} scale Sign scale
* @param {String} comment Text to display above the marker
*/
drawMissingComma(x, y, scale, comment) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo((x - 2 * scale) * dpr, y * dpr)
this.ctx.lineTo((x + 2 * scale) * dpr, y * dpr)
this.ctx.lineTo((x + 2 * scale) * dpr, (y - 4 * scale) * dpr)
this.ctx.stroke()
if (comment) {
this.ctx.font = `${12 * scale * dpr}px ${this.textFont}`
this.ctx.textAlign = 'center'
this.ctx.textBaseline = 'bottom'
this.ctx.fillText('?', (x + 2 * scale) * dpr, (y - 6 * scale) * dpr)
}
}
/**
* Draws the missing period sign
*
* @param {Number} x Sign center [px]
* @param {Number} y Sign bottom [px]
* @param {Number} scale Sign scale
*/
drawMissingPeriod(x, y, scale) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.ellipse(
x * dpr,
y * dpr,
2 * scale * dpr,
2 * scale * dpr,
0,
0,
2 * Math.PI
)
this.ctx.fill()
}
/**
* Draws the wrong spacing sign. Control direction of chevrons by reversing y1 and y2.
*
* @param {Number} x Sign center [px]
* @param {Number} y1 Sign top/bottom [px]
* @param {Number} y2 Sign bottom/top [px]
* @param {Number} scale Sign scale
*/
drawWrongSpacing(x, y1, y2, scale) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo((x - 4 * scale) * dpr, (y1 + 4 * scale) * dpr)
this.ctx.lineTo(x * dpr, y1 * dpr)
this.ctx.lineTo((x + 4 * scale) * dpr, (y1 + 4 * scale) * dpr)
this.ctx.moveTo(x * dpr, y1 * dpr)
this.ctx.lineTo(x * dpr, y2 * dpr)
this.ctx.moveTo((x - 4 * scale) * dpr, (y2 - 4 * scale) * dpr)
this.ctx.lineTo(x * dpr, y2 * dpr)
this.ctx.lineTo((x + 4 * scale) * dpr, (y2 - 4 * scale) * dpr)
this.ctx.stroke()
}
/**
* Strikes out the excessive text
*
* @param {Number} x1 Strike line start X [px]
* @param {Number} y1 Strike line start Y [px]
* @param {Number} x2 Strike line end X [px]
* @param {Number} y2 Strike line end Y [px]
*/
drawExcessiveText(x1, y1, x2, y2) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo(x1 * dpr, y1 * dpr)
this.ctx.lineTo(x2 * dpr, y2 * dpr)
this.ctx.stroke()
}
/**
* Strikes out the text and draws the replacement text above
*
* @param {Number} x1 Strike line start X [px]
* @param {Number} y1 Strike line start Y [px]
* @param {Number} x2 Strike line end X [px]
* @param {Number} y2 Strike line end Y [px]
* @param {Number} scale Sign scale
* @param {String} text Text to display above
*/
drawWrongText(x1, y1, x2, y2, scale, text) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo(x1 * dpr, (y1 - 6 * scale) * dpr)
this.ctx.lineTo(x2 * dpr, (y2 + 6 * scale) * dpr)
this.ctx.stroke()
this.ctx.font = `${12 * scale * dpr}px ${this.textFont}`
this.ctx.textAlign = 'left' // Thou we want the text to be centered, we align it manually to prevent it getting off canvas.
this.ctx.textBaseline = 'bottom'
const textMetrics = this.ctx.measureText(text)
this.ctx.fillText(
text,
Math.max(
Math.min(
((x1 + x2) / 2) * dpr - textMetrics.width / 2,
this.canvasPanel.width - textMetrics.width
),
0
),
(y2 + 6 * scale) * dpr
)
}
/**
* Draws the sign some text is missing
*
* @param {Number} x Sign center [px]
* @param {Number} y1 Sign bottom [px]
* @param {Number} y2 Sign top [px]
* @param {Number} scale Sign scale
* @param {String} text Text to display above
*/
drawMissingText(x, y1, y2, scale, text) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo((x - 6 * scale) * dpr, y1 * dpr)
this.ctx.lineTo((x + 6 * scale) * dpr, y1 * dpr)
this.ctx.moveTo(x * dpr, y1 * dpr)
this.ctx.lineTo(x * dpr, (y2 + 6 * scale) * dpr)
this.ctx.stroke()
this.ctx.font = `${12 * scale * dpr}px ${this.textFont}`
this.ctx.textAlign = 'left' // Thou we want the text to be centered, we align it manually to prevent it getting off canvas.
this.ctx.textBaseline = 'bottom'
const textMetrics = this.ctx.measureText(text)
this.ctx.fillText(
text,
Math.max(
Math.min(
x * dpr - textMetrics.width / 2,
this.canvasPanel.width - textMetrics.width
),
0
),
(y2 + 6 * scale) * dpr
)
}
/**
* Draws zig-zag line
*
* @param {Number} x1 Sign left [px]
* @param {Number} x2 Sign right [px]
* @param {Number} y Sign baseline [px]
* @param {Number} scale Sign scale
*/
drawAttentionRequired(x1, x2, y, scale) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo(x1 * dpr, (y - scale) * dpr)
for (let x = x1; ; ) {
if (x >= x2) break
this.ctx.lineTo((x += 2 * scale) * dpr, (y + scale) * dpr)
if (x >= x2) break
this.ctx.lineTo((x += 2 * scale) * dpr, (y - scale) * dpr)
}
this.ctx.stroke()
}
/**
* Calculates common string prefix length
*
* @param {String} s1 First string
* @param {String} s2 Second string
* @returns Number of characters the beginnings of the strings are equal
*/
static commonPrefixLength(s1, s2) {
let i = 0
let len = Math.min(s1.length, s2.length)
while (i < len && s1[i] === s2[i]) i++
return i
}
/**
* Calculates common string suffix length
*
* @param {String} s1 First string
* @param {String} s2 Second string
* @returns Number of characters the endings of the strings are equal
*/
static commonSuffixLength(s1, s2) {
let i = 0
let i1 = s1.length
let i2 = s2.length
while (0 < i1-- && 0 < i2-- && s1[i1] === s2[i2]) i++
return i
}
/**
* 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() {
this.correctionPanel = document.createElement('div')
this.correctionPanel.classList.add('bes-correction-panel')
this.scrollPanel = document.createElement('div')
this.scrollPanel.classList.add('bes-scroll-panel')
this.canvasPanel = document.createElement('canvas')
this.canvasPanel.classList.add('bes-canvas')
this.ctx = this.canvasPanel.getContext('2d')
this.ctx.scale(1, 1)
this.correctionPanel.appendChild(this.scrollPanel)
this.scrollPanel.appendChild(this.canvasPanel)
this.textElement.parentElement.insertBefore(
this.correctionPanel,
this.textElement
)
this.setCorrectionPanelSize()
}
/**
* Clears auxiliary DOM elements for text adornments.
*/
clearCorrectionPanel() {
this.correctionPanel.remove()
}
/**
* Resizes correction and scroll panels to match host element size.
*/
setCorrectionPanelSize() {
this.disableMutationObserver()
const styles = window.getComputedStyle(this.hostElement)
this.textFont = styles.fontFamily
// Resize canvas if needed.
this.canvasPanel.style.width = `${this.hostElement.scrollWidth}px`
this.canvasPanel.style.height = `${this.hostElement.scrollHeight}px`
const dpr = window.devicePixelRatio
const canvasPanelRect = this.canvasPanel.getBoundingClientRect()
const newCanvasWidth = Math.round(canvasPanelRect.width * dpr)
const newCanvasHeight = Math.round(canvasPanelRect.height * dpr)
if (
this.canvasPanel.width !== newCanvasWidth ||
this.canvasPanel.height !== newCanvasHeight
) {
this.canvasPanel.width = newCanvasWidth
this.canvasPanel.height = newCanvasHeight
this.redrawAllMistakeMarkup()
}
// Note: Firefox is not happy when syncing all margins at once.
this.scrollPanel.style.marginLeft = styles.marginLeft
this.scrollPanel.style.marginTop = styles.marginTop
this.scrollPanel.style.marginRight = styles.marginRight
this.scrollPanel.style.marginBottom = styles.marginBottom
this.scrollPanel.style.boxSizing = styles.boxSizing
this.scrollPanel.style.scrollBehavior = styles.scrollBehavior
if (this.isHostElementInline()) {
const totalWidth =
parseFloat(styles.paddingLeft) +
parseFloat(styles.marginLeft) +
parseFloat(styles.width) +
parseFloat(styles.marginRight) +
parseFloat(styles.paddingRight)
this.scrollPanel.style.width = `${totalWidth}px`
this.scrollPanel.style.height = styles.height
} else {
const hostRect = this.hostElement.getBoundingClientRect()
this.scrollPanel.style.width = `${hostRect.width}px`
this.scrollPanel.style.height = `${hostRect.height}px`
}
this.enableMutationObserver()
}
/**
* Prepares and displays popup.
*
* @param {*} elMatch Array containing block element/paragraph containing grammar checking rule match and a match
* @param {PointerEvent} source Click event source
*/
preparePopup(elMatch, source) {
this.dismissPopup()
const popup = document.querySelector('bes-popup-el')
BesPopup.clearReplacements()
elMatch.forEach(({ el, match }) => {
popup.setContent(el, match, this, this.isContentEditable())
this.highlightMistake(match)
})
popup.show(source.clientX, source.clientY)
}
/**
* Highlights given grammar mistake.
*
* @param {*} match Grammar checking rule match
*/
highlightMistake(match) {
const canvasPanelRect = this.canvasPanel.getBoundingClientRect()
match.highlights.forEach(rect => {
const el = document.createElement('div')
el.classList.add('bes-highlight-rect')
el.classList.add(
match.match.rule.id.startsWith('MORFOLOGIK_RULE')
? 'bes-highlight-spelling-rect'
: 'bes-highlight-grammar-rect'
)
el.style.left = `${rect.x + canvasPanelRect.x + window.scrollX}px`
el.style.top = `${rect.y + canvasPanelRect.y + window.scrollY}px`
el.style.width = `${rect.width}px`
el.style.height = `${rect.height}px`
document.body.appendChild(el)
this.highlightElements.push(el)
})
}
/**
* Clears highlight and hides popup
*/
dismissPopup() {
BesPopup.hide()
this.highlightElements.forEach(el => el.remove())
this.highlightElements = []
}
/**
* 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
}
/**
* Redraws all grammar mistake markup.
*/
redrawAllMistakeMarkup() {
this.ctx.clearRect(0, 0, this.canvasPanel.width, this.canvasPanel.height)
this.results.forEach(result => {
result.matches.forEach(match => this.drawMistakeMarkup(match))
})
}
/**
* 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 m = {
data: data,
range: this.makeRange(
data,
match.offset,
match.offset + match.length
),
match: match
}
this.drawMistakeMarkup(m)
matches.push(m)
})
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 }]
}
}
makeRange(data, start, end) {
let range = document.createRange()
// Locate start of the grammar mistake.
for (let idx = 0, offset = 0; ; offset += data[idx++].text.length) {
if (
!data[idx].markup &&
/*offset <= start &&*/ start < offset + data[idx].text.length
) {
range.setStart(data[idx].node, start - offset)
break
}
}
// Locate end of the grammar mistake.
for (let idx = 0, offset = 0; ; offset += data[idx++].text.length) {
if (
!data[idx].markup &&
/*offset <= end &&*/ end <= offset + data[idx].text.length
) {
range.setEnd(data[idx].node, end - offset)
break
}
}
return range
}
/**
* 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.results = this.results.filter(
result => !BesTreeService.isSameParagraph(result.element, el)
)
this.redrawAllMistakeMarkup()
}
/**
* 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
const canvasPanelRect = this.canvasPanel.getBoundingClientRect()
let x = source.clientX - canvasPanelRect.x
let y = source.clientY - canvasPanelRect.y
const pointsInRect = []
for (let result of this.results) {
for (let m of result.matches) {
for (let rect of m.highlights) {
if (BesService.isPointInRect(x, y, rect)) {
pointsInRect.push({ el, match: m })
break
}
}
}
}
this.dismissPopup()
if (pointsInRect.length) this.preparePopup(pointsInRect, source)
}
}
/**************************************************************************************************
*
* 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.redrawAllMistakeMarkup()
this.dismissPopup()
// 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.redrawAllMistakeMarkup()
// 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
}
}
/**************************************************************************************************
*
* Quill editor grammar-checking service
*
*************************************************************************************************/
class BesQuillService extends BesTreeService {
/**
* Constructs class.
*
* @param {Element} hostElement The element in DOM tree we are providing grammar-checking service
* for
* @param {*} quillInstance Quill instance
* @param {*} eventSink Event sink for notifications
*/
constructor(hostElement, quillInstance, eventSink) {
super(hostElement, hostElement, eventSink)
this.quillInstance = quillInstance
this.onChangeData = this.onChangeData.bind(this)
this.quillInstance.on('text-change', delta => {
this.onChangeData(delta)
})
}
/**
* Registers grammar checking service.
*
* @param {Element} hostElement DOM element to register grammar checking service for
* @param {Quill} quillInstance Enable Quill tweaks
* @param {*} eventSink Event sink for notifications
* @returns {BesQuillService} Grammar checking service instance
*/
static register(hostElement, quillInstance, eventSink) {
let service = BesService.getServiceByElement(hostElement)
if (service) return service
service = new BesQuillService(hostElement, quillInstance, 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.quillInstance.off('change:data', this.onChangeData)
if (this.timer) clearTimeout(this.timer)
super.unregister()
}
/**
* Called to report the text has changed
*/
onChangeData(delta) {
let index = 0
let reproofNeeded = false
delta.ops.forEach(op => {
if (op.retain) {
index += op.retain
if (op.attributes) {
reproofNeeded = true
}
} else if (op.insert) {
reproofNeeded = true
index += op.insert.length
} else if (op.delete) {
reproofNeeded = true
}
})
if (reproofNeeded) {
const editorLength = this.quillInstance.getLength()
const clampedIndex = Math.min(index, editorLength - 1)
const [leaf, offset] = this.quillInstance.getLeaf(clampedIndex)
if (leaf) {
let domElement = leaf.domNode
while (domElement && !this.isBlockElement(domElement)) {
domElement = domElement.parentNode
}
if (domElement) {
this.clearProofing(domElement)
setTimeout(() => {
this.redrawAllMistakeMarkup()
this.scheduleProofing(1000)
}, 0)
}
} else {
console.warn(
'Leaf is null. The index might be out of bounds or the editor content is empty.'
)
}
}
}
/**
* 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.scheduleProofing(20)
}
/**
* 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
// Ensure element is a valid DOM element
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
switch (
document.defaultView
.getComputedStyle(el, null)
.getPropertyValue('display')
.toLowerCase()
) {
case 'inline':
return false
default:
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 => {
const d = { nodes, start, end }
let m = {
data: d,
range: this.makeRange(
d,
match.offset,
match.offset + match.length
),
match: match
}
this.drawMistakeMarkup(m)
matches.push(m)
})
this.markProofed(paragraphRange, matches)
this.onProofingProgress(matches.length)
})
.catch(error => this.onFailedProofingResult(error))
}
}
this.onProofingProgress(0)
}
makeRange(data, start, end) {
let matchRange = document.createRange()
let nodeIdx = 0,
matchStart = data.start + start
while (nodeIdx < data.nodes.length && data.nodes[nodeIdx].end < matchStart)
nodeIdx++
matchRange.setStart(
data.nodes[nodeIdx].node,
matchStart - data.nodes[nodeIdx].start
)
let matchEnd = data.start + end
while (nodeIdx < data.nodes.length && data.nodes[nodeIdx].end < matchEnd)
nodeIdx++
matchRange.setEnd(
data.nodes[nodeIdx].node,
matchEnd - data.nodes[nodeIdx].start
)
return matchRange
}
/**
* 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.results = this.results.filter(
result => !BesPlainTextService.isSameParagraph(result.range, range)
)
this.redrawAllMistakeMarkup()
}
/**
* 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
const canvasPanelRect = this.canvasPanel.getBoundingClientRect()
let x = source.clientX - canvasPanelRect.x
let y = source.clientY - canvasPanelRect.y
const pointsInRect = []
for (let result of this.results) {
for (let m of result.matches) {
for (let rect of m.highlights) {
if (BesService.isPointInRect(x, y, rect)) {
pointsInRect.push({ el: result.range, match: m })
break
}
}
}
}
this.dismissPopup()
if (pointsInRect.length) this.preparePopup(pointsInRect, source)
}
/**
* 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.redrawAllMistakeMarkup()
this.dismissPopup()
// 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)
}
/**
* 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
}
/**
* Unregisters grammar checking service.
*/
unregister() {
super.unregister()
this.textElement.remove()
}
/**
* 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')
textElement.replaceChildren(document.createTextNode(hostElement.value))
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.font = styles.font
textElement.style.lineHeight = styles.lineHeight
textElement.style.whiteSpace = styles.whiteSpace
textElement.style.whiteSpaceCollapse = styles.whiteSpaceCollapse
textElement.style.hyphens = styles.hyphens
textElement.style.boxSizing = styles.boxSizing
textElement.style.scrollBehavior = styles.scrollBehavior
textElement.style.border = styles.border
textElement.style.borderRadius = styles.borderRadius
textElement.style.padding = styles.padding
textElement.style.left = `${rect.left + scrollLeft}px`
textElement.style.top = `${rect.top + scrollTop}px`
textElement.style.width = styles.width
textElement.style.height = styles.height
}
/**
* 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.redrawAllMistakeMarkup()
// 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;
font-weight: heavier;
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: 3px 2px;
}
.bes-toolbar button {
margin-right: 2px;
}
.bes-popup-title {
color: #333;
flex-grow: 1;
cursor: grab;
}
.bes-text-div{
background-color: #fff;
padding: 10px;
border-radius: 5px;
border: 1px solid #f1f3f9;
box-shadow:rgba(0, 0, 0, 0.16) 0px 2px 6px -1px, rgba(0, 0, 0, 0.04) 0px 1px 4px -1px
}
.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;
}
.bes-close-btn {
width: 20px;
height: 20px;
background: none;
border: none;
cursor: pointer;
padding: 2px;
}
.bes-close-btn svg {
width: 100%;
height: 100%;
fill: #333;
}
.bes-close-btn:hover {
background: #dee3ed;
border-radius: 8px
}
: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 {
color: #fff;
}
.bes-popup-container {
font-weight: lighter;
background-color: #2f3237;
border-color: 1px solid rgb(241, 243, 249)
box-shadow: rgb(94, 99, 110) 0px 0px 0px 1px
}
.bes-popup-title {
font-weight: heavier;
color: #fff;
}
.bes-text-div {
font-weight: lighter;
background-color: #111213;
border: 1px solid #2e3036;
}
}
</style>
<div class="bes-popup-container">
<div class="bes-toolbar">
<div class="bes-popup-title">Besana</div>
<button class="bes-close-btn" onclick="BesPopup.dismiss()">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z"/></svg>
</button>
</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 - 10}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 - 20}px`
} else {
this.style.top = `${(window.innerHeight - this.offsetHeight) / 2}px`
}
}
setContent(el, match, service, allowReplacements) {
const popup = this.shadowRoot.querySelector('.bes-popup-container')
const newTextDiv = this.createPopupTextDiv()
popup.appendChild(newTextDiv)
this.changeMessage(match.match.message, newTextDiv)
if (match.match.replacements) {
this.appendReplacements(el, match, service, allowReplacements, newTextDiv)
}
}
createPopupTextDiv() {
const textDiv = document.createElement('div')
textDiv.classList.add('bes-text-div')
const popupText = document.createElement('span')
popupText.classList.add('popup-text')
const replacementDiv = document.createElement('div')
replacementDiv.classList.add('bes-replacement-div')
textDiv.appendChild(popupText)
textDiv.appendChild(replacementDiv)
return textDiv
}
/**
* Clears all grammar mistake suggestions.
*/
static clearReplacements() {
const popup = document.querySelector('bes-popup-el')
popup.shadowRoot
.querySelectorAll('.bes-text-div')
.forEach(el => el.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, element) {
const replacementDiv = element.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)
service.dismissPopup()
}
})
replacementDiv.appendChild(replacementBtn)
})
}
/**
* Sets grammar mistake description
*
* @param {String} text
*/
changeMessage(text, element) {
element.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)
}
/**
* Hides all the popups.
*/
static hide() {
document.querySelectorAll('bes-popup-el').forEach(popup => {
popup.classList.remove('show')
})
}
/**
* Dismisses all the popups.
*/
static dismiss() {
besServices.forEach(service => service.dismissPopup())
}
}
customElements.define('bes-popup-el', BesPopup)
// 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
window.BesPopup = BesPopup
window.BesQuillService = BesQuillService