generated from mirrors/plugin-sample-vite-svelte
First commit
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 28s
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 28s
This commit is contained in:
parent
04f54e248a
commit
c69eaca7e9
21 changed files with 1147 additions and 1805 deletions
47
src/analytics.ts
Normal file
47
src/analytics.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
43
src/icons.ts
Normal 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>`
|
||||
]
|
||||
|
||||
}
|
||||
1089
src/index.ts
1089
src/index.ts
File diff suppressed because it is too large
Load diff
80
src/languagetool.ts
Normal file
80
src/languagetool.ts
Normal 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
151
src/menus.ts
Normal 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
65
src/protyleHelpers.ts
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
159
src/settings.ts
Normal 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
151
src/spellchecker.ts
Normal 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
191
src/suggestions.ts
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue