service2.js: Fix corner cases and prepare for plain-text services

This commit is contained in:
Simon Rozman 2024-05-21 11:43:52 +02:00
parent e05abce7d9
commit 67eee1015e
2 changed files with 95 additions and 141 deletions

View File

@ -3,14 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BesService Example</title> <title>BesService &lt;div contenteditable="true"&gt; Example</title>
<link rel="stylesheet" href="../styles.css" /> <link rel="stylesheet" href="../styles.css" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
<script>const besUrl = 'http://localhost:225/api/v2';</script> <script>const besUrl = 'http://localhost:225/api/v2';</script>
<script src="../service2.js"></script> <script src="../service2.js"></script>
</head> </head>
<body> <body>
<p class="my-block">This is an example of a simple <code>&lt;div contenteditable="true"&gt;</code> edit control. Edit the text, resize the control or browser window, scroll around...</p> <p class="my-block">This is an example of a simple <code>&lt;div contenteditable="true"&gt;</code> edit control. Edit the text, resize the control or browser window, scroll around, click...</p>
<div class="my-block my-control bes-service" contenteditable="true"> <div class="my-block my-control bes-service" contenteditable="true">
<p>Tukaj vpišite besedilo ki ga želite popraviti.</p> <p>Tukaj vpišite besedilo ki ga želite popraviti.</p>
<p>Prišla je njena lepa hčera. Smatram da tega nebi bilo potrebno storiti. Predavanje je trajalo dve ure. S njim grem v Kamnik. Janez jutri nebo prišel. Prišel je z 100 idejami.</p> <p>Prišla je njena lepa hčera. Smatram da tega nebi bilo potrebno storiti. Predavanje je trajalo dve ure. S njim grem v Kamnik. Janez jutri nebo prišel. Prišel je z 100 idejami.</p>

View File

