Smarter document loading
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m5s

This commit is contained in:
MassiveBox 2025-10-10 11:25:34 +02:00
parent 095e2a2bb9
commit 631cfd14bf
Signed by: massivebox
GPG key ID: 9B74D3A59181947D
6 changed files with 155 additions and 98 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ dev
dist
build
tmp
.lingma

View file

@ -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
@ -68,24 +69,28 @@ export default class SpellCheckPlugin extends Plugin {
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() {

View file

@ -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')

View file

@ -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<Element> = new Set();
this.contentElement.querySelectorAll('[data-node-id]').forEach(el => {
allElements.add(el);
});
return Array.from(allElements);
}
public toNode(): Node {
return this.contentElement
}
}

View file

@ -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 = <HTMLElement>ProtyleHelpers.fastGetBlockElement(this.blockID)
let overlay = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID)
this.block = <HTMLElement>this.protyle.fastGetBlockElement(this.blockID)
if(this.block == null) {
throw new Error(`Block ${this.blockID} not found`);
}
let overlay = <HTMLElement>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 = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID)
let overlay = <HTMLElement>ProtyleHelper.fastGetOverlayElement(this.blockID)
overlay?.remove();
}

View file

@ -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<string, StoredBlock>;
@ -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
}
static suggestionToWrongText(suggestion: Suggestion, blockID: string): string {
const blockTxt = ProtyleHelpers.fastGetBlockText(blockID)
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]
}
}
})
}
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)