First commit
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 28s

This commit is contained in:
MassiveBox 2025-09-19 21:18:06 +02:00
parent 04f54e248a
commit c69eaca7e9
Signed by: massivebox
GPG key ID: 9B74D3A59181947D
21 changed files with 1147 additions and 1805 deletions

47
src/analytics.ts Normal file
View file

@ -0,0 +1,47 @@
import {getBackend, getFrontend} from "siyuan";
import packageJson from '../package.json' assert { type: 'json' };
export class Analytics {
private readonly enabled: boolean;
private static readonly ENDPOINT = 'https://stats.massive.box/api/send_noua';
private static readonly WEBSITE_ID = '6963975c-c7e7-495f-a4f0-fa1a0d3e64ac';
constructor(enabled: boolean) {
this.enabled = enabled;
}
async sendEvent(name: string, addData?: Record<string, string>) {
if(!this.enabled) return;
const sendData: Record<string, string> = (name == 'load' || name == 'install') ?
{
'appVersion': window.navigator.userAgent.split(' ')[0],
'pluginVersion': packageJson.version,
'frontend': getFrontend(),
'backend': getBackend(),
'language': navigator.language,
'appLanguage': window.siyuan.config.lang,
...addData
} : { ...addData };
await fetch(Analytics.ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'event',
payload: {
website: Analytics.WEBSITE_ID,
name: name,
data: sendData,
},
})
})
}
}

View file

@ -1,63 +0,0 @@
<!--
Copyright (c) 2024 by frostime. All Rights Reserved.
Author : frostime
Date : 2023-11-19 12:30:45
FilePath : /src/hello.svelte
LastEditTime : 2024-10-16 14:37:50
Description :
-->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
// import { version } from "@/api";
import { showMessage, fetchPost, Protyle } from "siyuan";
export let app;
export let blockID: string;
let time: string = "";
let divProtyle: HTMLDivElement;
let protyle: any;
onMount(async () => {
// ver = await version();
fetchPost("/api/system/currentTime", {}, (response) => {
time = new Date(response.data).toString();
});
if (blockID) {
protyle = await initProtyle();
} else {
divProtyle.innerHTML = "Please open a document first";
}
});
onDestroy(() => {
showMessage("Hello panel closed");
protyle?.destroy();
});
async function initProtyle() {
return new Protyle(app, divProtyle, {
blockId: blockID
});
}
</script>
<div class="b3-dialog__content">
<div>appId:</div>
<div class="fn__hr"></div>
<div class="plugin-sample__time">${app?.appId}</div>
<div class="fn__hr"></div>
<div class="fn__hr"></div>
<div>API demo:</div>
<div class="fn__hr" />
<div class="plugin-sample__time">
System current time: <span id="time">{time}</span>
</div>
<div class="fn__hr" />
<div class="fn__hr" />
<div>Protyle demo: id = {blockID}</div>
<div class="fn__hr" />
<div id="protyle" style="height: 360px;" bind:this={divProtyle}/>
</div>

43
src/icons.ts Normal file
View file

