diff --git a/Readme.md b/Readme.md
index f6c8717..e7eb001 100644
--- a/Readme.md
+++ b/Readme.md
@@ -87,6 +87,24 @@ Kategorije pravopisnih pravil so:
| BESANA_CAT_5 | slovnične napake |
| BESANA_CAT_6 | nomotehnične napake (potrebuje posebno licenco) |
+### 4. Nastavljanje videza korekturnih znamenj
+
+Privzeto servis uporablja podčrtovanje pravopisnih napak (nastavitev `'underline'`). Videz lahko spreminjamo.
+
+
+
+
+Levo `'underline'`, desno `'lector'`.
+
+Primer:
+
+```JavaScript
+// Registriramo servis za naš urejevalnik.
+BesService
+ .registerByElement(el)
+ .setMarkupStyle('lector')
+```
+
## Navodila za razvijalce
Programsko kodo v tem repozitoriju razvijamo s programom Visual Studio Code. Potrebna je namestitev vtičnika `esbenp.prettier-vscode`.
diff --git a/samples/common.js b/samples/common.js
index 738c8de..b2b5ebf 100644
--- a/samples/common.js
+++ b/samples/common.js
@@ -59,10 +59,10 @@ class BesStatusIconEventSink {
const rect = service.textElement.getBoundingClientRect()
const scrollbarWidth =
service.textElement.offsetWidth - service.textElement.clientWidth
- this.statusDiv.style.left = `${rect.right - 40 - scrollbarWidth}px`
+ this.statusDiv.style.left = `${rect.right - 40 - scrollbarWidth + window.scrollX}px`
const scrollbarHeight =
service.textElement.offsetHeight - service.textElement.clientHeight
- this.statusDiv.style.top = `${rect.bottom - 30 - scrollbarHeight}px`
+ this.statusDiv.style.top = `${rect.bottom - 30 - scrollbarHeight + window.scrollY}px`
}
/**
diff --git a/samples/markup-style.html b/samples/markup-style.html
new file mode 100644
index 0000000..fbb4550
--- /dev/null
+++ b/samples/markup-style.html
@@ -0,0 +1,86 @@
+
+
+
This is an example how to switch markup style.
+
+
+
+
Tukaj vpišite besedilo ki ga želite popraviti.
+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.
+To velja tudi v Bledu. To se je zgodilo na velikemu vrtu. Prišel je na Kamnik. On je včeraj prišel z svojo torbo. Dve žemlje prosim. Pogosto brskam po temu forumu. Prišel je včeraj in sicer s otroci. To ne vem. Pogleda vse kar daš v odložišče. Nisem jo videl. Ona izgleda dobro. Pri zanikanju ne smete uporabljati tožilnik. Vlak gre v Ljubljano čez Zidani Most. Skočil je čez okno. Slovenija meji na avstrijo. Jaz pišem v Slovenščini vsak Torek. Novica, da je skupina 25 planincev hodila pod vodstvom gorskega vodnika je napačna in zavajujoča. Želim da poješ kosmizailo. Jaz pogosto brskam po temu forumu. Med tem ko je iskal ključe, so se odprla vrata. V takoimenovanem skladišču je bilo veliko ljudi. V sobi sta dve mize. Stekel je h mami. Videl sem Jurčič Micko. To je bil njegov življenski cilj. Po vrsti popravite vse kar želite. Preden zaspiva mi prebere pravljico. Prišel je s stricom. Oni zadanejo tarčo. Mi gremo teči po polju. Mi gremo peči kruh. Usedel se je k miza. Postreži kosilo! Skul je veslanje z dvemi vesli.
+Na mizo nisem položil knjigo.
+Kvazimodo ji je ponavadi prinesel hrano in pijačo, medtem ko je spala, da ne bi videla njegov iznakažen in grd obraz. Poleg tega ji je pustil tudi piščalko, da bi ga lahko priklicala, če bi bilo to potrebno. Kvazimodo se je odločil, da razveseli Esmeraldo in ji obljubi, da ji bo pripeljal Febusa. Toda Febus ni želel priti. Kvazimodo ji je raje lagal, da ni mogel najti Febusa, kot da Esmeraldi pove resnico, ker bi ona trpela.
+Tukaj vpišite besedilo ki ga želite popraviti.
+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.
+To velja tudi v Bledu. To se je zgodilo na velikemu vrtu. Prišel je na Kamnik. On je včeraj prišel z svojo torbo. Dve žemlje prosim. Pogosto brskam po temu forumu. Prišel je včeraj in sicer s otroci. To ne vem. Pogleda vse kar daš v odložišče. Nisem jo videl. Ona izgleda dobro. Pri zanikanju ne smete uporabljati tožilnik. Vlak gre v Ljubljano čez Zidani Most. Skočil je čez okno. Slovenija meji na avstrijo. Jaz pišem v Slovenščini vsak Torek. Novica, da je skupina 25 planincev hodila pod vodstvom gorskega vodnika je napačna in zavajujoča. Želim da poješ kosmizailo. Jaz pogosto brskam po temu forumu. Med tem ko je iskal ključe, so se odprla vrata. V takoimenovanem skladišču je bilo veliko ljudi. V sobi sta dve mize. Stekel je h mami. Videl sem Jurčič Micko. To je bil njegov življenski cilj. Po vrsti popravite vse kar želite. Preden zaspiva mi prebere pravljico. Prišel je s stricom. Oni zadanejo tarčo. Mi gremo teči po polju. Mi gremo peči kruh. Usedel se je k miza. Postreži kosilo! Skul je veslanje z dvemi vesli.
+Na mizo nisem položil knjigo.
+Kvazimodo ji je ponavadi prinesel hrano in pijačo, medtem ko je spala, da ne bi videla njegov iznakažen in grd obraz. Poleg tega ji je pustil tudi piščalko, da bi ga lahko priklicala, če bi bilo to potrebno. Kvazimodo se je odločil, da razveseli Esmeraldo in ji obljubi, da ji bo pripeljal Febusa. Toda Febus ni želel priti. Kvazimodo ji je raje lagal, da ni mogel najti Febusa, kot da Esmeraldi pove resnico, ker bi ona trpela.
+Tukaj vpišite besedilo ki ga želite popraviti.
+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.
+To velja tudi v Bledu. To se je zgodilo na velikemu vrtu. Prišel je na Kamnik. On je včeraj prišel z svojo torbo. Dve žemlje prosim. Pogosto brskam po temu forumu. Prišel je včeraj in sicer s otroci. To ne vem. Pogleda vse kar daš v odložišče. Nisem jo videl. Ona izgleda dobro. Pri zanikanju ne smete uporabljati tožilnik. Vlak gre v Ljubljano čez Zidani Most. Skočil je čez okno. Slovenija meji na avstrijo. Jaz pišem v Slovenščini vsak Torek. Novica, da je skupina 25 planincev hodila pod vodstvom gorskega vodnika je napačna in zavajujoča. Želim da poješ kosmizailo. Jaz pogosto brskam po temu forumu. Med tem ko je iskal ključe, so se odprla vrata. V takoimenovanem skladišču je bilo veliko ljudi. V sobi sta dve mize. Stekel je h mami. Videl sem Jurčič Micko. To je bil njegov življenski cilj. Po vrsti popravite vse kar želite. Preden zaspiva mi prebere pravljico. Prišel je s stricom. Oni zadanejo tarčo. Mi gremo teči po polju. Mi gremo peči kruh. Usedel se je k miza. Postreži kosilo! Skul je veslanje z dvemi vesli.
+Na mizo nisem položil knjigo.
+Kvazimodo ji je ponavadi prinesel hrano in pijačo, medtem ko je spala, da ne bi videla njegov iznakažen in grd obraz. Poleg tega ji je pustil tudi piščalko, da bi ga lahko priklicala, če bi bilo to potrebno. Kvazimodo se je odločil, da razveseli Esmeraldo in ji obljubi, da ji bo pripeljal Febusa. Toda Febus ni želel priti. Kvazimodo ji je raje lagal, da ni mogel najti Febusa, kot da Esmeraldi pove resnico, ker bi ona trpela.
+Element to delete
Element to delete
@@ -18,7 +42,7 @@Element to delete
Element to delete
Tukaj vpišite besedilo ki ga želite popraviti.
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.
diff --git a/service.js b/service.js index 8e25e8e..4591ed5 100644 --- a/service.js +++ b/service.js @@ -41,7 +41,9 @@ class BesService { 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 @@ -57,6 +59,7 @@ class BesService { this.hostElement.setAttribute('data-gramm', 'false') this.hostElement.setAttribute('data-gramm_editor', 'false') this.hostElement.setAttribute('data-enable-grammarly', 'false') + this.textFont = window.getComputedStyle(this.hostElement).fontFamily this.onScroll = this.onScroll.bind(this) this.hostElement.addEventListener('scroll', this.onScroll) @@ -70,6 +73,9 @@ class BesService { }) besServices.push(this) + + // Initial sync the scroll as hostElement may be scrolled by non-(0, 0) at the time of BesService registration. + this.onScroll() } /** @@ -186,6 +192,18 @@ class BesService { 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. * @@ -272,13 +290,33 @@ class BesService { 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() { - // 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.left = `${-this.hostElement.scrollLeft}px` + 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 @@ -300,7 +338,6 @@ class BesService { */ onResize() { this.setCorrectionPanelSize() - this.repositionAllMarkup() if (this.eventSink && 'resize' in this.eventSink) this.eventSink.resize(this) } @@ -315,39 +352,578 @@ class BesService { rect.left !== this.hostBoundingClientRect.left ) this.onReposition() - if ( - rect.width !== this.hostBoundingClientRect.width || - rect.height !== this.hostBoundingClientRect.height - ) - this.onResize() + this.onResize() this.hostBoundingClientRect = rect } /** - * Creates grammar mistake markup in DOM. + * Draws grammar mistake markup on canvas and populates collection of highlight rectangles. * - * @param {Range} range Grammar mistake range - * @param {String} ruleId Grammar mistake rule ID as reported by BesStr - * @returns {Array} Grammar mistake highlight elements + * @param {*} match Grammar checking rule match */ - addMistakeMarkup(range, ruleId) { - const scrollPanelRect = this.scrollPanel.getBoundingClientRect() - let highlights = [] - for (let rect of range.getClientRects()) { - const highlight = document.createElement('div') - highlight.classList.add( - ruleId.startsWith('MORFOLOGIK_RULE') - ? 'bes-spelling-mistake' - : 'bes-grammar-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) + 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 + const ruleId = match.match.rule.id + this.ctx.strokeStyle = ruleId.startsWith('MORFOLOGIK_RULE') + ? 'rgba(0, 123, 255, 0.8)' + : 'rgba(255, 115, 0, 0.8)' + this.ctx.fillStyle = ruleId.startsWith('MORFOLOGIK_RULE') + ? 'rgba(0, 123, 255, 0.8)' + : 'rgba(255, 115, 0, 0.8)' + let markerY1, markerY2 + switch (this.markupStyle) { + case 'lector': + if (ruleId === 'BESANA_6' /*PR_VNAP_V_STAVKU_MANJKA_VEJICA*/) { + // Thou we should draw ┘ after the word before match.match.offset, if there is a line break inbetween, + // the correction markup would be drawn in one line and the highlight rectangle for onClick would reside + // in another line, making a confusing UX. + markerY1 = match.highlights[0].top + markerY2 = match.highlights[0].bottom + const scale = (markerY2 - markerY1) / 18 + const x = match.highlights[0].left + const y = match.highlights[0].bottom + this.drawMissingComma(x, y, scale, '?') + break + } + + if (match.match.replacements && match.match.replacements.length === 1) { + const context = match.match.context.text.substr( + match.match.context.offset, + match.match.context.length + ) + const replacement = match.match.replacements[0].value + const lengthDiff = replacement.length - context.length + if ( + lengthDiff > 0 && + replacement.substr(-context.length) === context + ) { + // Something to insert before + const toInsert = replacement.substr(0, lengthDiff) + markerY1 = match.highlights[0].top + markerY2 = match.highlights[0].bottom + const scale = (markerY2 - markerY1) / 18 + + if (toInsert === ',') { + // Thou we should draw ┘ after the word before match.match.offset, if there is a line break inbetween, + // the correction markup would be drawn in one line and the highlight rectangle for onClick would reside + // in another line, making a confusing UX. + const x = match.highlights[0].left + const y = match.highlights[0].bottom + this.drawMissingComma(x, y, scale) + } else if (toInsert === '.') { + // Thou we should draw . after the word before match.match.offset, if there is a line break inbetween, + // the correction markup would be drawn in one line and the highlight rectangle for onClick would reside + // in another line, making a confusing UX. + const x = match.highlights[0].left + const y = match.highlights[0].bottom - 4 * scale + this.drawMissingPeriod(x, y, scale) + } else if (/^\s+$/.test(toInsert)) { + const x = match.highlights[0].left + const y1 = match.highlights[0].bottom - 2 * scale + const y2 = match.highlights[0].top + 2 * scale + this.drawWrongSpacing(x, y1, y2, scale) + } else { + const x = match.highlights[0].left - 1 * scale + const y1 = match.highlights[0].bottom + const y2 = match.highlights[0].top + this.drawMissingText( + x, + y1, + y2, + scale, + replacement.substr(lengthDiff).trim() + ) + } + } else if (replacement.substr(0, context.length) === context) { + // Something to insert after + const toInsert = replacement.substr(-lengthDiff) + markerY1 = match.highlights.at(-1).top + markerY2 = match.highlights.at(-1).bottom + const scale = (markerY2 - markerY1) / 18 + + if (toInsert === ',') { + const x = match.highlights.at(-1).right + const y = match.highlights.at(-1).bottom + this.drawMissingComma(x, y, scale) + } else if (toInsert === '.') { + const x = match.highlights.at(-1).right + 3 * scale + const y = match.highlights.at(-1).bottom - 4 * scale + this.drawMissingPeriod(x, y, scale) + } else if (/^\s+$/.test(toInsert)) { + const x = match.highlights.at(-1).right + const y1 = match.highlights.at(-1).bottom - 2 * scale + const y2 = match.highlights.at(-1).top + 2 * scale + this.drawWrongSpacing(x, y1, y2, scale) + } else { + const x = match.highlights.at(-1).right + 1 * scale + const y1 = match.highlights.at(-1).bottom + const y2 = match.highlights.at(-1).top + this.drawMissingText( + x, + y1, + y2, + scale, + replacement.substr(-lengthDiff).trim() + ) + } + } else if ( + lengthDiff < 0 && + context.substr(-replacement.length) === replacement + ) { + // Something to remove before + const toRemove = context.substr(0, -lengthDiff) + markerY1 = match.highlights[0].top + markerY2 = match.highlights[0].bottom + const scale = (markerY2 - markerY1) / 18 + + if (/^\s+$/.test(toRemove)) { + const rect = 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, scale) + } + + markerY1 = Math.min(...match.highlights.map(rect => rect.top)) + markerY2 = Math.max(...match.highlights.map(rect => rect.bottom)) } - return highlights + + this.drawSideMarker(markerY1, markerY2) + } + + /** + * Draws the marker that helps visualize lines of text where grammar mistakes were detected + * + * @param {Number} y1 Marker top [px] + * @param {Number} y2 Marker bottom [px] + */ + drawSideMarker(y1, y2) { + const dpr = window.devicePixelRatio + const markerX = this.canvasPanel.width - 5 * dpr + this.ctx.beginPath() + this.ctx.moveTo(markerX, y1 * dpr) + this.ctx.lineTo(markerX, y2 * dpr) + this.ctx.stroke() + } + + /** + * Draws the missing comma sign + * + * @param {Number} x Sign center [px] + * @param {Number} y Sign bottom [px] + * @param {Number} scale Sign scale + * @param {String} comment Text to display above the marker + */ + drawMissingComma(x, y, scale, comment) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo((x - 2 * scale) * dpr, y * dpr) + this.ctx.lineTo((x + 2 * scale) * dpr, y * dpr) + this.ctx.lineTo((x + 2 * scale) * dpr, (y - 4 * scale) * dpr) + this.ctx.stroke() + + if (comment) { + this.ctx.font = `${12 * scale * dpr}px ${this.textFont}` + this.ctx.textAlign = 'center' + this.ctx.textBaseline = 'bottom' + this.ctx.fillText('?', (x + 2 * scale) * dpr, (y - 6 * scale) * dpr) + } + } + + /** + * Draws the missing period sign + * + * @param {Number} x Sign center [px] + * @param {Number} y Sign bottom [px] + * @param {Number} scale Sign scale + */ + drawMissingPeriod(x, y, scale) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.ellipse( + x * dpr, + y * dpr, + 2 * scale * dpr, + 2 * scale * dpr, + 0, + 0, + 2 * Math.PI + ) + this.ctx.fill() + } + + /** + * Draws the wrong spacing sign. Control direction of chevrons by reversing y1 and y2. + * + * @param {Number} x Sign center [px] + * @param {Number} y1 Sign top/bottom [px] + * @param {Number} y2 Sign bottom/top [px] + * @param {Number} scale Sign scale + */ + drawWrongSpacing(x, y1, y2, scale) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo((x - 4 * scale) * dpr, (y1 + 4 * scale) * dpr) + this.ctx.lineTo(x * dpr, y1 * dpr) + this.ctx.lineTo((x + 4 * scale) * dpr, (y1 + 4 * scale) * dpr) + this.ctx.moveTo(x * dpr, y1 * dpr) + this.ctx.lineTo(x * dpr, y2 * dpr) + this.ctx.moveTo((x - 4 * scale) * dpr, (y2 - 4 * scale) * dpr) + this.ctx.lineTo(x * dpr, y2 * dpr) + this.ctx.lineTo((x + 4 * scale) * dpr, (y2 - 4 * scale) * dpr) + this.ctx.stroke() + } + + /** + * Strikes out the excessive text + * + * @param {Number} x1 Strike line start X [px] + * @param {Number} y1 Strike line start Y [px] + * @param {Number} x2 Strike line end X [px] + * @param {Number} y2 Strike line end Y [px] + */ + drawExcessiveText(x1, y1, x2, y2) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo(x1 * dpr, y1 * dpr) + this.ctx.lineTo(x2 * dpr, y2 * dpr) + this.ctx.stroke() + } + + /** + * Strikes out the text and draws the replacement text above + * + * @param {Number} x1 Strike line start X [px] + * @param {Number} y1 Strike line start Y [px] + * @param {Number} x2 Strike line end X [px] + * @param {Number} y2 Strike line end Y [px] + * @param {Number} scale Sign scale + * @param {String} text Text to display above + */ + drawWrongText(x1, y1, x2, y2, scale, text) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo(x1 * dpr, (y1 - 6 * scale) * dpr) + this.ctx.lineTo(x2 * dpr, (y2 + 6 * scale) * dpr) + this.ctx.stroke() + + this.ctx.font = `${12 * scale * dpr}px ${this.textFont}` + this.ctx.textAlign = 'left' // Thou we want the text to be centered, we align it manually to prevent it getting off canvas. + this.ctx.textBaseline = 'bottom' + const textMetrics = this.ctx.measureText(text) + this.ctx.fillText( + text, + Math.max( + Math.min( + ((x1 + x2) / 2) * dpr - textMetrics.width / 2, + this.canvasPanel.width - textMetrics.width + ), + 0 + ), + (y2 + 6 * scale) * dpr + ) + } + + /** + * Draws the sign some text is missing + * + * @param {Number} x Sign center [px] + * @param {Number} y1 Sign bottom [px] + * @param {Number} y2 Sign top [px] + * @param {Number} scale Sign scale + * @param {String} text Text to display above + */ + drawMissingText(x, y1, y2, scale, text) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo((x - 6 * scale) * dpr, y1 * dpr) + this.ctx.lineTo((x + 6 * scale) * dpr, y1 * dpr) + this.ctx.moveTo(x * dpr, y1 * dpr) + this.ctx.lineTo(x * dpr, (y2 + 6 * scale) * dpr) + this.ctx.stroke() + + this.ctx.font = `${12 * scale * dpr}px ${this.textFont}` + this.ctx.textAlign = 'left' // Thou we want the text to be centered, we align it manually to prevent it getting off canvas. + this.ctx.textBaseline = 'bottom' + const textMetrics = this.ctx.measureText(text) + this.ctx.fillText( + text, + Math.max( + Math.min( + x * dpr - textMetrics.width / 2, + this.canvasPanel.width - textMetrics.width + ), + 0 + ), + (y2 + 6 * scale) * dpr + ) + } + + /** + * Draws zig-zag line + * + * @param {Number} x1 Sign left [px] + * @param {Number} x2 Sign right [px] + * @param {Number} y Sign baseline [px] + * @param {Number} scale Sign scale + */ + drawAttentionRequired(x1, x2, y, scale) { + const dpr = window.devicePixelRatio + this.ctx.beginPath() + this.ctx.moveTo(x1 * dpr, (y - scale) * dpr) + for (let x = x1; ; ) { + if (x >= x2) break + this.ctx.lineTo((x += 2 * scale) * dpr, (y + scale) * dpr) + if (x >= x2) break + this.ctx.lineTo((x += 2 * scale) * dpr, (y - scale) * dpr) + } + this.ctx.stroke() + } + + /** + * Calculates 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 } /** @@ -366,18 +942,23 @@ class BesService { * Creates auxiliary DOM elements for text adornments. */ createCorrectionPanel() { - const panelParent = document.createElement('div') - panelParent.classList.add('bes-correction-panel-parent') - this.correctionPanel = document.createElement('div') this.correctionPanel.classList.add('bes-correction-panel') this.scrollPanel = document.createElement('div') - this.scrollPanel.classList.add('bes-correction-panel-scroll') + 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) - panelParent.appendChild(this.correctionPanel) this.correctionPanel.appendChild(this.scrollPanel) - this.textElement.parentElement.insertBefore(panelParent, this.textElement) + this.scrollPanel.appendChild(this.canvasPanel) + this.textElement.parentElement.insertBefore( + this.correctionPanel, + this.textElement + ) this.setCorrectionPanelSize() } @@ -386,24 +967,23 @@ class BesService { */ clearCorrectionPanel() { this.correctionPanel.remove() - this.scrollPanel.remove() } /** * Resizes correction and scroll panels to match host element size. */ setCorrectionPanelSize() { + this.disableMutationObserver() + const styles = window.getComputedStyle(this.hostElement) - this.correctionPanel.style.marginLeft = styles.marginLeft - this.correctionPanel.style.marginTop = styles.marginTop - this.correctionPanel.style.marginRight = styles.marginRight - this.correctionPanel.style.marginBottom = styles.marginBottom - this.correctionPanel.style.paddingLeft = styles.paddingLeft - this.correctionPanel.style.paddingTop = styles.paddingTop - this.correctionPanel.style.paddingRight = styles.paddingRight - this.correctionPanel.style.paddingBottom = styles.paddingBottom - this.scrollPanel.style.width = `${this.hostElement.scrollWidth}px` - this.scrollPanel.style.height = `${this.hostElement.scrollHeight}px` + + // 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) + @@ -411,41 +991,83 @@ class BesService { parseFloat(styles.width) + parseFloat(styles.marginRight) + parseFloat(styles.paddingRight) - this.correctionPanel.style.width = `${totalWidth}px` - this.correctionPanel.style.height = styles.height + this.scrollPanel.style.width = `${totalWidth}px` + this.scrollPanel.style.height = styles.height } else { - this.correctionPanel.style.width = styles.width - this.correctionPanel.style.height = styles.height + const hostRect = this.hostElement.getBoundingClientRect() + this.scrollPanel.style.width = `${hostRect.width}px` + this.scrollPanel.style.height = `${hostRect.height}px` } + + this.textFont = styles.fontFamily + + // Resize canvas if needed. + this.canvasPanel.style.width = `${this.hostElement.scrollWidth}px` + this.canvasPanel.style.height = `${this.hostElement.scrollHeight}px` + const dpr = window.devicePixelRatio + const canvasPanelRect = this.canvasPanel.getBoundingClientRect() + const newCanvasWidth = Math.round(canvasPanelRect.width * dpr) + const newCanvasHeight = Math.round(canvasPanelRect.height * dpr) + if ( + this.canvasPanel.width !== newCanvasWidth || + this.canvasPanel.height !== newCanvasHeight + ) { + this.canvasPanel.width = newCanvasWidth + this.canvasPanel.height = newCanvasHeight + this.redrawAllMistakeMarkup() + } + + this.enableMutationObserver() } /** - * Displays correction panel. + * Prepares and displays popup. * - * @param {*} el Block element/paragraph containing grammar checking rule match - * @param {*} match Grammar checking rule match + * @param {*} elMatch Array containing block element/paragraph containing grammar checking rule match and a match * @param {PointerEvent} source Click event source */ - popupCorrectionPanel(el, match, source) { + preparePopup(elMatch, source) { + this.dismissPopup() const popup = document.querySelector('bes-popup-el') - popup.changeMessage(match.match.message) - popup.appendReplacements(el, match, this, this.isContentEditable()) - this.highlightMistake(match) + BesPopup.clearReplacements() + elMatch.forEach(({ el, match }) => { + popup.setContent(el, match, this, this.isContentEditable()) + this.highlightMistake(match) + }) popup.show(source.clientX, source.clientY) } /** - * Removes previously highlighted grammar mistake and highlights new one. + * Highlights given grammar mistake. * * @param {*} match Grammar checking rule match */ highlightMistake(match) { - document.querySelectorAll('.bes-mistake-highlight-selected').forEach(el => { - el.classList.remove('bes-mistake-highlight-selected') + const canvasPanelRect = this.canvasPanel.getBoundingClientRect() + match.highlights.forEach(rect => { + const el = document.createElement('div') + el.classList.add('bes-highlight-rect') + el.classList.add( + match.match.rule.id.startsWith('MORFOLOGIK_RULE') + ? 'bes-highlight-spelling-rect' + : 'bes-highlight-grammar-rect' + ) + el.style.left = `${rect.x + canvasPanelRect.x + window.scrollX}px` + el.style.top = `${rect.y + canvasPanelRect.y + window.scrollY}px` + el.style.width = `${rect.width}px` + el.style.height = `${rect.height}px` + document.body.appendChild(el) + this.highlightElements.push(el) }) - match.highlights.forEach(h => - h.classList.add('bes-mistake-highlight-selected') - ) + } + + /** + * Clears highlight and hides popup + */ + dismissPopup() { + BesPopup.hide() + this.highlightElements.forEach(el => el.remove()) + this.highlightElements = [] } /** @@ -463,17 +1085,12 @@ class BesService { } /** - * Updates all grammar mistake markup positions. + * Redraws all grammar mistake markup. */ - repositionAllMarkup() { + redrawAllMistakeMarkup() { + this.ctx.clearRect(0, 0, this.canvasPanel.width, this.canvasPanel.height) this.results.forEach(result => { - result.matches.forEach(match => { - if (match.highlights) match.highlights.forEach(h => h.remove()) - match.highlights = this.addMistakeMarkup( - match.range, - match.match.rule.id - ) - }) + result.matches.forEach(match => this.drawMistakeMarkup(match)) }) } @@ -620,49 +1237,17 @@ class BesTreeService extends BesService { .then(responseData => { let matches = [] responseData.matches.forEach(match => { - let range = document.createRange() - - // Locate start of the grammar mistake. - for ( - let idx = 0, startingOffset = 0; - ; - startingOffset += data[idx++].text.length - ) { - if ( - !data[idx].markup && - /*startingOffset <= match.offset &&*/ match.offset < - startingOffset + data[idx].text.length - ) { - range.setStart( - data[idx].node, - match.offset - startingOffset - ) - break - } - } - - // Locate end of the grammar mistake. - let endOffset = match.offset + match.length - for ( - let idx = 0, startingOffset = 0; - ; - startingOffset += data[idx++].text.length - ) { - if ( - !data[idx].markup && - /*startingOffset <= endOffset &&*/ endOffset <= - startingOffset + data[idx].text.length - ) { - range.setEnd(data[idx].node, endOffset - startingOffset) - break - } - } - - matches.push({ - highlights: this.addMistakeMarkup(range, match.rule.id), - range: range, + let m = { + data: data, + range: this.makeRange( + data, + match.offset, + match.offset + match.length + ), match: match - }) + } + this.drawMistakeMarkup(m) + matches.push(m) }) this.markProofed(node, matches) this.onProofingProgress(matches.length) @@ -697,6 +1282,34 @@ class BesTreeService extends BesService { } } + 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. * @@ -729,28 +1342,10 @@ class BesTreeService extends BesService { * @param {Element} el DOM element for removal */ clearProofing(el) { - this.clearMarkup(el) this.results = this.results.filter( result => !BesTreeService.isSameParagraph(result.element, el) ) - } - - /** - * Clears given block element grammar mistake markup. - * - * @param {Element} el DOM element we want to clean markup for - */ - clearMarkup(el) { - this.results - .filter(result => BesTreeService.isSameParagraph(result.element, el)) - .forEach(result => - result.matches.forEach(match => { - if (match.highlights) { - match.highlights.forEach(h => h.remove()) - delete match.highlights - } - }) - ) + this.redrawAllMistakeMarkup() } /** @@ -906,24 +1501,22 @@ class BesTreeService extends BesService { const source = event?.detail !== 1 ? event?.detail : event const el = this.getBlockParent(source.targetElement || source.target) if (!el) return - + const canvasPanelRect = this.canvasPanel.getBoundingClientRect() + let x = source.clientX - canvasPanelRect.x + let y = source.clientY - canvasPanelRect.y + const pointsInRect = [] for (let result of this.results) { for (let m of result.matches) { - for (let h of m.highlights) { - if ( - BesService.isPointInRect( - source.clientX, - source.clientY, - h.getBoundingClientRect() - ) - ) { - this.popupCorrectionPanel(el, m, source) - return + for (let rect of m.highlights) { + if (BesService.isPointInRect(x, y, rect)) { + pointsInRect.push({ el, match: m }) + break } } } } - BesPopup.hide() + this.dismissPopup() + if (pointsInRect.length) this.preparePopup(pointsInRect, source) } } @@ -1007,8 +1600,8 @@ class BesDOMService extends BesTreeService { */ onInput() { // Now that the text is done changing, we can correctly calculate markup position. - this.repositionAllMarkup() - + this.redrawAllMistakeMarkup() + this.dismissPopup() // Defer grammar-checking to reduce stress on grammar-checking server. this.scheduleProofing(1000) } @@ -1089,7 +1682,7 @@ class BesCKService extends BesTreeService { // element, it will not be updated immediately. setTimeout(() => { // Now that the text is done changing, we can correctly calculate markup position. - this.repositionAllMarkup() + this.redrawAllMistakeMarkup() // Defer grammar-checking to reduce stress on grammar-checking server. this.scheduleProofing(1000) @@ -1256,7 +1849,7 @@ class BesQuillService extends BesTreeService { this.clearProofing(domElement) setTimeout(() => { - this.repositionAllMarkup() + this.redrawAllMistakeMarkup() this.scheduleProofing(1000) }, 0) } @@ -1432,27 +2025,18 @@ class BesPlainTextService extends BesService { .then(responseData => { let matches = [] responseData.matches.forEach(match => { - let matchRange = document.createRange() - let nodeIdx = 0, - matchStart = start + match.offset - while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchStart) - nodeIdx++ - matchRange.setStart( - nodes[nodeIdx].node, - matchStart - nodes[nodeIdx].start - ) - let matchEnd = matchStart + match.length - while (nodeIdx < nodes.length && nodes[nodeIdx].end < matchEnd) - nodeIdx++ - matchRange.setEnd( - nodes[nodeIdx].node, - matchEnd - nodes[nodeIdx].start - ) - matches.push({ - highlights: this.addMistakeMarkup(matchRange, match.rule.id), - range: matchRange, + const d = { nodes, start, end } + let m = { + data: d, + range: this.makeRange( + d, + match.offset, + match.offset + match.length + ), match: match - }) + } + this.drawMistakeMarkup(m) + matches.push(m) }) this.markProofed(paragraphRange, matches) this.onProofingProgress(matches.length) @@ -1464,6 +2048,26 @@ class BesPlainTextService extends BesService { 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 * @@ -1519,30 +2123,10 @@ class BesPlainTextService extends BesService { * @param {Range} range Paragraph range */ clearProofing(range) { - this.clearMarkup(range) this.results = this.results.filter( result => !BesPlainTextService.isSameParagraph(result.range, range) ) - } - - /** - * Clears given paragraph grammar mistake markup. - * - * @param {Range} range Paragraph range - */ - clearMarkup(range) { - this.results - .filter(result => - BesPlainTextService.isSameParagraph(result.range, range) - ) - .forEach(result => - result.matches.forEach(match => { - if (match.highlights) { - match.highlights.forEach(h => h.remove()) - delete match.highlights - } - }) - ) + this.redrawAllMistakeMarkup() } /** @@ -1585,24 +2169,22 @@ class BesPlainTextService extends BesService { const source = event?.detail !== 1 ? event?.detail : event const el = source.targetElement || source.target || this.hostElement if (!el) return - + const canvasPanelRect = this.canvasPanel.getBoundingClientRect() + let x = source.clientX - canvasPanelRect.x + let y = source.clientY - canvasPanelRect.y + const pointsInRect = [] for (let result of this.results) { for (let m of result.matches) { - for (let h of m.highlights) { - if ( - BesService.isPointInRect( - source.clientX, - source.clientY, - h.getBoundingClientRect() - ) - ) { - this.popupCorrectionPanel(result.range, m, source) - return + for (let rect of m.highlights) { + if (BesService.isPointInRect(x, y, rect)) { + pointsInRect.push({ el: result.range, match: m }) + break } } } } - BesPopup.hide() + this.dismissPopup() + if (pointsInRect.length) this.preparePopup(pointsInRect, source) } /** @@ -1716,8 +2298,8 @@ class BesDOMPlainTextService extends BesPlainTextService { delete this.textBeforeChange // Now that the text is done changing, we can correctly calculate markup position. - this.repositionAllMarkup() - + this.redrawAllMistakeMarkup() + this.dismissPopup() // Defer grammar-checking to reduce stress on grammar-checking server. this.scheduleProofing(1000) } @@ -1794,9 +2376,6 @@ class BesTAService extends BesPlainTextService { */ constructor(hostElement, eventSink) { super(hostElement, BesTAService.createTextElement(hostElement), eventSink) - this.textElement.replaceChildren( - document.createTextNode(this.hostElement.value) - ) } /** @@ -1817,6 +2396,14 @@ class BesTAService extends BesPlainTextService { return service } + /** + * Unregisters grammar checking service. + */ + unregister() { + super.unregister() + this.textElement.remove() + } + /** * Creates a clone div element for the