BesService/service.js

3003 lines
97 KiB
JavaScript

// TODO: Research if there is a way to disable languageTool & Grammarly extensions in CKEditor
// TODO: Add mutation observer should any style of hostElement/textElement change and repaint markup (e.g. notice font-weight difference when toggling light/dark color-scheme)
/**
* 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.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 canvasPanelRect = this.canvasPanel.getBoundingClientRect()
match.highlights = BesService.getClientRects(
match.range,
canvasPanelRect.x,
canvasPanelRect.y
)
if (match.highlights.length === 0) return
const dpr = window.devicePixelRatio
this.ctx.lineWidth = 2 * dpr // Use 2 for clearer visibility
let amplitude = 0
const ruleId = match.match.rule.id
if (ruleId.startsWith('MORFOLOGIK_RULE')) {
const styles = window.getComputedStyle(this.highlightSpelling)
this.ctx.strokeStyle = styles.color
this.ctx.fillStyle = styles.color
amplitude = -1
} else {
const styles = window.getComputedStyle(this.highlightGrammar)
this.ctx.strokeStyle = styles.color
this.ctx.fillStyle = styles.color
amplitude = 1
}
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 = BesService.getClientRects(
this.makeRange(
match.data,
match.match.offset,
match.match.offset - lengthDiff
),
canvasPanelRect.x,
canvasPanelRect.y
)[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 BesService.getClientRects(
this.makeRange(
match.data,
match.match.offset,
match.match.offset - lengthDiff
),
canvasPanelRect.x,
canvasPanelRect.y
))
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 = BesService.getClientRects(
this.makeRange(
match.data,
match.match.offset + match.match.length + lengthDiff,
match.match.offset + match.match.length
),
canvasPanelRect.x,
canvasPanelRect.y
)[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 BesService.getClientRects(
this.makeRange(
match.data,
match.match.offset + match.match.length + lengthDiff,
match.match.offset + match.match.length
),
canvasPanelRect.x,
canvasPanelRect.y
))
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 = BesService.getClientRects(
this.makeRange(
match.data,
match.match.offset + lengthL,
match.match.offset + match.match.length - lengthR
),
canvasPanelRect.x,
canvasPanelRect.y
)
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, amplitude, 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.setCtxFont(scale, dpr)
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.setCtxFont(scale, dpr)
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.setCtxFont(scale, dpr)
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} amplitude Sign amplitude [px]
* @param {Number} scale Sign scale
*/
drawAttentionRequired(x1, x2, y, amplitude, scale) {
const dpr = window.devicePixelRatio
this.ctx.beginPath()
this.ctx.moveTo(x1 * dpr, (y - amplitude * scale) * dpr)
for (let x = x1; ; ) {
if (x >= x2) break
this.ctx.lineTo((x += 2 * scale) * dpr, (y + amplitude * scale) * dpr)
if (x >= x2) break
this.ctx.lineTo((x += 2 * scale) * dpr, (y - amplitude * scale) * dpr)
}
this.ctx.stroke()
}
/**
* Sets markup font
*
* @param {Number} scale Sign scale
* @param {Number} dpr Device pixel ratio
*/
setCtxFont(scale, dpr) {
const styles = window.getComputedStyle(this.canvasPanel)
this.ctx.font = `${styles.fontStyle} ${styles.fontWeight} ${
14 * scale * dpr
}px ${styles.fontFamily}`
}
/**
* Calculates rectangles covering a given range and compensates for scroll offset
*
* @param {Range} range Range to get client rectangles for
* @param {Number} offsetX X offset to subtract from coordinates [px]
* @param {Number} offsetY Y offset to subtract from coordinates [px]
* @returns Array of rectangles
*/
static getClientRects(range, offsetX, offsetY) {
const rects = Array.from(range.getClientRects())
for (let rect of rects) {
rect.x -= offsetX
rect.y -= offsetY
}
return rects
}
/**
* 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
* @param {Number} tolerance Extra margin around the rectangle treated as "inside"
* @returns
*/
static isPointInRect(x, y, rect, tolerance) {
return (
rect.left - tolerance <= x &&
x < rect.right + tolerance &&
rect.top - tolerance <= y &&
y < rect.bottom + tolerance
)
}
/**
* 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.highlightSpelling = document.createElement('div')
this.highlightSpelling.classList.add('bes-highlight-placeholder')
this.highlightSpelling.classList.add('bes-highlight-spelling')
this.highlightGrammar = document.createElement('div')
this.highlightGrammar.classList.add('bes-highlight-placeholder')
this.highlightGrammar.classList.add('bes-highlight-grammar')
this.correctionPanel.appendChild(this.scrollPanel)
this.correctionPanel.appendChild(this.highlightSpelling)
this.correctionPanel.appendChild(this.highlightGrammar)
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)
// 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`
}
// 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()
}
this.enableMutationObserver()
}
/**
* Prepares and displays popup.
*
* @param {*} hits Array containing block element/paragraph containing grammar checking rule match and a match
* @param {PointerEvent} source Click event source
*/
preparePopup(hits, source) {
this.dismissPopup()
const popup = document.querySelector('bes-popup-el')
BesPopup.clearReplacements()
hits.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'
: 'bes-highlight-grammar'
)
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 => {
// Most important matches are first, we want to draw them last => iterate in reverse.
for (let i = result.matches.length; i-- > 0; )
this.drawMistakeMarkup(result.matches[i])
})
}
/**
* 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
}
matches.push(m)
})
// Most important matches are first, we want to draw them last => iterate in reverse.
for (let i = matches.length; i-- > 0; )
this.drawMistakeMarkup(matches[i])
this.markProofed(node, matches)
this.onProofingProgress(matches.length)
})
.catch(error => this.onFailedProofingResult(error))
}
return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
} else {
let data = []
if (this.doesElementAddSpace(node)) {
// Inline element adds some space between text. Convert node to spaces.
const inner = node.innerHTML
const len =
inner.length > 0
? node.outerHTML.indexOf(inner)
: node.outerHTML.length
data = data.concat({
text: ' '.repeat(len),
node: node,
markup: false
})
} else {
// Inline elements require no markup. Keep plain text only.
}
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
}
}
/**
* Tests if given element adds space before child/next text
*
* @param {Element} el DOM element
* @returns true if adds space; false otherwise.
*/
doesElementAddSpace(el) {
const prevNode = el.previousSibling
const nextNode = el.firstChild || el.nextSibling
if (!prevNode || !nextNode) return false
const range = document.createRange()
range.setStart(
prevNode,
prevNode.nodeType === Node.TEXT_NODE ? prevNode.length : 0
)
range.setEnd(nextNode, 0)
const bounds = range.getBoundingClientRect()
return bounds.width !== 0
}
/**
* 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 hits = []
for (let result of this.results) {
for (let m of result.matches) {
for (let rect of m.highlights) {
if (BesService.isPointInRect(x, y, rect, 5)) {
hits.push({ el, match: m })
break
}
}
}
}
this.dismissPopup()
if (hits.length) this.preparePopup(hits, 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
const affectedBlocks = new Set()
delta.ops.forEach(op => {
if (op.retain) {
index += op.retain
if (op.attributes) {
reproofNeeded = true
}
} else if (op.insert) {
reproofNeeded = true
index += typeof op.insert === 'string' ? op.insert.length : 1 // Handle string or embed
} 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
// Traverse up to find the block element
while (domElement && !this.isBlockElement(domElement)) {
domElement = domElement.parentNode
}
if (domElement) affectedBlocks.add(domElement)
} else {
console.warn(
'Leaf is null. The index might be out of bounds or the editor content is empty.'
)
}
// Handle pasted content spanning multiple blocks
const selection = this.quillInstance.getSelection()
if (selection) {
const [startLeaf] = this.quillInstance.getLeaf(selection.index)
const [endLeaf] = this.quillInstance.getLeaf(
selection.index + selection.length
)
if (startLeaf && endLeaf) {
let startElement = startLeaf.domNode
let endElement = endLeaf.domNode
while (startElement && !this.isBlockElement(startElement)) {
startElement = startElement.parentNode
}
while (endElement && !this.isBlockElement(endElement)) {
endElement = endElement.parentNode
}
if (startElement && endElement) {
let currentElement = startElement
while (currentElement) {
affectedBlocks.add(currentElement)
if (currentElement === endElement) break
currentElement = currentElement.nextElementSibling
}
}
}
}
// Clear proofing for all affected blocks
affectedBlocks.forEach(block => this.clearProofing(block))
// Schedule proofing for all affected blocks
setTimeout(() => {
this.scheduleProofing(1000)
}, 0)
}
}
/**
* 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
}
matches.push(m)
})
// Most important matches are first, we want to draw them last => iterate in reverse.
for (let i = matches.length; i-- > 0; )
this.drawMistakeMarkup(matches[i])
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 hits = []
for (let result of this.results) {
for (let m of result.matches) {
for (let rect of m.highlights) {
if (BesService.isPointInRect(x, y, rect, 5)) {
hits.push({ el: result.range, match: m })
break
}
}
}
}
this.dismissPopup()
if (hits.length) this.preparePopup(hits, 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.display = styles.display
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.scrollBehavior = styles.scrollBehavior
textElement.style.overflow = styles.overflow
textElement.style.border = styles.border
textElement.style.borderRadius = styles.borderRadius
textElement.style.borderColor = 'transparent'
textElement.style.padding = styles.padding
textElement.style.left = `${rect.left + scrollLeft}px`
textElement.style.top = `${rect.top + scrollTop}px`
textElement.style.width = `${
rect.width -
parseFloat(styles.borderLeftWidth) -
parseFloat(styles.paddingLeft) -
parseFloat(styles.paddingRight) -
parseFloat(styles.borderRightWidth)
}px`
textElement.style.height = `${
rect.height -
parseFloat(styles.borderTopWidth) -
parseFloat(styles.paddingTop) -
parseFloat(styles.paddingBottom) -
parseFloat(styles.borderBottomWidth)
}px`
}
/**
* 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