@ -0,0 +1,43 @@
import {Plugin} from 'siyuan';
export class Icons {
constructor(p: Plugin) {
this.icons.forEach(icon =>
p.addIcons(icon)
)
}
private icons = [
// info - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:info
`<symbol id="info" viewBox="0 -960 960 960">
<path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
</symbol>`,
// spell check - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:spellcheck
`<symbol id="spellcheck" viewBox="0 -960 960 960">
<path d="M564-80 394-250l56-56 114 114 226-226 56 56L564-80ZM120-320l194-520h94l194 520h-92l-46-132H254l-46 132h-88Zm162-208h156l-76-216h-4l-76 216Z"/>
</symbol>`,
// language - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:language_chinese_quick
`<symbol id="language" viewBox="0 -960 960 960">
<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/>
</symbol>`,
// toggle - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:toggle_on
`<symbol id="toggle" viewBox="0 -960 960 960">
<path d="M280-240q-100 0-170-70T40-480q0-100 70-170t170-70h400q100 0 170 70t70 170q0 100-70 170t-170 70H280Zm0-80h400q66 0 113-47t47-113q0-66-47-113t-113-47H280q-66 0-113 47t-47 113q0 66 47 113t113 47Zm400-40q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM480-480Z"/>
</symbol>`,
// autodetect - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:network_intelligence
`<symbol id="autodetect" viewBox="0 -960 960 960">
<path d="M346-212q-8 0-15-3.5T320-227l-68-123h48l36 68h77v-28h-60l-36-68h-81l-49-87q-2-4-3-7.5t-1-7.5q0-2 4-15l49-87h81l36-68h60v-28h-77l-36 68h-48l68-123q4-8 11-11.5t15-3.5h90q13 0 21.5 8.5T466-718v140h-61l-28 28h89v106h-76l-34-67h-69l-28 28h80l34 67h93v174q0 13-8.5 21.5T436-212h-90Zm178 0q-13 0-21.5-8.5T494-242v-174h93l34-67h80l-28-28h-69l-35 67h-75v-106h89l-28-28h-61v-140q0-13 8.5-21.5T524-748h90q8 0 15 3.5t11 11.5l68 123h-48l-36-68h-77v28h60l35 68h82l49 87q2 4 3 7.5t1 7.5q0 2-4 15l-49 87h-82l-35 68h-60v28h77l36-68h48l-68 123q-4 8-11 11.5t-15 3.5h-90Z"/>
</symbol>`,
// error - https://fonts.google.com/icons?selected=Material+Symbols+Outlined
`<symbol id="error" viewBox="0 -960 960 960">
<path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
</symbol>`,
// add - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:add
`<symbol id="add" viewBox="0 -960 960 960">
<path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
</symbol>`
]
}

View file

File diff suppressed because it is too large Load diff

80
src/languagetool.ts Normal file
View file

@ -0,0 +1,80 @@
import {PluginSettings} from "@/settings";
export type Suggestion = {
message: string
shortMessage: string
replacements: Array<{
value: string
type: string
}>
offset: number
length: number
context: {
text: string
offset: number
length: number
}
sentence: string
type: {
typeName: string
}
rule: {
id: string
description: string
issueType: string
category: {
id: string
name: string
}
isPremium: boolean
confidence: number
}
ignoreForIncompleteSentence: boolean
contextForSureMatch: number
}
export type Language = { name: string; code: string; longCode: string; }
interface HTTPError extends Error {
status?: number;
}
export class LanguageTool {
public static async check(text: string, language: string, settings: PluginSettings): Promise<Suggestion[]> {
const body = new URLSearchParams({
text: text,
language: language,
level: settings.picky ? 'picky' : 'default',
motherTongue: settings.motherTongue == '' ? window.navigator.language : settings.motherTongue,
});
if(settings.username != '') {
body.append('username', settings.username);
}
if(settings.apiKey) {
body.append('apiKey', settings.apiKey);
}
if(language == 'auto') {
body.append('preferredVariants', settings.preferredVariants)
}
const res = await fetch(settings.server + 'v2/check', {method: 'POST', body});
if(res.status != 200) {
const err = new Error('Network error') as HTTPError
err.status = res.status;
throw err
}
const json = await res.json();
return json.matches;
}
public static async getLanguages(settings: PluginSettings): Promise<Language[]> {
const res = await fetch(settings.server + 'v2/languages', {method: 'GET'});
return await res.json();
}
}

151
src/menus.ts Normal file
View file

