This adjusts markup color slightly for the dark display, makes it fully opaque, alternates the squiggly line between grammar and spelling mistakes to visualize one-word-two-mistake-types, paints most important mistakes last/on top etc. All to make the markup as visible as possible.
2968 lines
96 KiB
JavaScript
2968 lines
96 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
|
|
|
|
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
|
|
}
|
|
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
|