@ -25,6 +25,7 @@ window.addEventListener('scroll', () =>
class BesService { class BesService {
constructor(hostElement) { constructor(hostElement) {
this.hostElement = hostElement this.hostElement = hostElement
this.results = [] // Results of grammar-checking, one per each block/paragraph of text
this.createCorrectionPanel() this.createCorrectionPanel()
// Disable browser built-in spell-checker to prevent collision with our grammar markup. // Disable browser built-in spell-checker to prevent collision with our grammar markup.
@ -52,7 +53,7 @@ class BesService {
* Called initially when grammar-checking run is started * Called initially when grammar-checking run is started
*/ */
onStartProofing() { onStartProofing() {
this.proofingCount = 0 // Ref-count how many grammar-checking blocks of text are active 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.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.proofingMatches = 0 // Number of grammar mistakes detected in entire grammar-checking run
this.updateStatusIcon('bes-status-loading', 'Besana preverja pravopis.') this.updateStatusIcon('bes-status-loading', 'Besana preverja pravopis.')
@ -126,6 +127,15 @@ class BesService {
// Scroll panel is "position: absolute", we need to keep it aligned with the host element. // Scroll panel is "position: absolute", we need to keep it aligned with the host element.
this.scrollPanel.style.top = `${-this.hostElement.scrollTop}px` this.scrollPanel.style.top = `${-this.hostElement.scrollTop}px`
this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}px` this.scrollPanel.style.left = `${-this.hostElement.scrollLeft}px`
// Markup is in a "position:absolute" <div> element requiring repositioning when scrolling host element or window.
// It is defered to reduce stress in a flood of scroll events.
// TODO: We could technically just update scrollTop and scrollLeft of all markup rects for even better performance?
if (this.scrollTimeout) clearTimeout(this.scrollTimeout)
this.scrollTimeout = setTimeout(() => {
this.repositionAllMarkup()
delete this.scrollTimeout
}, 500)
} }
/** /**
@ -134,6 +144,46 @@ class BesService {
onResize() { onResize() {
this.setCorrectionPanelSize() this.setCorrectionPanelSize()
this.setStatusDivPosition() this.setStatusDivPosition()
// When window is resized, host element might resize too.
// This may cause text to re-wrap requiring markup repositioning.
this.repositionAllMarkup()
}
/**
* Creates grammar mistake markup in DOM.
*
* @param {Range} range Grammar mistake range
* @returns {Object} Client rectangles and grammar mistake highlight elements
*/
addMistakeMarkup(range) {
const clientRects = range.getClientRects()
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
let highlights = []
for (let i = 0, n = clientRects.length; i < n; ++i) {
const rect = clientRects[i]
const highlight = document.createElement('div')
highlight.classList.add('bes-typo-mistake')
highlight.style.left = `${rect.left - scrollPanelRect.left}px`
highlight.style.top = `${rect.top - scrollPanelRect.top}px`
highlight.style.width = `${rect.width}px`
highlight.style.height = `${rect.height}px`
this.scrollPanel.appendChild(highlight)
highlights.push(highlight)
}
return { clientRects, highlights }
}
/**
* Tests if given coordinate is inside of a rectangle.
*
* @param {Number} x X coordinate
* @param {Number} y Y coordinate
* @param {DOMRect} rect Rectangle
* @returns
*/
static isPointInRect(x, y, rect) {
return rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom
} }
/** /**
@ -239,7 +289,6 @@ class BesService {
class BesDOMService extends BesService { class BesDOMService extends BesService {
constructor(hostElement) { constructor(hostElement) {
super(hostElement) super(hostElement)
this.results = [] // Results of grammar-checking, one per each block of text
this.onBeforeInput = this.onBeforeInput.bind(this) this.onBeforeInput = this.onBeforeInput.bind(this)
this.hostElement.addEventListener('beforeinput', this.onBeforeInput) this.hostElement.addEventListener('beforeinput', this.onBeforeInput)
this.onInput = this.onInput.bind(this) this.onInput = this.onInput.bind(this)
@ -271,33 +320,6 @@ class BesDOMService extends BesService {
super.unregister() super.unregister()
} }
/**
* Called to report scrolling
*/
onScroll() {
super.onScroll()
// Markup is in a "position:absolute" <div> element requiring repositioning when scrolling host element or window.
// It is defered to reduce stress in a flood of scroll events.
// TODO: We could technically just update scrollTop and scrollLeft of all markup rects for even better performance?
if (this.scrollTimeout) clearTimeout(this.scrollTimeout)
this.scrollTimeout = setTimeout(() => {
this.repositionAllMarkup()
delete this.scrollTimeout
}, 500)
}
/**
* Called to report resizing
*/
onResize() {
super.onResize()
// When window is resized, host element might resize too.
// This may cause text to re-wrap requiring markup re
this.repositionAllMarkup()
}
/** /**
* Called to report the text is about to change * Called to report the text is about to change
* *
@ -312,13 +334,9 @@ class BesDOMService extends BesService {
// Remove markup of all blocks of text that are about to change. // Remove markup of all blocks of text that are about to change.
let blockElements = new Set() let blockElements = new Set()
event.getTargetRanges().forEach(range => { event.getTargetRanges().forEach(range => {
BesDOMService.getNodesInRange(range).forEach(el => { BesDOMService.getNodesInRange(range).forEach(el =>
if (
el === this.hostElement ||
Array.from(this.hostElement.childNodes).includes(el)
)
blockElements.add(this.getBlockParent(el)) blockElements.add(this.getBlockParent(el))
}) )
}) })
blockElements.forEach(block => this.clearProofing(block)) blockElements.forEach(block => this.clearProofing(block))
} }
@ -343,11 +361,7 @@ class BesDOMService extends BesService {
proofAll() { proofAll() {
this.onStartProofing() this.onStartProofing()
this.proofNode(this.hostElement, this.abortController) this.proofNode(this.hostElement, this.abortController)
if (this.proofingCount == 0) { this.onProofingProgress(0)
// No text blocks were discovered for proofing. onProofingProgress() will not be called
// and we need to notify manually.
this.onEndProofing()
}
} }
/** /**
@ -365,15 +379,18 @@ class BesDOMService extends BesService {
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
if (this.isBlockElement(node)) { if (this.isBlockElement(node)) {
// Block elements are grammar-checked independently. // Block elements are grammar-checked independently.
if (this.isProofed(node)) this.onProofing()
let result = this.getProofing(node)
if (result != null) {
this.onProofingProgress(result.matches.length)
return [{ text: `<${node.tagName}/>`, node: node, markup: true }] return [{ text: `<${node.tagName}/>`, node: node, markup: true }]
}
let data = [] let data = []
for (const el2 of node.childNodes) for (const el2 of node.childNodes)
data = data.concat(this.proofNode(el2, abortController)) data = data.concat(this.proofNode(el2, abortController))
if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) { if (data.some(x => !x.markup && !/^\s*$/.test(x.text))) {
// Block element contains some text. // Block element contains some text.
this.onProofing()
const signal = abortController.signal const signal = abortController.signal
fetch( fetch(
new Request(besUrl + '/check', { new Request(besUrl + '/check', {
@ -477,10 +494,12 @@ class BesDOMService extends BesService {
* Tests if given block element has already been grammar-checked. * Tests if given block element has already been grammar-checked.
* *
* @param {Element} el DOM element to check * @param {Element} el DOM element to check
* @returns {Boolean} true if the element has already been grammar-checked; false otherwise. * @returns {*} Result of grammar check if the element has already been grammar-checked; null otherwise.
*/ */
isProofed(el) { getProofing(el) {
return this.results?.find(result => result.element === el) != null return this.results.find(result =>
BesDOMService.isSameParagraph(result.element, el)
)
} }
/** /**
@ -504,51 +523,9 @@ class BesDOMService extends BesService {
*/ */
clearProofing(el) { clearProofing(el) {
this.clearMarkup(el) this.clearMarkup(el)
this.results = this.results?.filter(result => result.element !== el) this.results = this.results.filter(
} result => !BesDOMService.isSameParagraph(result.element, el)
)
/**
* Creates grammar mistake markup in DOM.
*
* @param {Range} range Grammar mistake range
* @returns {Object} Client rectangles and grammar mistake highlight elements
*/
addMistakeMarkup(range) {
const clientRects = range.getClientRects()
const scrollPanelRect = this.scrollPanel.getBoundingClientRect()
let highlights = []
for (let i = 0, n = clientRects.length; i < n; ++i) {
const rect = clientRects[i]
const highlight = document.createElement('div')
highlight.classList.add('bes-typo-mistake')
const topPosition = rect.top - scrollPanelRect.top
const leftPosition = rect.left - scrollPanelRect.left
highlight.style.left = `${leftPosition}px`
highlight.style.top = `${topPosition}px`
highlight.style.width = `${rect.width}px`
highlight.style.height = `${rect.height}px`
this.scrollPanel.appendChild(highlight)
highlights.push(highlight)
}
return { clientRects, highlights }
}
/**
* Updates grammar mistake markup positions.
*
* @param {Element} el DOM element we want to update markup for
*
* TODO: Unused
*/
repositionMarkup(el) {
let result = this.results?.find(result => result.element === el)
if (!result) return
result.matches.forEach(match => {
const { clientRects, highlights } = this.addMistakeMarkup(match.range)
match.rects = clientRects
if (match.highlights) match.highlights.forEach(h => h.remove())
match.highlights = highlights
})
} }
/** /**
@ -571,30 +548,27 @@ class BesDOMService extends BesService {
* @param {Element} el DOM element we want to clean markup for * @param {Element} el DOM element we want to clean markup for
*/ */
clearMarkup(el) { clearMarkup(el) {
let result = this.results?.find(result => result.element === el) this.results
if (!result) return .filter(result => BesDOMService.isSameParagraph(result.element, el))
.forEach(result =>
result.matches.forEach(match => { result.matches.forEach(match => {
if (match.highlights) { if (match.highlights) {
match.highlights.forEach(h => h.remove()) match.highlights.forEach(h => h.remove())
delete match.highlights delete match.highlights
} }
}) })
)
} }
/** /**
* Clears all grammar mistake markup. * Tests if given block elements represent the same block of text
* *
* TODO: Unused * @param {Element} el1 DOM element
* @param {Element} el2 DOM element
* @returns {Boolean} true if block elements are the same
*/ */
clearAllMarkup() { static isSameParagraph(el1, el2) {
this.results.forEach(result => { return el1 === el2
result.matches.forEach(match => {
if (match.highlights) {
match.highlights.forEach(h => h.remove())
delete match.highlights
}
})
})
} }
/** /**
@ -714,23 +688,21 @@ class BesDOMService extends BesService {
*/ */
onClick(event) { onClick(event) {
const source = event?.detail !== 1 ? event?.detail : event const source = event?.detail !== 1 ? event?.detail : event
const target = this.getBlockParent(source.targetElement || source.target) const el = this.getBlockParent(source.targetElement || source.target)
if (!target) return if (!el) return
const matches = this.results?.find( const matches = this.results.find(child =>
child => child.element === target BesDOMService.isSameParagraph(child.element, el)
)?.matches )?.matches
if (matches) { if (matches) {
const popup = document.querySelector('bes-popup-el')
for (let m of matches) { for (let m of matches) {
if (m.rects) { if (m.rects) {
for (let r of m.rects) { for (let r of m.rects) {
if ( if (BesService.isPointInRect(source.clientX, source.clientY, r)) {
BesDOMService.isPointInRect(source.clientX, source.clientY, r) const popup = document.querySelector('bes-popup-el')
) {
popup.changeMessage(m.match.message) popup.changeMessage(m.match.message)
popup.appendReplacements( popup.appendReplacements(
target, el,
m, m,
this, this,
this.hostElement.contentEditable !== 'false' this.hostElement.contentEditable !== 'false'
@ -745,18 +717,6 @@ class BesDOMService extends BesService {
BesPopup.hide() BesPopup.hide()
} }
/**
* Tests if given coordinate is inside of a rectangle.
*
* @param {Number} x X coordinate
* @param {Number} y Y coordinate
* @param {DOMRect} rect Rectangle
* @returns
*/
static isPointInRect(x, y, rect) {
return rect.left <= x && x < rect.right && rect.top <= y && y < rect.bottom
}
/** /**
* Replaces grammar checking match with a suggestion provided by grammar checking service. * Replaces grammar checking match with a suggestion provided by grammar checking service.
* *
@ -770,13 +730,7 @@ class BesDOMService extends BesService {
this.clearProofing(el) this.clearProofing(el)
match.range.deleteContents() match.range.deleteContents()
match.range.insertNode(document.createTextNode(replacement)) match.range.insertNode(document.createTextNode(replacement))
this.onStartProofing() this.proofAll()
this.proofNode(el, this.abortController)
if (this.proofingCount == 0) {
// No text blocks were discovered for proofing. onProofingProgress() will not be called
// and we need to notify manually.
this.onEndProofing()
}
} }
} }
@ -919,7 +873,7 @@ class BesPopup extends HTMLElement {
/** /**
* Adds a grammar mistake suggestion. * Adds a grammar mistake suggestion.
* *
* @param {Element} el Block element containing the grammar mistake * @param {*} el Block element/paragraph containing the grammar mistake
* @param {*} match Grammar checking rule match * @param {*} match Grammar checking rule match
* @param {BesService} service Grammar checking service * @param {BesService} service Grammar checking service
* @param {Boolean} allowReplacements Host element is mutable and grammar mistake may be replaced by suggestion * @param {Boolean} allowReplacements Host element is mutable and grammar mistake may be replaced by suggestion