diff --git a/.gitignore b/.gitignore index c2a2064..3bd5137 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dev dist build tmp +.lingma diff --git a/src/index.ts b/src/index.ts index ebf78cc..14c120d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -import {Plugin, showMessage} from 'siyuan'; -import {ProtyleHelpers} from "@/protyleHelpers"; +import {IProtyle, Plugin, showMessage} from 'siyuan'; +import {ProtyleHelper} from "@/protyleHelper"; import {Icons} from "@/icons"; import {Settings} from "@/settings"; import {SettingUtils} from "@/libs/setting-utils"; @@ -15,6 +15,7 @@ import {Language} from "@/spellChecker"; export default class SpellCheckPlugin extends Plugin { private menus: Menus + private currentlyEditing: { protyle: ProtyleHelper, enabled: boolean, language: string }; public settingsUtil: SettingUtils; public suggestions: SuggestionEngine @@ -62,30 +63,34 @@ export default class SpellCheckPlugin extends Plugin { }` window.document.head.appendChild(style); - if(window.siyuan.config.editor.spellcheck) { + if (window.siyuan.config.editor.spellcheck) { showMessage(this.i18nx.errors.builtInEnabled, -1, 'error') } this.eventBus.on('ws-main', async (event) => { - if (event.detail.cmd != 'transactions') { return } + if (event.detail.cmd != 'transactions') { + return + } const operation = event.detail.data[0].doOperations[0] const action = operation.action const blockID = operation.id - if(action != 'update') { return } + if (action != 'update' || !this.currentlyEditing.enabled) { + return + } + await this.suggestions.storeBlocks(this.currentlyEditing.protyle, this.currentlyEditing.language) await this.suggestions.suggestAndRender(blockID) - void this.suggestions.forAllBlocksSuggest(this.suggestions.documentID, false, true, false) + void this.suggestions.forAllBlocksSuggest(false, true) }) this.eventBus.on('open-menu-content', async (event) => { - if(!this.suggestions.documentEnabled) { return } void this.analytics.sendEvent('menu-open-any'); - const blockID = ProtyleHelpers.getNodeId(event.detail.range.startContainer.parentElement) + const blockID = ProtyleHelper.getNodeId(event.detail.range.startContainer.parentElement) const suggNo = this.suggestions.getSuggestionNumber(blockID, event.detail.range) this.menus.addCorrectionsToParagraphMenu(blockID, suggNo, event.detail.menu) @@ -93,31 +98,46 @@ export default class SpellCheckPlugin extends Plugin { }) this.eventBus.on('open-menu-doctree', async (event) => { - const docID = ProtyleHelpers.getNodeId(event.detail.elements[0]) // @TODO this is ugly, why does the event not carry the docID? + const docID = ProtyleHelper.getNodeId(event.detail.elements[0]) // @TODO this is ugly, why does the event not carry the docID? void this.menus.addSettingsToDocMenu(docID, event.detail.menu) }) this.eventBus.on('switch-protyle', async (event) => { + void this.suggestions.forAllBlocksSuggest(false, true) + const settings = await ProtyleHelper.getDocumentSettings(event.detail.protyle.block.id, + this.settingsUtil.get('enabledByDefault'), this.settingsUtil.get('defaultLanguage')) + this.currentlyEditing = { + protyle: new ProtyleHelper(event.detail.protyle.contentElement), + enabled: settings.enabled, + language: settings.language + } + new ResizeObserver( + this.suggestions.forAllBlocksSuggest.bind(this.suggestions) + ).observe(event.detail.protyle.contentElement) + }) - const docID = event.detail.protyle.block.id - const settings = await ProtyleHelpers.getDocumentSettings(docID, this.settingsUtil.get('enabledByDefault'), this.settingsUtil.get('defaultLanguage')) - - this.suggestions.documentID = docID - this.suggestions.documentEnabled = settings.enabled - this.suggestions.documentLanguage = settings.language - - this.suggestions.clearStorage() - void this.suggestions.forAllBlocksSuggest(docID, true, true, false) - - const activeEditor = document.querySelector('.fn__flex-1.protyle:not([class*="fn__none"])') - new ResizeObserver(this.reRenderSuggestions.bind(this)).observe(activeEditor) - + this.eventBus.on('loaded-protyle-static', async (event) => { + await this.protyleLoad(event) + }) + this.eventBus.on('loaded-protyle-dynamic', async (event) => { + await this.protyleLoad(event) }) } - private reRenderSuggestions() { - void this.suggestions.forAllBlocksSuggest(this.suggestions.documentID, false, true, false) + private async protyleLoad(event: CustomEvent<{ protyle: IProtyle; }>) { + + const protyle = new ProtyleHelper(event.detail.protyle.contentElement) + const docID = event.detail.protyle.block.id + + const settings = await ProtyleHelper.getDocumentSettings(docID, + this.settingsUtil.get('enabledByDefault'), this.settingsUtil.get('defaultLanguage')) + + if(settings.enabled) { + await this.suggestions.storeBlocks(protyle, settings.language) + void this.suggestions.forAllBlocksSuggest(true, true) + } + } onunload() { diff --git a/src/menus.ts b/src/menus.ts index fe13833..6e89181 100644 --- a/src/menus.ts +++ b/src/menus.ts @@ -2,8 +2,7 @@ import {Menu, showMessage, subMenu} from 'siyuan'; import SpellCheckPlugin from "@/index"; import {getBlockAttrs, setBlockAttrs} from "@/api"; import {Settings} from "@/settings"; -import {ProtyleHelpers} from "@/protyleHelpers"; -import {SuggestionEngine} from "@/suggestions"; +import {ProtyleHelper} from "@/protyleHelper"; export class Menus { @@ -37,7 +36,7 @@ export class Menus { label: this.plugin.i18nx.textMenu.addToDictionary, click: async () => { void this.plugin.analytics.sendEvent('menu-click-add-to-dictionary'); - const word = SuggestionEngine.suggestionToWrongText(suggestion, blockID) + const word = this.plugin.suggestions.suggestionToWrongText(suggestion, blockID) await Settings.addToDictionary(word, this.plugin.settingsUtil) showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info') await this.plugin.suggestions.renderSuggestions(blockID) @@ -72,7 +71,7 @@ export class Menus { icon: 'info', label: this.plugin.i18nx.docMenu.documentStatus, click: async () => { - const settings = await ProtyleHelpers.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) + const settings = await ProtyleHelper.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) if(settings == null) { void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') @@ -93,7 +92,7 @@ export class Menus { click: async () => { void this.plugin.analytics.sendEvent('docmenu-click-toggle'); const attrs = await getBlockAttrs(docID) - const settings = await ProtyleHelpers.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) + const settings = await ProtyleHelper.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) if(settings == null) { void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') diff --git a/src/protyleHelpers.ts b/src/protyleHelper.ts similarity index 63% rename from src/protyleHelpers.ts rename to src/protyleHelper.ts index de88a46..a5ae0cc 100644 --- a/src/protyleHelpers.ts +++ b/src/protyleHelper.ts @@ -1,15 +1,21 @@ import {getBlockAttrs} from "@/api"; import SpellCheckPlugin from "@/index"; -export class ProtyleHelpers { +export class ProtyleHelper { - // We shouldn't use JavaScript elements to get and set data in blocks, but the kernel API is noticeably too slow for this. + private contentElement: Element + + constructor(contentElement?: Element) { + this.contentElement = contentElement || document.documentElement; + } + + // We shouldn't use JavaScript elements to get data in blocks, but the kernel API is noticeably too slow for this. // We must try to keep the dependency to the HTML to a minimum. // doesn't use kernel API, so it's faster - public static fastGetBlockElement(blockID: string): Element { + public fastGetBlockElement(blockID: string): Element { const wrapper = Array.from( - document.querySelectorAll(`div[data-node-id="${blockID}"]`) + this.contentElement.querySelectorAll(`div[data-node-id="${blockID}"]`) ).find(el => !el.closest('.protyle-wysiwyg__embed') // true = not inside that class ); @@ -17,16 +23,16 @@ export class ProtyleHelpers { return wrapper?.querySelector(':scope > [contenteditable="true"]') ?? null; } - public static fastGetBlockHTML(blockID: string): string { + public fastGetBlockHTML(blockID: string): string { return this.fastGetBlockElement(blockID).innerHTML } - public static fastGetBlockText(blockID: string): string { + public fastGetBlockText(blockID: string): string { return this.fastGetBlockElement(blockID)?.textContent } - public static fastGetTitleElement(docID: string) { - const container = document.querySelector(`div.protyle-title.protyle-wysiwyg--attr[data-node-id="${docID}"]`); + public fastGetTitleElement() { + const container = this.contentElement.querySelector(`div.protyle-title.protyle-wysiwyg--attr`); // [data-node-id="${docID}"] if (!container) return null; return container.querySelector('div.protyle-title__input[contenteditable="true"]'); } @@ -57,9 +63,16 @@ export class ProtyleHelpers { } } - public static isProtyleReady(docID: string): boolean { - const protyleTitleContainer = document.querySelector(`div[class="protyle-title protyle-wysiwyg--attr"]`) - return protyleTitleContainer.getAttribute('data-node-id') == docID + public getBlockElements(): Element[] { + const allElements: Set = new Set(); + this.contentElement.querySelectorAll('[data-node-id]').forEach(el => { + allElements.add(el); + }); + return Array.from(allElements); + } + + public toNode(): Node { + return this.contentElement } } \ No newline at end of file diff --git a/src/spellCheckerUI.ts b/src/spellCheckerUI.ts index d809c1c..a662687 100644 --- a/src/spellCheckerUI.ts +++ b/src/spellCheckerUI.ts @@ -1,28 +1,31 @@ -import {ProtyleHelpers} from "@/protyleHelpers"; +import {ProtyleHelper} from "@/protyleHelper"; export class SpellCheckerUI { private readonly blockID: string; - private readonly docID: string; + private readonly protyle: ProtyleHelper; private block: HTMLElement; private overlay: HTMLElement; - constructor(blockID: string, docID: string) { + constructor(blockID: string, protyle: ProtyleHelper) { this.blockID = blockID; - this.docID = docID; + this.protyle = protyle; this.setBlock() } private setBlock() { - this.block = ProtyleHelpers.fastGetBlockElement(this.blockID) - let overlay = ProtyleHelpers.fastGetOverlayElement(this.blockID) + this.block = this.protyle.fastGetBlockElement(this.blockID) + if(this.block == null) { + throw new Error(`Block ${this.blockID} not found`); + } + let overlay = ProtyleHelper.fastGetOverlayElement(this.blockID) if(overlay == null) { this.overlay = document.createElement('div') this.overlay.className = 'underline-overlay'; this.overlay.setAttribute('for-block-id', this.blockID) - const protyleTitle = ProtyleHelpers.fastGetTitleElement(this.docID) + const protyleTitle = this.protyle.fastGetTitleElement() protyleTitle?.append(this.overlay) }else{ if(this.overlay == null) { @@ -144,7 +147,7 @@ export class SpellCheckerUI { } public destroy() { - let overlay = ProtyleHelpers.fastGetOverlayElement(this.blockID) + let overlay = ProtyleHelper.fastGetOverlayElement(this.blockID) overlay?.remove(); } diff --git a/src/suggestions.ts b/src/suggestions.ts index aea86a4..eb95692 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -1,6 +1,6 @@ -import {ProtyleHelpers} from "@/protyleHelpers"; +import {ProtyleHelper} from "@/protyleHelper"; import {Settings} from "@/settings"; -import {getChildBlocks, updateBlock} from "@/api"; +import {updateBlock} from "@/api"; import {SpellCheckerUI} from "@/spellCheckerUI"; import {showMessage} from "siyuan"; import SpellCheckPlugin from "@/index"; @@ -8,7 +8,9 @@ import {Suggestion} from "@/spellChecker"; interface StoredBlock { spellChecker: SpellCheckerUI; - suggestions: Suggestion[]; + language: string; + suggestions: Suggestion[] | null; + protyle: ProtyleHelper; } type BlockStorage = Record; @@ -18,10 +20,6 @@ export class SuggestionEngine { private blockStorage: BlockStorage = {}; private plugin: SpellCheckPlugin; - public documentID: string; - public documentEnabled: boolean = false; - public documentLanguage: string = 'auto'; - constructor(plugin: SpellCheckPlugin) { this.plugin = plugin } @@ -36,72 +34,79 @@ export class SuggestionEngine { } } - private async discoverBlocks(blockID: string) { - const children = await getChildBlocks(blockID) - if(children.length == 0) { - if(!(blockID in this.blockStorage)) { - const spellChecker = new SpellCheckerUI(blockID, this.documentID) - this.blockStorage[blockID] = { - spellChecker: spellChecker, - suggestions: [] - } - } - }else{ - for (const child of children) { - await this.discoverBlocks(child.id) - } - } + public getProtyle(blockID: string) { + if(!(blockID in this.blockStorage)) { return null } + return this.blockStorage[blockID].protyle } - public async forAllBlocksSuggest(docID: string, suggest: boolean, render: boolean, remove: boolean) { - if(!this.documentEnabled) { return } - if(suggest) { - await this.discoverBlocks(docID) // updates this.blockStorage - } + public async storeBlocks(protyle: ProtyleHelper, documentLanguage: string) { + const blocks = protyle.getBlockElements() + blocks.forEach(block => { + const blockID = ProtyleHelper.getNodeId(block) + if(!blockID) { + return + } + if(!(blockID in this.blockStorage)) { + try { + const spellChecker = new SpellCheckerUI(blockID, protyle) + this.blockStorage[blockID] = { + spellChecker: spellChecker, + language: documentLanguage, + suggestions: null, + protyle: protyle + } + }catch (_) {} + } + }) + } + + public async forAllBlocksSuggest(suggest: boolean = false, render: boolean = true) { const blockPromises = Object.keys(this.blockStorage).map(async (blockID) => { - if(suggest) { + if(!(blockID in this.blockStorage)) { + return + } + if(suggest && this.blockStorage[blockID].suggestions == null) { await this.suggestForBlock(blockID) } if(render) { await this.renderSuggestions(blockID) } - if(remove) { - await this.removeSuggestionsAndRender(blockID) - } }); await Promise.all(blockPromises); } public async suggestAndRender(blockID: string) { - if(!this.documentEnabled) { return } await this.suggestForBlock(blockID) await this.renderSuggestions(blockID) } public async suggestForBlock(blockID: string) { - let suggestions: Suggestion[] - const text = ProtyleHelpers.fastGetBlockText(blockID) - if(text == null || !this.documentEnabled) { + if(!(blockID in this.blockStorage)) { return } - if(!(blockID in this.blockStorage)) { - await this.discoverBlocks(blockID) - return this.suggestForBlock(blockID) + const thisBlock = this.blockStorage[blockID] + thisBlock.suggestions = [] // we change from null so that it doesn't run again in forAllBlocksSuggest if we're waiting for the spell checker + + let suggestions: Suggestion[] + const text = thisBlock.protyle.fastGetBlockText(blockID) + if(text == null || text == '') { + return } if(this.plugin.settingsUtil.get('offline')) { - suggestions = await this.plugin.offlineSpellChecker.check(text, [this.documentLanguage]) + suggestions = await this.plugin.offlineSpellChecker.check(text, [thisBlock.language]) + thisBlock.suggestions = suggestions }else{ try { - suggestions = await this.plugin.onlineSpellChecker.check(text, [this.documentLanguage]) + suggestions = await this.plugin.onlineSpellChecker.check(text, [thisBlock.language]) + thisBlock.suggestions = suggestions }catch (_) { showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error') + thisBlock.suggestions = null } } - this.blockStorage[blockID].suggestions = suggestions - } public async removeSuggestionsAndRender(blockID: string) { @@ -109,19 +114,35 @@ export class SuggestionEngine { } public async renderSuggestions(blockID: string) { - if(!(blockID in this.blockStorage) || !this.documentEnabled) { + + if(!(blockID in this.blockStorage)) { return } - this.blockStorage[blockID].spellChecker.clearUnderlines() - this.blockStorage[blockID].suggestions.forEach(suggestion => { - if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion, blockID), this.plugin.settingsUtil)) { - this.blockStorage[blockID].spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length) + const thisBlock = this.blockStorage[blockID] + if(!document.contains(thisBlock.protyle.toNode())) { + delete this.blockStorage[blockID] + return + } + + thisBlock.spellChecker.clearUnderlines() + + thisBlock.suggestions?.forEach(suggestion => { + if(!Settings.isInCustomDictionary(this.suggestionToWrongText(suggestion, blockID), this.plugin.settingsUtil)) { + try { + thisBlock.spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length) + }catch (_) { + delete this.blockStorage[blockID] + } } }) + } - static suggestionToWrongText(suggestion: Suggestion, blockID: string): string { - const blockTxt = ProtyleHelpers.fastGetBlockText(blockID) + public suggestionToWrongText(suggestion: Suggestion, blockID: string): string { + if(!(blockID in this.blockStorage)) { + return + } + const blockTxt = this.blockStorage[blockID].protyle.fastGetBlockText(blockID) return blockTxt.slice(suggestion.offset, suggestion.offset + suggestion.length) } @@ -166,7 +187,7 @@ export class SuggestionEngine { console.log("dbg " + blockID + ' ' + suggestionNumber + ' ' + correctionNumber) console.log(this.blockStorage) const suggestion = this.blockStorage[blockID].suggestions[suggestionNumber] - const rich = ProtyleHelpers.fastGetBlockHTML(blockID) + const rich = new ProtyleHelper().fastGetBlockHTML(blockID) const fixedOffset = this.adjustIndexForTags(rich, suggestion.offset) const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber] + rich.slice(fixedOffset + suggestion.length)