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 dist
build build
tmp tmp
.lingma

View file

@ -1,5 +1,5 @@
import {Plugin, showMessage} from 'siyuan'; import {IProtyle, Plugin, showMessage} from 'siyuan';
import {ProtyleHelpers} from "@/protyleHelpers"; import {ProtyleHelper} from "@/protyleHelper";
import {Icons} from "@/icons"; import {Icons} from "@/icons";
import {Settings} from "@/settings"; import {Settings} from "@/settings";
import {SettingUtils} from "@/libs/setting-utils"; import {SettingUtils} from "@/libs/setting-utils";
@ -15,6 +15,7 @@ import {Language} from "@/spellChecker";
export default class SpellCheckPlugin extends Plugin { export default class SpellCheckPlugin extends Plugin {
private menus: Menus private menus: Menus
private currentlyEditing: { protyle: ProtyleHelper, enabled: boolean, language: string };
public settingsUtil: SettingUtils; public settingsUtil: SettingUtils;
public suggestions: SuggestionEngine public suggestions: SuggestionEngine
@ -62,30 +63,34 @@ export default class SpellCheckPlugin extends Plugin {
}` }`
window.document.head.appendChild(style); window.document.head.appendChild(style);
if(window.siyuan.config.editor.spellcheck) { if (window.siyuan.config.editor.spellcheck) {
showMessage(this.i18nx.errors.builtInEnabled, -1, 'error') showMessage(this.i18nx.errors.builtInEnabled, -1, 'error')
} }
this.eventBus.on('ws-main', async (event) => { 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 operation = event.detail.data[0].doOperations[0]
const action = operation.action const action = operation.action
const blockID = operation.id 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) 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) => { this.eventBus.on('open-menu-content', async (event) => {
if(!this.suggestions.documentEnabled) { return }
void this.analytics.sendEvent('menu-open-any'); 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) const suggNo = this.suggestions.getSuggestionNumber(blockID, event.detail.range)
this.menus.addCorrectionsToParagraphMenu(blockID, suggNo, event.detail.menu) 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) => { 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) void this.menus.addSettingsToDocMenu(docID, event.detail.menu)
}) })
this.eventBus.on('switch-protyle', async (event) => { 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 this.eventBus.on('loaded-protyle-static', async (event) => {
const settings = await ProtyleHelpers.getDocumentSettings(docID, this.settingsUtil.get('enabledByDefault'), this.settingsUtil.get('defaultLanguage')) await this.protyleLoad(event)
})
this.suggestions.documentID = docID this.eventBus.on('loaded-protyle-dynamic', async (event) => {
this.suggestions.documentEnabled = settings.enabled await this.protyleLoad(event)
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)
}) })
} }
private reRenderSuggestions() { private async protyleLoad(event: CustomEvent<{ protyle: IProtyle; }>) {
void this.suggestions.forAllBlocksSuggest(this.suggestions.documentID, false, true, false)
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() { onunload() {

View file

@ -2,8 +2,7 @@ import {Menu, showMessage, subMenu} from 'siyuan';
import SpellCheckPlugin from "@/index"; import SpellCheckPlugin from "@/index";
import {getBlockAttrs, setBlockAttrs} from "@/api"; import {getBlockAttrs, setBlockAttrs} from "@/api";
import {Settings} from "@/settings"; import {Settings} from "@/settings";
import {ProtyleHelpers} from "@/protyleHelpers"; import {ProtyleHelper} from "@/protyleHelper";
import {SuggestionEngine} from "@/suggestions";
export class Menus { export class Menus {
@ -37,7 +36,7 @@ export class Menus {
label: this.plugin.i18nx.textMenu.addToDictionary, label: this.plugin.i18nx.textMenu.addToDictionary,
click: async () => { click: async () => {
void this.plugin.analytics.sendEvent('menu-click-add-to-dictionary'); 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) await Settings.addToDictionary(word, this.plugin.settingsUtil)
showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info') showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info')
await this.plugin.suggestions.renderSuggestions(blockID) await this.plugin.suggestions.renderSuggestions(blockID)
@ -72,7 +71,7 @@ export class Menus {
icon: 'info', icon: 'info',
label: this.plugin.i18nx.docMenu.documentStatus, label: this.plugin.i18nx.docMenu.documentStatus,
click: async () => { 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) { if(settings == null) {
void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); void this.plugin.analytics.sendEvent('docmenu-click-info-notebook');
showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info')
@ -93,7 +92,7 @@ export class Menus {
click: async () => { click: async () => {
void this.plugin.analytics.sendEvent('docmenu-click-toggle'); void this.plugin.analytics.sendEvent('docmenu-click-toggle');
const attrs = await getBlockAttrs(docID) 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) { if(settings == null) {
void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); void this.plugin.analytics.sendEvent('docmenu-click-info-notebook');
showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info')

View file

@ -1,15 +1,21 @@
import {getBlockAttrs} from "@/api"; import {getBlockAttrs} from "@/api";
import SpellCheckPlugin from "@/index"; 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. // We must try to keep the dependency to the HTML to a minimum.
// doesn't use kernel API, so it's faster // doesn't use kernel API, so it's faster
public static fastGetBlockElement(blockID: string): Element { public fastGetBlockElement(blockID: string): Element {
const wrapper = Array.from( const wrapper = Array.from(
document.querySelectorAll(`div[data-node-id="${blockID}"]`) this.contentElement.querySelectorAll(`div[data-node-id="${blockID}"]`)
).find(el => ).find(el =>
!el.closest('.protyle-wysiwyg__embed') // true = not inside that class !el.closest('.protyle-wysiwyg__embed') // true = not inside that class
); );
@ -17,16 +23,16 @@ export class ProtyleHelpers {
return wrapper?.querySelector(':scope > [contenteditable="true"]') ?? null; return wrapper?.querySelector(':scope > [contenteditable="true"]') ?? null;
} }
public static fastGetBlockHTML(blockID: string): string { public fastGetBlockHTML(blockID: string): string {
return this.fastGetBlockElement(blockID).innerHTML return this.fastGetBlockElement(blockID).innerHTML
} }
public static fastGetBlockText(blockID: string): string { public fastGetBlockText(blockID: string): string {
return this.fastGetBlockElement(blockID)?.textContent return this.fastGetBlockElement(blockID)?.textContent
} }
public static fastGetTitleElement(docID: string) { public fastGetTitleElement() {
const container = document.querySelector(`div.protyle-title.protyle-wysiwyg--attr[data-node-id="${docID}"]`); const container = this.contentElement.querySelector(`div.protyle-title.protyle-wysiwyg--attr`); // [data-node-id="${docID}"]
if (!container) return null; if (!container) return null;
return container.querySelector('div.protyle-title__input[contenteditable="true"]'); return container.querySelector('div.protyle-title__input[contenteditable="true"]');
} }
@ -57,9 +63,16 @@ export class ProtyleHelpers {
} }
} }
public static isProtyleReady(docID: string): boolean { public getBlockElements(): Element[] {
const protyleTitleContainer = document.querySelector(`div[class="protyle-title protyle-wysiwyg--attr"]`) const allElements: Set<Element> = new Set();
return protyleTitleContainer.getAttribute('data-node-id') == docID 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 { export class SpellCheckerUI {
private readonly blockID: string; private readonly blockID: string;
private readonly docID: string; private readonly protyle: ProtyleHelper;
private block: HTMLElement; private block: HTMLElement;
private overlay: HTMLElement; private overlay: HTMLElement;
constructor(blockID: string, docID: string) { constructor(blockID: string, protyle: ProtyleHelper) {
this.blockID = blockID; this.blockID = blockID;
this.docID = docID; this.protyle = protyle;
this.setBlock() this.setBlock()
} }
private setBlock() { private setBlock() {
this.block = <HTMLElement>ProtyleHelpers.fastGetBlockElement(this.blockID) this.block = <HTMLElement>this.protyle.fastGetBlockElement(this.blockID)
let overlay = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID) if(this.block == null) {
throw new Error(`Block ${this.blockID} not found`);
}
let overlay = <HTMLElement>ProtyleHelper.fastGetOverlayElement(this.blockID)
if(overlay == null) { if(overlay == null) {
this.overlay = document.createElement('div') this.overlay = document.createElement('div')
this.overlay.className = 'underline-overlay'; this.overlay.className = 'underline-overlay';
this.overlay.setAttribute('for-block-id', this.blockID) this.overlay.setAttribute('for-block-id', this.blockID)
const protyleTitle = ProtyleHelpers.fastGetTitleElement(this.docID) const protyleTitle = this.protyle.fastGetTitleElement()
protyleTitle?.append(this.overlay) protyleTitle?.append(this.overlay)
}else{ }else{
if(this.overlay == null) { if(this.overlay == null) {
@ -144,7 +147,7 @@ export class SpellCheckerUI {
} }
public destroy() { public destroy() {
let overlay = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID) let overlay = <HTMLElement>ProtyleHelper.fastGetOverlayElement(this.blockID)
overlay?.remove(); overlay?.remove();
} }

View file

@ -1,6 +1,6 @@
import {ProtyleHelpers} from "@/protyleHelpers"; import {ProtyleHelper} from "@/protyleHelper";
import {Settings} from "@/settings"; import {Settings} from "@/settings";
import {getChildBlocks, updateBlock} from "@/api"; import {updateBlock} from "@/api";
import {SpellCheckerUI} from "@/spellCheckerUI"; import {SpellCheckerUI} from "@/spellCheckerUI";
import {showMessage} from "siyuan"; import {showMessage} from "siyuan";
import SpellCheckPlugin from "@/index"; import SpellCheckPlugin from "@/index";
@ -8,7 +8,9 @@ import {Suggestion} from "@/spellChecker";
interface StoredBlock { interface StoredBlock {
spellChecker: SpellCheckerUI; spellChecker: SpellCheckerUI;
suggestions: Suggestion[]; language: string;
suggestions: Suggestion[] | null;
protyle: ProtyleHelper;
} }
type BlockStorage = Record<string, StoredBlock>; type BlockStorage = Record<string, StoredBlock>;
@ -18,10 +20,6 @@ export class SuggestionEngine {
private blockStorage: BlockStorage = {}; private blockStorage: BlockStorage = {};
private plugin: SpellCheckPlugin; private plugin: SpellCheckPlugin;
public documentID: string;
public documentEnabled: boolean = false;
public documentLanguage: string = 'auto';
constructor(plugin: SpellCheckPlugin) { constructor(plugin: SpellCheckPlugin) {
this.plugin = plugin this.plugin = plugin
} }
@ -36,72 +34,79 @@ export class SuggestionEngine {
} }
} }
private async discoverBlocks(blockID: string) { public getProtyle(blockID: string) {
const children = await getChildBlocks(blockID) if(!(blockID in this.blockStorage)) { return null }
if(children.length == 0) { return this.blockStorage[blockID].protyle
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 async forAllBlocksSuggest(docID: string, suggest: boolean, render: boolean, remove: boolean) { public async storeBlocks(protyle: ProtyleHelper, documentLanguage: string) {
if(!this.documentEnabled) { return } const blocks = protyle.getBlockElements()
if(suggest) { blocks.forEach(block => {
await this.discoverBlocks(docID) // updates this.blockStorage 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) => { 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) await this.suggestForBlock(blockID)
} }
if(render) { if(render) {
await this.renderSuggestions(blockID) await this.renderSuggestions(blockID)
} }
if(remove) {
await this.removeSuggestionsAndRender(blockID)
}
}); });
await Promise.all(blockPromises); await Promise.all(blockPromises);
} }
public async suggestAndRender(blockID: string) { public async suggestAndRender(blockID: string) {
if(!this.documentEnabled) { return }
await this.suggestForBlock(blockID) await this.suggestForBlock(blockID)
await this.renderSuggestions(blockID) await this.renderSuggestions(blockID)
} }
public async suggestForBlock(blockID: string) { public async suggestForBlock(blockID: string) {
let suggestions: Suggestion[] if(!(blockID in this.blockStorage)) {
const text = ProtyleHelpers.fastGetBlockText(blockID)
if(text == null || !this.documentEnabled) {
return return
} }
if(!(blockID in this.blockStorage)) { const thisBlock = this.blockStorage[blockID]
await this.discoverBlocks(blockID) thisBlock.suggestions = [] // we change from null so that it doesn't run again in forAllBlocksSuggest if we're waiting for the spell checker
return this.suggestForBlock(blockID)
let suggestions: Suggestion[]
const text = thisBlock.protyle.fastGetBlockText(blockID)
if(text == null || text == '') {
return
} }
if(this.plugin.settingsUtil.get('offline')) { 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{ }else{
try { try {
suggestions = await this.plugin.onlineSpellChecker.check(text, [this.documentLanguage]) suggestions = await this.plugin.onlineSpellChecker.check(text, [thisBlock.language])
thisBlock.suggestions = suggestions
}catch (_) { }catch (_) {
showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error') showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error')
thisBlock.suggestions = null
} }
} }
this.blockStorage[blockID].suggestions = suggestions
} }
public async removeSuggestionsAndRender(blockID: string) { public async removeSuggestionsAndRender(blockID: string) {
@ -109,19 +114,35 @@ export class SuggestionEngine {
} }
public async renderSuggestions(blockID: string) { public async renderSuggestions(blockID: string) {
if(!(blockID in this.blockStorage) || !this.documentEnabled) {
if(!(blockID in this.blockStorage)) {
return return
} }
this.blockStorage[blockID].spellChecker.clearUnderlines() const thisBlock = this.blockStorage[blockID]
this.blockStorage[blockID].suggestions.forEach(suggestion => { if(!document.contains(thisBlock.protyle.toNode())) {
if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion, blockID), this.plugin.settingsUtil)) { delete this.blockStorage[blockID]
this.blockStorage[blockID].spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length) 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 { public suggestionToWrongText(suggestion: Suggestion, blockID: string): string {
const blockTxt = ProtyleHelpers.fastGetBlockText(blockID) if(!(blockID in this.blockStorage)) {
return
}
const blockTxt = this.blockStorage[blockID].protyle.fastGetBlockText(blockID)
return blockTxt.slice(suggestion.offset, suggestion.offset + suggestion.length) return blockTxt.slice(suggestion.offset, suggestion.offset + suggestion.length)
} }
@ -166,7 +187,7 @@ export class SuggestionEngine {
console.log("dbg " + blockID + ' ' + suggestionNumber + ' ' + correctionNumber) console.log("dbg " + blockID + ' ' + suggestionNumber + ' ' + correctionNumber)
console.log(this.blockStorage) console.log(this.blockStorage)
const suggestion = this.blockStorage[blockID].suggestions[suggestionNumber] 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 fixedOffset = this.adjustIndexForTags(rich, suggestion.offset)
const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber] + rich.slice(fixedOffset + suggestion.length) const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber] + rich.slice(fixedOffset + suggestion.length)