@ -0,0 +1,151 @@
import {Menu, showMessage, subMenu} from 'siyuan';
import SpellCheckPlugin from "@/index";
import {getBlockAttrs, setBlockAttrs} from "@/api";
import {LanguageTool} from "@/languagetool";
import {PluginSettings, Settings} from "@/settings";
import {ProtyleHelpers} from "@/protyleHelpers";
import {SuggestionEngine} from "@/suggestions";
export class Menus {
private plugin: SpellCheckPlugin
public constructor(plugin: SpellCheckPlugin) {
this.plugin = plugin
}
public addCorrectionsToParagraphMenu(blockID: string, suggestionNumber: number, menu: subMenu) {
const storedBlock = this.plugin.suggestions.getStorage()[blockID]
if (suggestionNumber == -1) {
return
}
void this.plugin.analytics.sendEvent('menu-open-wrong');
let suggestion = storedBlock.suggestions[suggestionNumber]
menu.addItem({
icon: 'info',
label: suggestion.shortMessage == '' ? suggestion.message : suggestion.shortMessage,
click: async () => {
showMessage(suggestion.message, 5000, 'info')
void this.plugin.analytics.sendEvent('menu-click-info');
}
})
if(suggestion.type.typeName == 'UnknownWord') {
// add to dictionary
menu.addItem({
icon: 'add',
label: this.plugin.i18nx.textMenu.addToDictionary,
click: async () => {
void this.plugin.analytics.sendEvent('menu-click-add-to-dictionary');
const word = SuggestionEngine.suggestionToWrongText(suggestion)
await Settings.addToDictionary(word, this.plugin.settingsUtil)
showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info')
await this.plugin.suggestions.renderSuggestions(blockID)
}
})
}
// corrections
suggestion.replacements.forEach((replacement, correctionNumber) => {
menu.addItem({
icon: 'spellcheck',
label: replacement.value,
click: async () => {
void this.plugin.analytics.sendEvent('menu-click-correct', {
'type': suggestion.rule.category.id
});
if(this.plugin.settingsUtil.get('experimentalCorrect')) {
void this.plugin.suggestions.correctSuggestion(blockID, suggestionNumber, correctionNumber)
}else{
void navigator.clipboard.writeText(replacement.value)
showMessage(this.plugin.i18nx.errors.correctionNotEnabled, 5000, 'info')
}
}
})
})
}
public async addSettingsToDocMenu(docID: string, menu: subMenu) {
menu.addItem({
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'))
if(settings == null) {
void this.plugin.analytics.sendEvent('docmenu-click-info-notebook');
showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info')
return
}
showMessage(`
<b>${this.plugin.i18nx.docMenu.documentStatus}</b><br />
${this.plugin.i18nx.docMenu.status}: ${settings.enabled ? this.plugin.i18nx.docMenu.enabled : this.plugin.i18nx.docMenu.disabled}<br />
${this.plugin.i18nx.docMenu.language}: ${settings.language}
`, 5000, 'info')
void this.plugin.analytics.sendEvent('docmenu-click-info');
}
})
menu.addItem({
icon: 'toggle',
label: this.plugin.i18nx.docMenu.toggleSpellCheck,
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'))
if(settings == null) {
void this.plugin.analytics.sendEvent('docmenu-click-info-notebook');
showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info')
return
}
attrs[SpellCheckPlugin.ENABLED_ATTR] = settings.enabled ? 'false' : 'true'
await setBlockAttrs(docID, attrs)
location.reload()
}
})
menu.addItem({
icon: 'language',
label: this.plugin.i18nx.docMenu.setDocumentLanguage,
click: async (_, ev: MouseEvent) => {
void this.plugin.analytics.sendEvent('docmenu-click-setlang-1');
const languages = await LanguageTool.getLanguages(<PluginSettings>this.plugin.settingsUtil.dump())
const langMenu = new Menu('spellCheckLangMenu');
langMenu.addItem({
icon: 'autodetect',
label: this.plugin.i18nx.docMenu.autodetectLanguage,
click: async () => {
const attrs = await getBlockAttrs(docID)
attrs[SpellCheckPlugin.LANGUAGE_ATTR] = 'auto'
await setBlockAttrs(docID, attrs)
void this.plugin.analytics.sendEvent('docmenu-click-setlang-2', {
'language': 'auto'
});
location.reload()
}
});
languages.forEach(language => {
langMenu.addItem({
icon: 'language',
label: language.name + ' [' + language.longCode + ']',
click: async () => {
const attrs = await getBlockAttrs(docID)
attrs[SpellCheckPlugin.LANGUAGE_ATTR] = language.longCode
await setBlockAttrs(docID, attrs)
void this.plugin.analytics.sendEvent('docmenu-click-setlang-2', {
'language': language.longCode
});
location.reload()
}
});
});
langMenu.open({ x: ev.clientX, y: ev.clientY });
}
})
}
}

65
src/protyleHelpers.ts Normal file
View file

