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.
2847 lines
92 KiB
JavaScript
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
|