@ -0,0 +1,65 @@
import {getBlockAttrs} from "@/api";
import SpellCheckPlugin from "@/index";
export class ProtyleHelpers {
// We shouldn't use JavaScript elements to get and set 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 {
const wrapper = Array.from(
document.querySelectorAll(`div[data-node-id="${blockID}"]`)
).find(el =>
!el.closest('.protyle-wysiwyg__embed') // true = not inside that class
);
return wrapper?.querySelector(':scope > [contenteditable="true"]') ?? null;
}
public static fastGetBlockHTML(blockID: string): string {
return this.fastGetBlockElement(blockID).innerHTML
}
public static 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}"]`);
if (!container) return null;
return container.querySelector('div.protyle-title__input[contenteditable="true"]');
}
public static fastGetOverlayElement(blockID: string): Element {
return document.querySelector(`div.underline-overlay[for-block-id="${blockID}"]`)
}
// given an element such as a span inside a block, return its blockID
public static getNodeId(el: Element) {
let i = 0;
while (el && i < 50) {
if (el.hasAttribute('data-node-id')) {
return el.getAttribute('data-node-id');
}
el = el.parentElement;
i++;
}
return null;
}
public static async getDocumentSettings(docID: string, enabledByDefault: boolean, defaultLanguage: string): Promise<{enabled: boolean, language: string} | null> {
const attrs = await getBlockAttrs(docID)
if(attrs == null) { return null }
return {
enabled: (SpellCheckPlugin.ENABLED_ATTR in attrs) ? attrs[SpellCheckPlugin.ENABLED_ATTR] == 'true' : enabledByDefault,
language: (SpellCheckPlugin.LANGUAGE_ATTR in attrs) ? attrs[SpellCheckPlugin.LANGUAGE_ATTR] : defaultLanguage
}
}
public static isProtyleReady(docID: string): boolean {
const protyleTitleContainer = document.querySelector(`div[class="protyle-title protyle-wysiwyg--attr"]`)
return protyleTitleContainer.getAttribute('data-node-id') == docID
}
}

View file

@ -1,139 +0,0 @@
<script lang="ts">
import { showMessage } from "siyuan";
import SettingPanel from "./libs/components/setting-panel.svelte";
let groups: string[] = ["🌈 Group 1", "✨ Group 2"];
let focusGroup = groups[0];
const group1Items: ISettingItem[] = [
{
type: 'checkbox',
title: 'checkbox',
description: 'checkbox',
key: 'a',
value: true
},
{
type: 'textinput',
title: 'text',
description: 'This is a text',
key: 'b',
value: 'This is a text',
placeholder: 'placeholder'
},
{
type: 'textarea',
title: 'textarea',
description: 'This is a textarea',
key: 'b2',
value: 'This is a textarea',
placeholder: 'placeholder',
direction: 'row'
},
{
type: 'select',
title: 'select',
description: 'select',
key: 'c',
value: 'x',
options: {
x: 'x',
y: 'y',
z: 'z'
}
}
];
const group2Items: ISettingItem[] = [
{
type: 'button',
title: 'button',
description: 'This is a button',
key: 'e',
value: 'Click Button',
button: {
label: 'Click Me',
callback: () => {
showMessage('Hello, world!');
}
}
},
{
type: 'slider',
title: 'slider',
description: 'slider',
key: 'd',
value: 50,
slider: {
min: 0,
max: 100,
step: 1
}
}
];
/********** Events **********/
interface ChangeEvent {
group: string;
key: string;
value: any;
}
const onChanged = ({ detail }: CustomEvent<ChangeEvent>) => {
if (detail.group === groups[0]) {
// setting.set(detail.key, detail.value);
//Please add your code here
//Udpate the plugins setting data, don't forget to call plugin.save() for data persistence
}
};
</script>
<div class="fn__flex-1 fn__flex config__panel">
<ul class="b3-tab-bar b3-list b3-list--background">
{#each groups as group}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<li
data-name="editor"
class:b3-list-item--focus={group === focusGroup}
class="b3-list-item"
on:click={() => {
focusGroup = group;
}}
on:keydown={() => {}}
>
<span class="b3-list-item__text">{group}</span>
</li>
{/each}
</ul>
<div class="config__tab-wrap">
<SettingPanel
group={groups[0]}
settingItems={group1Items}
display={focusGroup === groups[0]}
on:changed={onChanged}
on:click={({ detail }) => { console.debug("Click:", detail.key); }}
>
<div class="fn__flex b3-label">
💡 This is our default settings.
</div>
</SettingPanel>
<SettingPanel
group={groups[1]}
settingItems={group2Items}
display={focusGroup === groups[1]}
on:changed={onChanged}
on:click={({ detail }) => { console.debug("Click:", detail.key); }}
>
</SettingPanel>
</div>
</div>
<style lang="scss">
.config__panel {
height: 100%;
}
.config__panel > ul > li {
padding-left: 1rem;
}
</style>

159
src/settings.ts Normal file
View file

@ -0,0 +1,159 @@
import {SettingUtils} from "@/libs/setting-utils";
import {showMessage} from 'siyuan';
import {LanguageTool} from "@/languagetool";
import SpellCheckPlugin from "@/index";
export type PluginSettings = {
server: string
username: string
apiKey: string
picky: boolean
motherTongue: string
preferredVariants: string
enabledByDefault: boolean
defaultLanguage: string
preferredLanguages: string
analytics: boolean
}
export class Settings {
static async init(plugin: SpellCheckPlugin): Promise<SettingUtils> {
const to = plugin.i18nx.settings
const su = new SettingUtils({
plugin: plugin, name: plugin.name
});
su.addItem({
type: 'hint',
key: 'info',
title: to.info.title,
description: to.info.description,
value: ''
})
su.addItem({
type: 'checkbox',
key: 'experimentalCorrect',
title: to.experimentalCorrect.title,
description: to.experimentalCorrect.description,
value: false
})
su.addItem({
type: 'textarea',
key: 'customDictionary',
title: to.customDictionary.title,
description: to.customDictionary.description,
value: 'SySpell,SiYuan'
})
su.addItem({
type: 'textinput',
key: 'server',
title: to.server.title,
description: to.server.description,
value: 'https://lt.massive.box/'
})
await su.load() // needed to fetch languages from server
let languagesKV = {}
try {
let languages = await LanguageTool.getLanguages(<PluginSettings>su.dump())
languages.forEach(language => {
languagesKV[language.longCode] = language.name + ' [' + language.longCode + ']'
})
} catch {
showMessage(plugin.i18nx.errors.checkServer, -1, 'error')
showMessage(plugin.i18nx.errors.fatal, -1, 'error')
}
su.addItem({
type: 'textinput',
key: 'username',
title: to.username.title,
description: to.username.description,
value: ''
})
su.addItem({
type: 'textinput',
key: 'apiKey',
title: to.apiKey.title,
description: to.apiKey.description,
value: ''
})
su.addItem({
type: 'checkbox',
key: 'picky',
title: to.picky.title,
description: to.picky.description,
value: false
})
su.addItem({
type: 'select',
key: 'motherTongue',
title: to.motherTongue.title,
description: to.motherTongue.description,
value: (window.navigator.language in languagesKV) ? window.navigator.language : 'en-US',
options: languagesKV
})
su.addItem({
type: 'textinput',
key: 'preferredVariants',
title: to.preferredVariants.title,
description: to.preferredVariants.description,
value: 'en-US,de-DE'
})
su.addItem({
type: 'checkbox',
key: 'enabledByDefault',
title: to.enabledByDefault.title,
description: to.enabledByDefault.description,
value: true
})
languagesKV['auto'] = plugin.i18nx.docMenu.autodetectLanguage
su.addItem({
type: 'select',
key: 'defaultLanguage',
title: to.defaultLanguage.title,
description: to.defaultLanguage.description,
options: languagesKV,
value: 'auto'
})
su.addItem({
type: 'checkbox',
key: 'analytics',
title: to.analytics.title,
description: to.analytics.description,
value: true
})
await su.load()
return su
}
// dictionary is a string of words separated by commas
static isInCustomDictionary(word: string, settings: SettingUtils) {
const dictionary = settings.get('customDictionary').split(',')
return dictionary.includes(word)
}
static addToDictionary(word: string, settings: SettingUtils) {
const dictionary = settings.get('customDictionary').split(',')
if (!dictionary.includes(word)) {
dictionary.push(word)
return settings.setAndSave('customDictionary', dictionary.join(','))
}
}
}

151
src/spellchecker.ts Normal file
View file

@ -0,0 +1,151 @@
import {ProtyleHelpers} from "@/protyleHelpers";
export class SpellChecker {
private readonly blockID: string;
private readonly docID: string;
private block: HTMLElement;
private overlay: HTMLElement;
constructor(blockID: string, docID: string) {
this.blockID = blockID;
this.docID = docID;
this.setBlock()
}
private setBlock() {
this.block = <HTMLElement>ProtyleHelpers.fastGetBlockElement(this.blockID)
let overlay = <HTMLElement>ProtyleHelpers.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)
protyleTitle?.append(this.overlay)
}else{
if(this.overlay == null) {
this.overlay = overlay
}
}
}
public highlightCharacterRange(startIndex: number, endIndex: number) {
this.setBlock()
// Get all text content
const textContent = this.block?.innerText || '';
if (startIndex >= textContent.length || endIndex > textContent.length || startIndex >= endIndex) {
console.log('Invalid range');
return;
}
// Find the text nodes and character positions
const range = this.createRangeFromCharacterIndices(startIndex, endIndex);
if (range) {
this.createUnderlineFromRange(range, endIndex - startIndex);
}
}
private createRangeFromCharacterIndices(startIndex: number, endIndex: number) {
// Get the innerHTML and create a temporary container to parse it
const tempContainer = document.createElement('div');
tempContainer.innerHTML = this.block.innerHTML;
// Walk through all nodes (including text and elements) to build character map
const walker = document.createTreeWalker(
this.block,
NodeFilter.SHOW_TEXT,
null
);
let currentIndex = 0;
let startNode = null, startOffset = 0;
let endNode = null, endOffset = 0;
let textNode;
// Build a map of character positions to actual DOM text nodes
while (textNode = walker.nextNode()) {
const nodeLength = textNode.length;
const nodeEndIndex = currentIndex + nodeLength;
// Find start position
if (startNode === null && startIndex >= currentIndex && startIndex < nodeEndIndex) {
startNode = textNode;
startOffset = startIndex - currentIndex;
}
// Find end position
if (endIndex > currentIndex && endIndex <= nodeEndIndex) {
endNode = textNode;
endOffset = endIndex - currentIndex;
break;
}
currentIndex = nodeEndIndex;
}
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
return null;
}
private createUnderlineFromRange(range: Range, charsCount: number) {
const rects = range.getClientRects();
const editorRect = this.block.getBoundingClientRect();
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
const underline = document.createElement('div');
underline.className = 'error-underline';
const left = rect.left - editorRect.left + this.block.scrollLeft;
const top = rect.bottom - editorRect.top - 2 + this.block.scrollTop;
const width = rect.width;
const offset = SpellChecker.distance(this.overlay, this.block)
underline.style.left = (left + offset.h) + 'px';
underline.style.top = (top + 2 + offset.v) + 'px';
underline.style.width = width + 'px';
if(!SpellChecker.checkDontUnderline(width, charsCount)) {
this.overlay.appendChild(underline);
}
}
}
// if the underline is too wide for the number of characters that are underlined, we don't render it
// this is a consequence of using .innerText: things like <img> tags are only a character
private static checkDontUnderline(width: number, charsCount: number) {
const maxWidthPerChar = 16;
return width > maxWidthPerChar * charsCount
}
private static distance(elA: HTMLElement, elB: HTMLElement): {h: number, v: number} {
const rectA = elA.getBoundingClientRect();
const rectB = elB.getBoundingClientRect();
return {
h: Math.abs(rectA.left - rectB.left),
v: Math.abs(rectA.top - rectB.top)
}
}
public clearUnderlines() {
this.overlay.innerHTML = '';
}
public destroy() {
let overlay = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID)
overlay?.remove();
}
}

191
src/suggestions.ts Normal file
View file

@ -0,0 +1,191 @@
import {ProtyleHelpers} from "@/protyleHelpers";
import {LanguageTool, Suggestion} from "@/languagetool";
import {PluginSettings, Settings} from "@/settings";
import {getChildBlocks, updateBlock} from "@/api";
import {SpellChecker} from "@/spellchecker";
import {showMessage} from "siyuan";
import SpellCheckPlugin from "@/index";
interface StoredBlock {
spellChecker: SpellChecker;
suggestions: Suggestion[];
}
type BlockStorage = Record<string, StoredBlock>;
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
}
public getStorage(): BlockStorage {
return this.blockStorage
}
public clearStorage() {
for(let blockID in this.blockStorage) {
this.blockStorage[blockID].spellChecker.destroy()
delete this.blockStorage[blockID]
}
}
private async discoverBlocks(blockID: string) {
const children = await getChildBlocks(blockID)
if(children.length == 0) {
if(!(blockID in this.blockStorage)) {
const spellChecker = new SpellChecker(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) {
if(!this.documentEnabled) { return }
if(suggest) {
await this.discoverBlocks(docID) // updates this.blockStorage
}
const blockPromises = Object.keys(this.blockStorage).map(async (blockID) => {
if(suggest) {
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) {
return
}
if(!(blockID in this.blockStorage)) {
await this.discoverBlocks(blockID)
return this.suggestForBlock(blockID)
}
try {
suggestions = await LanguageTool.check(text, this.documentLanguage, <PluginSettings>this.plugin.settingsUtil.dump())
}catch (_) {
showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error')
}
this.blockStorage[blockID].suggestions = suggestions
}
public async removeSuggestionsAndRender(blockID: string) {
this.blockStorage[blockID].spellChecker.clearUnderlines()
}
public async renderSuggestions(blockID: string) {
if(!(blockID in this.blockStorage) || !this.documentEnabled) {
return
}
this.blockStorage[blockID].spellChecker.clearUnderlines()
this.blockStorage[blockID].suggestions.forEach(suggestion => {
if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion), this.plugin.settingsUtil)) {
this.blockStorage[blockID].spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length)
}
})
}
static suggestionToWrongText(suggestion: Suggestion): string {
return suggestion.context.text.slice(suggestion.context.offset, suggestion.context.offset + suggestion.context.length)
}
private getAbsoluteOffsetInBlock(range: Range, blockID: string): number {
const block = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? (range.commonAncestorContainer as Element).closest(`[data-node-id="${blockID}"]`)
: (range.commonAncestorContainer as Text).parentElement!.closest(`[data-node-id="${blockID}"]`);
if (!block) return -1;
const measureToRange = range.cloneRange();
measureToRange.setStart(block, 0);
measureToRange.setEnd(range.startContainer, range.startOffset);
return measureToRange.toString().length;
}
// given the content of a "wrong" span, get the suggestion number
public getSuggestionNumber(blockID: string, range: Range): number {
const offset = this.getAbsoluteOffsetInBlock(range, blockID)
let suggNo = -1
this.blockStorage[blockID].suggestions.forEach((suggestion, i) => {
if(offset >= suggestion.offset && offset <= suggestion.offset + suggestion.length) {
suggNo = i
}
})
return suggNo
}
// correct the error in the block
public async correctSuggestion(blockID: string, suggestionNumber: number, correctionNumber: number) {
if (suggestionNumber == -1) {
return
}
console.log("dbg " + blockID + ' ' + suggestionNumber + ' ' + correctionNumber)
console.log(this.blockStorage)
const suggestion = this.blockStorage[blockID].suggestions[suggestionNumber]
const rich = ProtyleHelpers.fastGetBlockHTML(blockID)
const fixedOffset = this.adjustIndexForTags(rich, suggestion.offset)
const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber].value + rich.slice(fixedOffset + suggestion.length)
console.log("new str " + newStr);
await updateBlock('markdown', window.Lute.New().BlockDOM2Md(newStr), blockID)
void this.suggestAndRender(blockID)
}
private adjustIndexForTags(html: string, plainIdx: number): number {
let plain = 0; // characters consumed in s1
let rich = 0; // characters consumed in s2
while (rich < html.length && plain < plainIdx) {
if (html[rich] === '<') {
// skip entire tag in s2
while (rich < html.length && html[rich] !== '>') rich++;
rich++; // include the '>'
} else {
// normal character: advance both counters
rich++;
plain++;
}
}
return rich; // index inside s2
}
}