├── .npmrc ├── .eslintignore ├── versions.json ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── styles.css ├── version-bump.mjs ├── .eslintrc ├── package.json ├── src ├── logger.ts ├── main.ts ├── priority-queue.ts ├── commands.ts ├── mathjax-search.ts ├── fuzzy-search-dld.ts ├── mathjax-helper.ts ├── settings.ts └── mathjax-suggest.ts ├── LICENSE ├── esbuild.config.mjs ├── README.md └── _README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "better-mathjax", 3 | "name": "Better MathJax", 4 | "version": "1.0.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "Provide math autocompletion and customizable snippets.", 7 | "author": "GreasyCat", 8 | "authorUrl": "https://github.com/greasycat", 9 | "fundingUrl": "https://www.buymeacoffee.com/greasycat", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | package-lock.json 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "lib": [ 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7" 21 | ] 22 | }, 23 | "include": [ 24 | "**/*.ts", 25 | "src/**/*.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .better-mathjax-suggestion-math-entry { 11 | padding-left: 10px; 12 | } 13 | 14 | 15 | .better-mathjax-helper-example-title { 16 | font-weight: bold; 17 | } 18 | 19 | .better-mathjax-helper-example-entry { 20 | padding-left: 20px; 21 | } 22 | 23 | .better-mathjax-helper-see-also-title { 24 | font-weight: bold; 25 | } 26 | 27 | .better-mathjax-helper-see-also-entry { 28 | padding-left: 20px; 29 | } 30 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-mathjax", 3 | "version": "1.0.1", 4 | "description": "MathJax Autocompletion Plugin for Obsidian", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "greasycat@github", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "fz-search": "github:jeancroy/FuzzySearch#master", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | 3 | private consoleLogEnabled: boolean; 4 | private static _instance: Logger; 5 | 6 | constructor() { 7 | } 8 | 9 | public static get instance(): Logger { 10 | if (!Logger._instance) { 11 | Logger._instance = new Logger(); 12 | } 13 | return Logger._instance; 14 | } 15 | public setConsoleLogEnabled(enabled: boolean) { 16 | this.consoleLogEnabled = enabled; 17 | this.info("BetterMathjax Debug Log Enabled:", enabled) 18 | } 19 | private log(...args: unknown[]) { 20 | console.log("[DEBUG]",new Date().toLocaleTimeString(), ...args); 21 | } 22 | 23 | public info(...args: unknown[]) { 24 | if (this.consoleLogEnabled) { 25 | this.log("INFO:",...args); 26 | } 27 | } 28 | 29 | public error(...args: unknown[]) { 30 | this.log("ERROR: ", ...args); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 GreasyCat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BetterMathjax For Obsidian 2 | This plugin will make the mathjax experience in Obsidian better by providing autocompletion. 3 | 4 | ## Features 5 | Autocompletion with inline Mathjax rendering after detection of a `$$` or `$$$$` block. 6 | 7 | ![2023-01-26_18-29-32.png](https://s2.loli.net/2023/01/27/gCUNFnHspqAE8e7.png) 8 | 9 | Using key shortcuts to show a helper modal for reference purposes and fast editing snippets 10 | 11 | ![2023-01-26_18-30-09.png](https://s2.loli.net/2023/01/27/J3QwytrSPloOYiK.png) 12 | 13 | Quick Navigation With Placeholder 14 | 15 | ![2023-01-26_18-36-24.png](https://s2.loli.net/2023/01/27/GdQ7wLEYeA1Xtnl.png) 16 | 17 | Fully customizable configuration 18 | 19 | ![2023-01-26_18-32-28.png](https://s2.loli.net/2023/01/27/a25ItcnyXQJPMsS.png) 20 | 21 | # Installation 22 | ### 1. Downloads from Obsidian Community Plugins List 23 | ### 2. Manual 24 | - Create `$YOUR_VAULT_FOLDER$/.obsidian/better-mathjax` folder 25 | - Put the release files `main.js.bak`, `manifest.json` and `styles.css` into `$YOUR_VAULT_FOLDER$/.obsidian/better-mathjax` 26 | 27 | # Usage 28 | # PLEASE SET HOTKEYS!!! 29 | ### Hotkeys/Keymappings (example) 30 | 31 | 1. Confirm Selected Entry: `Enter` 32 | 2. Select Previous/Up: `Ctrl+[` 33 | 3. Select Next/Down: `Ctrl+]` 34 | 4. Select Next Placeholder: `Ctrl+'` 35 | 5. Select Previous Placeholder: `Ctrl+;` 36 | 6. Show help info for the current selection: `Ctrl+Shift+/` 37 | 7. Manual Reload Configuration file: `None` 38 | 39 | # Special Thanks 40 | - [Where the mathjax data came from](https://www.onemathematicalcat.org/MathJaxDocumentation/TeXSyntax.htm) CC2.5 41 | - [The LCS implementation](https://github.com/jeancroy/FuzzySearch) MIT 42 | - [My first attempt of mathjax autocompletion](https://github.com/greasycat/BetterLatexForObsidian) MIT 43 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {loadMathJax, Plugin} from 'obsidian'; 2 | 3 | import MathjaxSuggest from './mathjax-suggest'; 4 | import { 5 | selectNextSuggestCommand, 6 | selectPreviousSuggestCommand, 7 | selectNextPlaceholderCommand, 8 | selectPreviousPlaceholderCommand, 9 | showMathjaxHelperOnCurrentSelection, 10 | reloadUserDefinedFile, 11 | } from './commands'; 12 | import {BetterMathjaxSettings, BetterMathjaxSettingTab, DEFAULT_SETTINGS, userDefinedFileChanged} from "./settings"; 13 | import {MathjaxHelper} from "./mathjax-helper"; 14 | import Logger from "./logger"; 15 | 16 | export default class BetterMathjaxPlugin extends Plugin { 17 | settings: BetterMathjaxSettings; 18 | mathjaxHelper: MathjaxHelper; 19 | mathjaxSuggest: MathjaxSuggest; 20 | 21 | async onload() { 22 | await this.loadSettings(); 23 | Logger.instance.setConsoleLogEnabled(this.settings.debugMode); 24 | this.addSettingTab(new BetterMathjaxSettingTab(this.app, this)); 25 | 26 | // Actively load mathjax 27 | await loadMathJax(); 28 | 29 | this.mathjaxHelper = new MathjaxHelper(this.app, this.settings); 30 | this.mathjaxSuggest = new MathjaxSuggest(this, this.settings, this.mathjaxHelper); 31 | this.registerEditorSuggest(this.mathjaxSuggest); 32 | 33 | this.addCommand(selectNextSuggestCommand(this.mathjaxSuggest)); 34 | this.addCommand(selectPreviousSuggestCommand(this.mathjaxSuggest)); 35 | this.addCommand(selectNextPlaceholderCommand(this.mathjaxSuggest)); 36 | this.addCommand(selectPreviousPlaceholderCommand(this.mathjaxSuggest)); 37 | this.addCommand(showMathjaxHelperOnCurrentSelection(this.mathjaxSuggest)); 38 | this.addCommand(reloadUserDefinedFile(this.mathjaxHelper)); 39 | 40 | this.registerEvent(this.app.vault.on("modify", userDefinedFileChanged, this)); 41 | } 42 | 43 | async loadSettings() { 44 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 45 | } 46 | 47 | async saveSettings() { 48 | await this.saveData(this.settings); 49 | } 50 | } 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/priority-queue.ts: -------------------------------------------------------------------------------- 1 | // Credit to stackoverflow user 'gyre' 2 | const top = 0; 3 | const parent = (i:number) => ((i + 1) >>> 1) - 1; 4 | const left = (i:number) => (i << 1) + 1; 5 | const right = (i:number) => (i + 1) << 1; 6 | 7 | export default class PriorityQueue { 8 | private readonly _heap: any[]; 9 | private readonly _comparator: (a:any, b:any) => boolean; 10 | 11 | constructor(comparator = (a:any, b:any) => a > b) { 12 | this._heap = []; 13 | this._comparator = comparator; 14 | } 15 | size() { 16 | return this._heap.length; 17 | } 18 | isEmpty() { 19 | return this.size() == 0; 20 | } 21 | peek() { 22 | return this._heap[top]; 23 | } 24 | push(...values:any[]) { 25 | values.forEach(value => { 26 | this._heap.push(value); 27 | this._siftUp(); 28 | }); 29 | return this.size(); 30 | } 31 | pop() { 32 | const poppedValue = this.peek(); 33 | const bottom = this.size() - 1; 34 | if (bottom > top) { 35 | this._swap(top, bottom); 36 | } 37 | this._heap.pop(); 38 | this._siftDown(); 39 | return poppedValue; 40 | } 41 | replace(value:any) { 42 | const replacedValue = this.peek(); 43 | this._heap[top] = value; 44 | this._siftDown(); 45 | return replacedValue; 46 | } 47 | _greater(i:number, j:number) { 48 | return this._comparator(this._heap[i], this._heap[j]); 49 | } 50 | _swap(i:number, j:number) { 51 | [this._heap[i], this._heap[j]] = [this._heap[j], this._heap[i]]; 52 | } 53 | _siftUp() { 54 | let node = this.size() - 1; 55 | while (node > top && this._greater(node, parent(node))) { 56 | this._swap(node, parent(node)); 57 | node = parent(node); 58 | } 59 | } 60 | _siftDown() { 61 | let node = top; 62 | while ( 63 | (left(node) < this.size() && this._greater(left(node), node)) || 64 | (right(node) < this.size() && this._greater(right(node), node)) 65 | ) { 66 | const maxChild = (right(node) < this.size() && this._greater(right(node), left(node))) ? right(node) : left(node); 67 | this._swap(node, maxChild); 68 | node = maxChild; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import {Command, Notice, Plugin} from 'obsidian'; 2 | import MathjaxSuggest from './mathjax-suggest'; 3 | import {BetterMathjaxSettings} from "./settings"; 4 | import {MathjaxHelper} from "./mathjax-helper"; 5 | import Logger from './logger'; 6 | 7 | export function selectNextSuggestCommand(latexSuggest: MathjaxSuggest): Command { 8 | return { 9 | id: 'select-next-suggestion', 10 | name: 'Select next suggestion', 11 | hotkeys: [], 12 | repeatable: true, editorCallback: (_: never) => { 13 | latexSuggest.selectNextSuggestion(); 14 | }, 15 | }; 16 | } 17 | 18 | export function selectPreviousSuggestCommand(latexSuggest: MathjaxSuggest): Command { 19 | return { 20 | id: 'select-previous-suggestion', 21 | name: 'Select previous suggestion', 22 | hotkeys: [], 23 | repeatable: true, editorCallback: (_: never) => { 24 | latexSuggest.selectPreviousSuggestion(); 25 | }, 26 | }; 27 | } 28 | 29 | export function selectNextPlaceholderCommand(latexSuggest: MathjaxSuggest): Command { 30 | return { 31 | id: 'better-mathjax-select-next-placeholder', 32 | name: 'Select next placeholder', 33 | repeatable: true, editorCallback: (_: never) => { 34 | latexSuggest.selectNextPlaceholder(); 35 | }, 36 | }; 37 | } 38 | 39 | export function selectPreviousPlaceholderCommand(latexSuggest: MathjaxSuggest): Command { 40 | return { 41 | id: 'better-mathjax-select-previous-placeholder', 42 | name: 'Select previous placeholder', 43 | repeatable: true, editorCallback: (_: never) => { 44 | latexSuggest.selectPreviousPlaceholder(); 45 | }, 46 | }; 47 | } 48 | 49 | export function showMathjaxHelperOnCurrentSelection(latexSuggestions: MathjaxSuggest): Command { 50 | return { 51 | id: 'better-mathjax-show-mathjax-helper-on-current-selection', 52 | name: 'Show mathjax helper on current selection', 53 | repeatable: true, editorCallback: (_: never) => { 54 | latexSuggestions.showMathjaxHelperOnCurrentSelection(); 55 | }, 56 | }; 57 | } 58 | 59 | export function insertSubscriptPlaceholder(mathjaxSuggest: MathjaxSuggest, settings: BetterMathjaxSettings): Command { 60 | return { 61 | id: 'better-mathjax-insert-subscript-placeholder-bracket', 62 | name: 'Insert subscript', 63 | repeatable: true, editorCallback: (editor, view) => { 64 | 65 | 66 | // Get current cursor position 67 | const cursor = editor.getCursor(); 68 | if (settings.matchingSubScript && mathjaxSuggest.enabled) 69 | { 70 | editor.replaceRange("_{@1@}", cursor); 71 | mathjaxSuggest.selectNextPlaceholder(); 72 | } 73 | }, 74 | }; 75 | } 76 | 77 | export function insertSuperscriptPlaceholder(mathjaxSuggest: MathjaxSuggest, settings: BetterMathjaxSettings): Command { 78 | return { 79 | id: 'better-mathjax-insert-superscript-placeholder-bracket', 80 | name: 'Insert superscript', 81 | repeatable: true, editorCallback: (editor, view) => { 82 | 83 | Logger.instance.info("Inserting superscript"); 84 | // Get current cursor position 85 | const cursor = editor.getCursor(); 86 | if (settings.matchingSuperScript && mathjaxSuggest.enabled) { 87 | editor.replaceRange("^{@1@}", cursor); 88 | mathjaxSuggest.selectNextPlaceholder(); 89 | } 90 | }, 91 | }; 92 | } 93 | 94 | export function reloadUserDefinedFile(mathjaxHelper: MathjaxHelper): Command { 95 | return { 96 | id: 'better-mathjax-reload-user-defined-file', 97 | name: 'Reload user defined file', 98 | repeatable: true, editorCallback: (editor, view) => { 99 | mathjaxHelper.readUserDefinedSymbols().then(() => { 100 | new Notice("User defined file reloaded"); 101 | }); 102 | }, 103 | }; 104 | } 105 | 106 | 107 | export function addSubSuperScriptCommand(plugin: Plugin, mathjaxSuggest: MathjaxSuggest, settings: BetterMathjaxSettings) { 108 | plugin.addCommand(insertSubscriptPlaceholder(mathjaxSuggest, settings)); 109 | plugin.addCommand(insertSuperscriptPlaceholder(mathjaxSuggest, settings)); 110 | } 111 | 112 | export function removeSubSuperScriptCommand(plugin: Plugin) { 113 | // @ts-ignore 114 | plugin.app.commands.removeCommand("better-mathjax:better-mathjax-insert-superscript-placeholder-bracket"); 115 | // @ts-ignore 116 | plugin.app.commands.removeCommand("better-mathjax:better-mathjax-insert-subscript-placeholder-bracket"); 117 | } 118 | -------------------------------------------------------------------------------- /src/mathjax-search.ts: -------------------------------------------------------------------------------- 1 | import PriorityQueue from './priority-queue'; 2 | 3 | import {dld} from './fuzzy-search-dld'; 4 | // @ts-ignore 5 | import FuzzySearch from 'fz-search'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-var-requires 8 | import {MathJaxSymbol} from './mathjax-symbols'; 9 | import {BetterMathjaxSettings} from "./settings"; 10 | import Logger from "./logger"; 11 | 12 | type QueueItem = { 13 | item: string, 14 | distance: number, 15 | 16 | } 17 | 18 | export type FuzzySearchType = "LCS" | "DLD"; 19 | 20 | export type MathJaxSymbolQuery = { 21 | name: string, 22 | originalSymbol: MathJaxSymbol | null, 23 | userSymbol: MathJaxSymbol | null, 24 | } 25 | 26 | export function getSymbolFromQuery(pair: MathJaxSymbolQuery): MathJaxSymbol { 27 | const newSymbol: MathJaxSymbol = JSON.parse(JSON.stringify(pair.originalSymbol)); 28 | if (newSymbol == null) { 29 | if (pair.userSymbol == null) { 30 | throw new Error("Both symbols are null"); 31 | } 32 | return pair.userSymbol; 33 | } 34 | if (pair.userSymbol) { 35 | // check if the user symbol has an empty description, if so use the original symbol's description 36 | if (pair.userSymbol.description !== "") { 37 | newSymbol.description = pair.userSymbol.description; 38 | } 39 | // check for examples list 40 | if ((String.isString(pair.userSymbol.examples) && pair.userSymbol.examples !== "") || (Array.isArray(pair.userSymbol.examples) && pair.userSymbol.examples.length > 0)) { 41 | newSymbol.examples = pair.userSymbol.examples; 42 | } 43 | // check for see_also list 44 | if (Array.isArray(pair.userSymbol.see_also) && pair.userSymbol.see_also.length > 0) { 45 | newSymbol.see_also = pair.userSymbol.see_also; 46 | } 47 | 48 | // check for snippet 49 | if (pair.userSymbol.snippet !== "") { 50 | newSymbol.snippet = pair.userSymbol.snippet; 51 | } 52 | } 53 | return newSymbol; 54 | } 55 | 56 | export default class MathjaxSearch { 57 | private data: Map; 58 | private readonly settings: BetterMathjaxSettings; 59 | 60 | constructor(settings: BetterMathjaxSettings) { 61 | this.data = new Map(); 62 | this.settings = settings; 63 | } 64 | 65 | 66 | load(data: MathJaxSymbol[]) { 67 | this.data = new Map(data.map((item) => [item.name, {name:item.name, originalSymbol: item, userSymbol: null}])); 68 | Logger.instance.info("Loaded Mathjax symbols. Size: ", this.data.size); 69 | } 70 | 71 | update(newData: Map) { 72 | // iterate over newData if the symbol has been created, update it 73 | // else add it to the map 74 | newData.forEach((newSymbol, key) => { 75 | if (this.data.has(key)) { 76 | const symbolQuery = this.data.get(key); 77 | if (symbolQuery) { 78 | symbolQuery.userSymbol = newSymbol; 79 | } 80 | } else { 81 | this.data.set(key, {name: key, originalSymbol: null, userSymbol: newSymbol}); 82 | } 83 | }); 84 | } 85 | 86 | search(query: string, limit = 5): MathJaxSymbolQuery[] { 87 | switch (this.settings.fuzzySearchType) { 88 | case "DLD": 89 | return this.searchDld(query, limit); 90 | case "LCS": 91 | return this.searchLcs(query, limit); 92 | default: 93 | return this.searchLcs(query, limit); 94 | } 95 | } 96 | 97 | searchDld(query: string, limit = 5): MathJaxSymbolQuery[] { 98 | const queue = new PriorityQueue((a: QueueItem, b: QueueItem) => a.distance > b.distance); 99 | this.data.forEach((item) => { 100 | // remove the first backslash in item.name using regex 101 | queue.push({ 102 | item, 103 | distance: dld(query, item.name) 104 | }); 105 | }); 106 | const result: MathJaxSymbolQuery[] = []; 107 | while (!queue.isEmpty() && limit > 0) { 108 | const symbol: MathJaxSymbolQuery = queue.pop().item; 109 | // symbol.name = symbol.name.replace(/\\/, ''); 110 | result.push(symbol); 111 | limit--; 112 | } 113 | return result; 114 | } 115 | 116 | searchLcs(query: string, limit = 5): MathJaxSymbolQuery[] { 117 | 118 | // convert values into array 119 | const values = Array.from(this.data.values()); 120 | 121 | // set normalize to return the string as is so the query is case-sensitive 122 | const searcher = new FuzzySearch({source: values, keys: ['name'], output_limit: limit, normalize: (string: string)=>{return string}}); 123 | 124 | //@ts-ignore 125 | return searcher.search(query); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /_README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Sample Plugin 2 | 3 | This is a sample plugin for Obsidian (https://obsidian.md). 4 | 5 | This project uses Typescript to provide type checking and documentation. 6 | The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does. 7 | 8 | **Note:** The Obsidian API is still in early alpha and is subject to change at any time! 9 | 10 | This sample plugin demonstrates some of the basic functionality the plugin API can do. 11 | - Changes the default font color to red using `styles.css`. 12 | - Adds a ribbon icon, which shows a Notice when clicked. 13 | - Adds a command "Open Sample Modal" which opens a Modal. 14 | - Adds a plugin setting tab to the settings page. 15 | - Registers a global click event and output 'click' to the console. 16 | - Registers a global interval which logs 'setInterval' to the console. 17 | 18 | ## First time developing plugins? 19 | 20 | Quick starting guide for new plugin devs: 21 | 22 | - Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. 23 | - Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). 24 | - Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. 25 | - Install NodeJS, then run `npm i` in the command line under your repo folder. 26 | - Run `npm run dev` to compile your plugin from `main.ts` to `main.js.bak`. 27 | - Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js.bak`. 28 | - Reload Obsidian to load the new version of your plugin. 29 | - Enable plugin in settings window. 30 | - For updates to the Obsidian API run `npm update` in the command line under your repo folder. 31 | 32 | ## Releasing new releases 33 | 34 | - Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. 35 | - Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. 36 | - Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases 37 | - Upload the files `manifest.json`, `main.js.bak`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. 38 | - Publish the release. 39 | 40 | > You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. 41 | > The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` 42 | 43 | ## Adding your plugin to the community plugin list 44 | 45 | - Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md 46 | - Publish an initial version. 47 | - Make sure you have a `README.md` file in the root of your repo. 48 | - Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. 49 | 50 | ## How to use 51 | 52 | - Clone this repo. 53 | - `npm i` or `yarn` to install dependencies 54 | - `npm run dev` to start compilation in watch mode. 55 | 56 | ## Manually installing the plugin 57 | 58 | - Copy over `main.js.bak`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. 59 | 60 | ## Improve code quality with eslint (optional) 61 | - [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. 62 | - To use eslint with this project, make sure to install eslint from terminal: 63 | - `npm install -g eslint` 64 | - To use eslint to analyze this project use this command: 65 | - `eslint main.ts` 66 | - eslint will then create a report with suggestions for code improvement by file and line number. 67 | - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: 68 | - `eslint .\src\` 69 | 70 | ## Funding URL 71 | 72 | You can include funding URLs where people who use your plugin can financially support it. 73 | 74 | The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: 75 | 76 | ```json 77 | { 78 | "fundingUrl": "https://buymeacoffee.com" 79 | } 80 | ``` 81 | 82 | If you have multiple URLs, you can also do: 83 | 84 | ```json 85 | { 86 | "fundingUrl": { 87 | "Buy Me a Coffee": "https://buymeacoffee.com", 88 | "GitHub Sponsor": "https://github.com/sponsors", 89 | "Patreon": "https://www.patreon.com/" 90 | } 91 | } 92 | ``` 93 | 94 | ## API Documentation 95 | 96 | See https://github.com/obsidianmd/obsidian-api 97 | -------------------------------------------------------------------------------- /src/fuzzy-search-dld.ts: -------------------------------------------------------------------------------- 1 | // function to print pretty matrix 2 | export function printMatrix(source:string, target:string, matrix: number[][]) { 3 | source = "##" + source; 4 | target = "###" + target; 5 | 6 | console.log(target.split("").join("\t")); 7 | 8 | for (let i = 0; i < matrix.length; i++) { 9 | console.log(source[i]+"\t"+matrix[i].join('\t')); 10 | } 11 | } 12 | 13 | export function damerauLevenshteinDistance(source: string, target: string) { 14 | if (!source || source.length === 0) { 15 | // If both strings are empty, the distance is 0 16 | if (!target || target.length === 0) { 17 | return 0; 18 | } else { 19 | // If only the source is empty, the distance is the length of the target 20 | return target.length; 21 | } 22 | } 23 | else if (!target) { 24 | // If only the target is empty, the distance is the length of the source 25 | return source.length; 26 | } 27 | 28 | // Remember that the string is 0 indexed 29 | const sourceLength = source.length; 30 | const targetLength = target.length; 31 | 32 | // distance matrix is 1 indexed 33 | const distanceMatrix:number[][] = [] 34 | 35 | // Set infinity to the sum of the lengths of the strings 36 | const INF = sourceLength + targetLength; 37 | 38 | 39 | distanceMatrix[0] = [INF]; // (0) is an array contains a infinity 40 | 41 | // Set the 0 row and column to infinity 42 | // And the 1 row and column to the distance by merely adding or removing a character 43 | for (let i = 0; i <= sourceLength; i++) { 44 | distanceMatrix[i + 1] = []; 45 | distanceMatrix[i + 1][1] = i; 46 | distanceMatrix[i + 1][0] = INF; 47 | } 48 | 49 | for (let j = 0; j <= targetLength; j++) { 50 | distanceMatrix[1][j + 1] = j; 51 | distanceMatrix[0][j + 1] = INF; 52 | } 53 | 54 | // Create a dictionary of last row positions 55 | // of each character in the source strings 56 | const lastMatchedRowOfTheCharacter:{[char: string]: number} = {}; 57 | 58 | // Iterate through the source string aka the rows 59 | for (let row = 1; row <= sourceLength; row++) { 60 | 61 | // Set the current character in the source string 62 | const sourceChar = source[row - 1]; 63 | 64 | // Set the last matched character's column index in the current row to 0 (no match) 65 | let lastMatchedColumnIndex = 0; 66 | 67 | // Iterate through the target strings aka columns 68 | // and calculate the distance 69 | for (let col = 1; col <= targetLength; col++) { 70 | 71 | //Get the current character in the target strings 72 | const targetChar = target[col - 1]; 73 | 74 | // Try get the last matched row of the current character in the target string from the dictionary 75 | // If it doesn't exist, set the dictionary value to 0 and get it 76 | if (lastMatchedRowOfTheCharacter[targetChar] === undefined) { 77 | lastMatchedRowOfTheCharacter[targetChar] = 0; 78 | } 79 | 80 | const lastMatchedRow = lastMatchedRowOfTheCharacter[targetChar]; 81 | 82 | 83 | // Calculate the cost of the substitution 84 | // If the characters are the same, the cost is 0 85 | const cost = sourceChar === targetChar ? 0 : 1; 86 | 87 | // Calculate the distance for the other cases (removal, insertion, substitution) 88 | // Remember that the distance matrix is 1 indexed so i-1 is i and j is j+1 89 | const distaneAdding = distanceMatrix[row][col + 1] + 1; 90 | const distanceRemoving = distanceMatrix[row + 1][col] + 1; 91 | const distanceSubstitution = distanceMatrix[row][col] + cost; 92 | 93 | // Calculate the distance for transposition 94 | const distanceTransposition = distanceMatrix[lastMatchedRow][lastMatchedColumnIndex] + 95 | (row - lastMatchedRow - 1) + 1 + 96 | (col - lastMatchedColumnIndex - 1); 97 | 98 | // Set the distance to the minimum of the other cases 99 | distanceMatrix[row + 1][col + 1] = Math.min(distaneAdding, distanceRemoving, distanceSubstitution, distanceTransposition); 100 | 101 | //If the characters are the same, update the last matched column indexed 102 | //This is used for distanceTransposition 103 | if (cost === 0) { 104 | lastMatchedColumnIndex = col; 105 | } 106 | } 107 | // Update the last matched row of the current character in the source strings 108 | lastMatchedRowOfTheCharacter[sourceChar] = row; 109 | } 110 | // printMatrix(source, target, distanceMatrix); 111 | return distanceMatrix[sourceLength + 1][targetLength + 1]; 112 | } 113 | 114 | export function dld(source: string, target: string) { 115 | const distance = damerauLevenshteinDistance(source, target); 116 | // let ratio = 1 - (distance / Math.max(source.length, target.length)); 117 | const ratio = 1 - (distance / (source.length + target.length)); 118 | return ratio; 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/mathjax-helper.ts: -------------------------------------------------------------------------------- 1 | import {App, finishRenderMath, Modal, Notice, parseYaml, renderMath, Setting, stringifyYaml, TFile} from "obsidian"; 2 | import {BetterMathjaxSettings} from "./settings"; 3 | import MathjaxSearch, {getSymbolFromQuery, MathJaxSymbolQuery} from "./mathjax-search"; 4 | import {LATEX_SYMBOLS, MathJaxSymbol} from "./mathjax-symbols"; 5 | import Logger from "./logger"; 6 | 7 | 8 | export class MathjaxHelperModal extends Modal { 9 | private settings: BetterMathjaxSettings; 10 | 11 | private mathJaxHelper: MathjaxHelper; 12 | private readonly symbolPair: MathJaxSymbolQuery; 13 | 14 | constructor(app: App, symbolPair: MathJaxSymbolQuery, mathJaxHelper: MathjaxHelper, settings: BetterMathjaxSettings) { 15 | super(app); 16 | this.app = app; 17 | this.symbolPair = symbolPair; 18 | this.settings = settings; 19 | this.mathJaxHelper = mathJaxHelper; 20 | } 21 | 22 | onOpen() { 23 | const {contentEl} = this; 24 | 25 | const symbol = getSymbolFromQuery(this.symbolPair); 26 | Logger.instance.info("Symbol: " + symbol); 27 | 28 | // Show symbol name 29 | contentEl.createEl("h2", {text: symbol.name}); 30 | 31 | //show symbol description 32 | // check if the description is a string or string[] 33 | Logger.instance.info("description: "); 34 | if (String.isString(symbol.description)) { 35 | contentEl.createEl("p", {text: symbol.description}); 36 | } else if (Array.isArray(symbol.description)) { 37 | for (const description of symbol.description) { 38 | contentEl.createEl("p", {text: description}); 39 | } 40 | } 41 | 42 | 43 | if (Array.isArray(symbol.examples) && symbol.examples.length > 0) { 44 | contentEl.createEl("h4", {text: "Examples"}); 45 | for (const example of symbol.examples) { 46 | const p = contentEl.createEl("p", {text: example}); 47 | const math = renderMath(example, false); 48 | finishRenderMath().then(() => { 49 | }); 50 | p.addClass("better-mathjax-helper-example-entry") 51 | p.appendChild(math); 52 | } 53 | } 54 | 55 | // Show see_also 56 | if (Array.isArray(symbol.see_also) && symbol.see_also.length > 0) { 57 | const seeAlsoTitle = contentEl.createEl("h4", {text: "See also"}); 58 | seeAlsoTitle.addClass("better-mathjax-helper-see-also-title"); 59 | for (const see_also of symbol.see_also) { 60 | const p = contentEl.createEl("p", {text: see_also}); 61 | p.addClass("better-mathjax-helper-see-also-entry"); 62 | } 63 | } 64 | 65 | // Show symbol snippet to edit 66 | contentEl.createEl("h4", {text: "Your Snippet"}); 67 | new Setting(contentEl).setName("Content").addTextArea((text) => { 68 | text.setValue(symbol.snippet); 69 | text.onChange((value) => { 70 | // copy the symbol 71 | // if the symbol has never been created, create it 72 | const newSymbol: MathJaxSymbol = 73 | { 74 | name: symbol.name, 75 | snippet: value, 76 | description: "", 77 | examples: "", 78 | see_also: [] 79 | }; 80 | if (this.settings.userDefinedSymbols.get(symbol.name) === undefined) { 81 | 82 | this.settings.userDefinedSymbols.set(symbol.name, newSymbol); 83 | } 84 | // if the symbol has been created, update it 85 | else { 86 | this.settings.userDefinedSymbols.set(symbol.name, newSymbol); 87 | } 88 | }); 89 | }); 90 | 91 | 92 | } 93 | 94 | onClose() { 95 | this.contentEl.empty(); 96 | this.mathJaxHelper.saveUserDefinedSymbols().then(() => { 97 | new Notice("User defined symbols saved"); 98 | }); 99 | } 100 | 101 | 102 | } 103 | 104 | type CodeBlock = { 105 | content: string; 106 | type: string; 107 | } 108 | 109 | export class MathjaxHelper { 110 | private readonly app: App; 111 | private readonly settings: BetterMathjaxSettings; 112 | private fuzzySearch: MathjaxSearch; 113 | private lastQuery: MathJaxSymbolQuery[]; 114 | 115 | private codeBlocks: CodeBlock[]; 116 | 117 | constructor(app: App, settings: BetterMathjaxSettings) { 118 | this.app = app; 119 | this.settings = settings; 120 | //json and unjson the fuzzy search type from the settings 121 | 122 | this.fuzzySearch = new MathjaxSearch(settings); 123 | this.fuzzySearch.load(LATEX_SYMBOLS); 124 | 125 | if (this.settings.userDefinedSymbols == undefined || !(this.settings.userDefinedSymbols instanceof Map)) { 126 | this.settings.userDefinedSymbols = new Map(); 127 | } 128 | 129 | this.readUserDefinedSymbols().then((status) => { 130 | Logger.instance.info("User Defined Symbols Loading:", status); 131 | }); 132 | } 133 | 134 | search(query: string, limit = 5) { 135 | this.lastQuery = this.fuzzySearch.search(query, limit); 136 | return this.lastQuery; 137 | } 138 | 139 | showHelperBySelectedItemIndex(index: number) { 140 | const modal = new MathjaxHelperModal(this.app, this.lastQuery[index], this, this.settings); 141 | modal.open(); 142 | } 143 | 144 | async readUserDefinedSymbols(): Promise { 145 | const file = this.app.vault.getAbstractFileByPath(this.settings.userDefineSymbolFilePath); 146 | // check if the file exists 147 | if (file instanceof TFile) { 148 | // read the file 149 | const content = await this.app.vault.cachedRead(file); 150 | 151 | //clear code blocks 152 | this.codeBlocks = []; 153 | 154 | //clear user defined symbols 155 | this.settings.userDefinedSymbols.clear(); 156 | 157 | let firstBlockLoaded = false; 158 | // Regex to match Markdown code block and extract both the code type and the content 159 | const regex = /```(\w+)\n([\s\S]*?)\n```/gm; 160 | let match; 161 | while ((match = regex.exec(content)) !== null) { 162 | const codeType = match[1]; 163 | const codeContent = match[2]; 164 | let json: any; 165 | try { 166 | if (codeType === "json" || codeType === "yaml") { 167 | if (firstBlockLoaded) { 168 | continue; 169 | } 170 | json = codeType === "json" ? JSON.parse(codeContent) : parseYaml(codeContent); 171 | this.loadSymbolArray(json); 172 | firstBlockLoaded = true; 173 | this.codeBlocks.push({content: "", type: codeType}); 174 | } else { 175 | this.codeBlocks.push({content: codeContent, type: codeType}); 176 | } 177 | } catch (TypeError) { 178 | Logger.instance.error(`Unsupported code block type: ${codeType}`); 179 | return false; 180 | } 181 | } 182 | return true; 183 | } else { 184 | new Notice("User defined symbols file not found"); 185 | Logger.instance.error("User defined symbols file not found"); 186 | // return an error 187 | return false; 188 | } 189 | } 190 | 191 | async saveUserDefinedSymbols() { 192 | const file = this.app.vault.getAbstractFileByPath(this.settings.userDefineSymbolFilePath); 193 | if (file === null) { 194 | new Notice("User defined symbols file not found"); 195 | Logger.instance.error("User defined symbols file not found"); 196 | return; 197 | } 198 | 199 | let content = ""; 200 | if (this.codeBlocks.length === 0) { 201 | this.codeBlocks.push({content: "", type: "json"}); 202 | } 203 | for (const codeBlock of this.codeBlocks) { 204 | switch (codeBlock.type) { 205 | case "json": 206 | content += "```json\n" + JSON.stringify(Array.from(this.settings.userDefinedSymbols.values()), null, 2) + "\n```\n"; 207 | break; 208 | case "yaml": 209 | content += "```yaml\n" + stringifyYaml(Array.from(this.settings.userDefinedSymbols.values())) + "\n```\n"; 210 | break; 211 | default: 212 | content += "```" + codeBlock.type + "\n" + codeBlock.content + "\n```\n"; 213 | break; 214 | } 215 | } 216 | 217 | await this.app.vault.modify(file as TFile, content); 218 | } 219 | 220 | loadSymbolArray(array: any[]) { 221 | if (Array.isArray(array)) { 222 | for (const symbol of array) { 223 | // check if the symbol has a name, a snippet, a description, examples and see_also 224 | // if not give a default value 225 | if (symbol.name === undefined || symbol.name === "") { 226 | continue; 227 | } 228 | 229 | // create a new symbol 230 | const newSymbol: MathJaxSymbol = { 231 | name: symbol.name, 232 | snippet: symbol.snippet, 233 | description: symbol.description, 234 | examples: symbol.examples, 235 | see_also: symbol.see_also, 236 | }; 237 | 238 | // add to the userDefinedSymbols 239 | this.settings.userDefinedSymbols.set(newSymbol.name, newSymbol); 240 | this.fuzzySearch.update(this.settings.userDefinedSymbols); 241 | 242 | Logger.instance.info("New symbol loaded: ", newSymbol.name); 243 | } 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import {MathJaxSymbol} from "./mathjax-symbols"; 2 | import {App, PluginSettingTab, Setting, TFile, Notice, TAbstractFile} from "obsidian"; 3 | import BetterMathjaxPlugin from "./main"; 4 | import {FuzzySearchType} from "./mathjax-search"; 5 | import Logger from "./logger"; 6 | 7 | export interface BetterMathjaxSettings { 8 | //suggest settings 9 | useSnippetFirst: boolean; 10 | maxSuggestionNumber: number; 11 | alwaysShowExamples: boolean; // Always show example even when not provided, this may lead to mathjax rendering issues 12 | 13 | autoEnabling: boolean; // enable the autocompletion automatically when inside $ (inline) or $$ (multiple lines) 14 | forceEnabling: boolean; // always enable the autocompletion 15 | 16 | // user defined symbols 17 | userDefineSymbolFilePath: string; 18 | userDefinedSymbols: Map; 19 | 20 | // quick pairing 21 | matchingSuperScript: boolean; 22 | matchingSubScript: boolean; 23 | 24 | // fuzzy search 25 | fuzzySearchType: FuzzySearchType; 26 | 27 | // other settings 28 | debugMode: boolean; 29 | } 30 | 31 | export const DEFAULT_SETTINGS: BetterMathjaxSettings = { 32 | //auto suggestion settings 33 | useSnippetFirst: true, 34 | maxSuggestionNumber: 5, 35 | alwaysShowExamples: true, 36 | autoEnabling: true, 37 | forceEnabling: false, 38 | 39 | // quick pairing 40 | matchingSubScript: true, 41 | matchingSuperScript: true, 42 | 43 | // user defined symbols 44 | userDefinedSymbols: new Map(), 45 | userDefineSymbolFilePath: "symbols.md", 46 | 47 | // fuzzy search type 48 | fuzzySearchType: "LCS", 49 | 50 | // misc 51 | debugMode: false, 52 | }; 53 | 54 | export class BetterMathjaxSettingTab extends PluginSettingTab { 55 | plugin: BetterMathjaxPlugin; 56 | lastNoticeTime: number; 57 | 58 | constructor(app: App, plugin: BetterMathjaxPlugin) { 59 | super(app, plugin); 60 | this.plugin = plugin; 61 | this.lastNoticeTime = 0; 62 | } 63 | 64 | showNotice(message: string, timeout = 3000) { 65 | if (Date.now() - this.lastNoticeTime > timeout) { 66 | new Notice(message); 67 | this.lastNoticeTime = Date.now(); 68 | } 69 | } 70 | 71 | display(): void { 72 | const {containerEl} = this; 73 | 74 | containerEl.empty(); 75 | 76 | containerEl.createEl('h2', {text: 'Settings for BetterMathjax.'}); 77 | 78 | new Setting(containerEl) 79 | .setName('Use snippet first') 80 | .setDesc('Snippet will always be used for autocompletion instead of the symbol name unless the snippet is not provided.') 81 | .addToggle(toggle => toggle 82 | .setValue(this.plugin.settings.useSnippetFirst) 83 | .onChange(async (value) => { 84 | this.plugin.settings.useSnippetFirst = value; 85 | await this.plugin.saveSettings(); 86 | } 87 | )); 88 | 89 | new Setting(containerEl) 90 | .setName('Max suggestion number') 91 | .setDesc('Maximum number of suggestions to show') 92 | .addText(text => text 93 | .setValue(this.plugin.settings.maxSuggestionNumber.toString()) 94 | .onChange(async (value) => { 95 | this.plugin.settings.maxSuggestionNumber = parseInt(value); 96 | await this.plugin.saveSettings(); 97 | })); 98 | 99 | new Setting(containerEl) 100 | .setName('Always show examples') 101 | .setDesc('Always show example even when not provided, this may lead to mathjax rendering issues') 102 | .addToggle(toggle => toggle 103 | .setValue(this.plugin.settings.alwaysShowExamples) 104 | .onChange(async (value) => { 105 | this.plugin.settings.alwaysShowExamples = value; 106 | await this.plugin.saveSettings(); 107 | })); 108 | 109 | new Setting(containerEl) 110 | .setName('Auto enabling') 111 | .setDesc('Enable the autocompletion automatically when inside $ (inline) or $$ (multiple lines)') 112 | .addToggle(toggle => toggle 113 | .setValue(this.plugin.settings.autoEnabling) 114 | .onChange(async (value) => { 115 | this.plugin.settings.autoEnabling = value; 116 | await this.plugin.saveSettings(); 117 | })); 118 | 119 | new Setting(containerEl) 120 | .setName('Force enabling') 121 | .setDesc('Always enable the autocompletion (even when not inside $ or $$)') 122 | .addToggle(toggle => toggle 123 | .setValue(this.plugin.settings.forceEnabling) 124 | .onChange(async (value) => { 125 | this.plugin.settings.forceEnabling = value; 126 | await this.plugin.saveSettings(); 127 | })); 128 | 129 | new Setting(containerEl) 130 | .setName('Matching super script') 131 | .setDesc('Match the super script when typing') 132 | .addToggle(toggle => toggle 133 | .setValue(this.plugin.settings.matchingSuperScript) 134 | .onChange(async (value) => { 135 | this.plugin.settings.matchingSuperScript = value; 136 | await this.plugin.saveSettings(); 137 | })); 138 | 139 | new Setting(containerEl) 140 | .setName('Matching sub script') 141 | .setDesc('Match the sub script when typing') 142 | .addToggle(toggle => toggle 143 | .setValue(this.plugin.settings.matchingSubScript) 144 | .onChange(async (value) => { 145 | this.plugin.settings.matchingSubScript = value; 146 | await this.plugin.saveSettings(); 147 | })); 148 | 149 | new Setting(containerEl) 150 | .setName('User defined symbols filepath') 151 | .setDesc('The file that contains the user defined symbols (must be markdown file)') 152 | .addText(text => text 153 | .setValue(this.plugin.settings.userDefineSymbolFilePath) 154 | .setPlaceholder("user-defined-symbols.md") 155 | .onChange(async (value) => { 156 | // regex to check if the path is a markdown file 157 | if (value.match(/.*\.md$/)) { 158 | this.plugin.settings.userDefineSymbolFilePath = value; 159 | await this.plugin.saveSettings(); 160 | } else { 161 | this.showNotice("The file should be a markdown file, otherwise it may not appear in the Obsidian file explorer.", 3000); 162 | } 163 | 164 | })) 165 | .addButton(button => button 166 | .setButtonText("Generate") 167 | .onClick(async () => { 168 | const file = this.app.vault.getAbstractFileByPath(this.plugin.settings.userDefineSymbolFilePath); 169 | 170 | if (file instanceof TFile) { 171 | // read the file if empty then generate the default 172 | const content = await this.app.vault.read(file); 173 | if (content && content.trim() === "") { 174 | new Notice("Generating default user defined symbols", 3000); 175 | await this.app.vault.modify(file, generateDefaultUserDefinedSymbols()); 176 | 177 | } else { 178 | new Notice("User defined symbols already exists, if you still want the sample code, delete the file", 3000); 179 | } 180 | } else { 181 | this.app.vault.create(this.plugin.settings.userDefineSymbolFilePath, generateDefaultUserDefinedSymbols()).then((file) => { 182 | if (file === null) { 183 | new Notice("Failed to create the file, make sure the path is correct.", 3000); 184 | } 185 | this.plugin.mathjaxHelper.readUserDefinedSymbols(); 186 | }); 187 | } 188 | })) 189 | .addButton(button => button 190 | .setButtonText("Open") 191 | .onClick(async () => { 192 | const file = this.app.vault.getAbstractFileByPath(this.plugin.settings.userDefineSymbolFilePath); 193 | if (file instanceof TFile) { 194 | await this.app.workspace.getLeaf("split", "vertical").openFile(file); 195 | } else { 196 | new Notice("The file does not exist", 3000); 197 | } 198 | }) 199 | ) 200 | .addButton(button => button 201 | .setButtonText("Reload") 202 | .onClick(async () => { 203 | this.plugin.mathjaxHelper.readUserDefinedSymbols().then(() => { 204 | new Notice("Reloaded user defined symbols", 3000); 205 | }); 206 | })); 207 | 208 | 209 | new Setting(containerEl) 210 | .setName("Fuzzy search type") 211 | .setDesc("Select the fuzzy search algorithm") 212 | .addDropdown(dropdown => { 213 | dropdown.addOption("LCS", "Longest common subsequence"); 214 | dropdown.addOption("DLD", "Damerau-Levenshtein distance"); 215 | dropdown.setValue(this.plugin.settings.fuzzySearchType); 216 | dropdown.onChange(async (value) => { 217 | switch (value) { 218 | case "LCS": 219 | this.plugin.settings.fuzzySearchType = "LCS"; 220 | break; 221 | case "DLD": 222 | this.plugin.settings.fuzzySearchType = "DLD"; 223 | break; 224 | 225 | } 226 | await this.plugin.saveSettings(); 227 | }); 228 | }); 229 | 230 | new Setting(containerEl) 231 | .setName("Debug mode") 232 | .setDesc("Enable debug mode to see the console log") 233 | .addToggle(toggle => toggle 234 | .setValue(this.plugin.settings.debugMode) 235 | .onChange(async (value) => { 236 | Logger.instance.setConsoleLogEnabled(value); 237 | this.plugin.settings.debugMode = value; 238 | await this.plugin.saveSettings(); 239 | } 240 | )); 241 | 242 | 243 | } 244 | } 245 | 246 | 247 | export function userDefinedFileChanged(file: TAbstractFile) { 248 | if (file.path === this.settings.userDefineSymbolFilePath) { 249 | this.mathjaxHelper.readUserDefinedSymbols().then((status: boolean) => { 250 | if (status) { 251 | new Notice("User defined file successful reloaded", 3000); 252 | Logger.instance.info("User defined file successful reloaded"); 253 | } else { 254 | new Notice("User defined file reload failed, check your format!!", 6000); 255 | Logger.instance.error("User defined file reload failed"); 256 | } 257 | }); 258 | } 259 | } 260 | 261 | function generateDefaultUserDefinedSymbols(): string { 262 | return "```note\n" + 263 | "- Use can use either json or yaml to customize your snippets (but be careful with \"\" and indent when using yaml\n" + 264 | "- If any of the field (e.g. description) is set to empty \"\" or [], then the default value will be used\n" + 265 | "- Everything in the note section will be saved\n" + 266 | "- Avoid putting a comma in the end of the json file\n" + 267 | "```\n" + 268 | "\n" + 269 | "```json\n" + 270 | "[\n" + 271 | " {\n" + 272 | " \"name\": \"\\\\int\",\n" + 273 | " \"snippet\": \"\\\\int_{@1@}^{@2@}\",\n" + 274 | " \"description\": \"\",\n" + 275 | " \"examples\": \"\",\n" + 276 | " \"see_also\": []\n" + 277 | " },\n" + 278 | " {\n" + 279 | " \"name\": \"\\\\sum\",\n" + 280 | " \"snippet\": \"\\\\sum_{@1@}^{@2@}\",\n" + 281 | " \"description\": \"\",\n" + 282 | " \"examples\": \"\",\n" + 283 | " \"see_also\": []\n" + 284 | " }\n" + 285 | "]\n" + 286 | "" 287 | } 288 | -------------------------------------------------------------------------------- /src/mathjax-suggest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Editor, 3 | EditorPosition, 4 | EditorSuggest, 5 | EditorSuggestContext, 6 | EditorSuggestTriggerInfo, 7 | finishRenderMath, Notice, 8 | Plugin, 9 | renderMath, 10 | TFile 11 | } from 'obsidian'; 12 | import {MathjaxHelper} from './mathjax-helper'; 13 | import {BetterMathjaxSettings} from "./settings"; 14 | import Logger from "./logger"; 15 | import { addSubSuperScriptCommand, removeSubSuperScriptCommand } from './commands'; 16 | import {getSymbolFromQuery, MathJaxSymbolQuery} from "./mathjax-search"; 17 | 18 | export default class MathjaxSuggest extends EditorSuggest { 19 | private mathjaxHelper: MathjaxHelper; 20 | private editor: Editor; 21 | private readonly settings: BetterMathjaxSettings; 22 | 23 | private readonly plugin: Plugin; 24 | 25 | public enabled: boolean; 26 | 27 | private startPos: EditorPosition; 28 | private endPos: EditorPosition; 29 | private suggestionTired: boolean; 30 | 31 | private startup: boolean; 32 | 33 | constructor(plugin: Plugin, settings: BetterMathjaxSettings, mathjaxHelper: MathjaxHelper) { 34 | super(plugin.app); 35 | this.plugin = plugin; 36 | this.mathjaxHelper = mathjaxHelper; 37 | this.settings = settings; 38 | this.startup = true; 39 | } 40 | 41 | getSuggestions(context: EditorSuggestContext): MathJaxSymbolQuery[] { 42 | // convert the item in results to a string[] 43 | return this.mathjaxHelper.search(context.query, this.settings.maxSuggestionNumber); 44 | } 45 | 46 | onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null { 47 | if (this.startup) { 48 | this.mathjaxHelper.readUserDefinedSymbols().then(() => { 49 | Logger.instance.info("Startup finished"); 50 | this.startup = false; 51 | }); 52 | } 53 | 54 | if (this.suggestionTired) { 55 | this.suggestionTired = false; 56 | return null; 57 | } 58 | 59 | this.editor = editor; 60 | 61 | this.enabled = false; 62 | if (this.settings.forceEnabling) { 63 | this.enabled = true; 64 | } else { 65 | if (this.settings.autoEnabling) { 66 | const text = this.getTextBeforeCursor(cursor); 67 | this.enabled = this.checkMathjaxEnvironment(text) 68 | } 69 | 70 | if (!this.enabled) { 71 | removeSubSuperScriptCommand(this.plugin); 72 | return null; 73 | } 74 | } 75 | 76 | addSubSuperScriptCommand(this.plugin, this, this.settings); 77 | 78 | 79 | const word = this.getWord(this.getCurrentLineBeforeCursor(cursor)); 80 | 81 | this.startPos = {line: cursor.line, ch: cursor.ch - word.length}; 82 | this.endPos = cursor; 83 | 84 | if (word !== "") { 85 | return {start: this.startPos, end: cursor, query: word}; 86 | } 87 | 88 | return null; 89 | 90 | } 91 | 92 | async renderSuggestion(suggestion: MathJaxSymbolQuery, el: HTMLElement): Promise { 93 | const symbol = getSymbolFromQuery(suggestion); 94 | el.setText(symbol.name); 95 | // Create new element 96 | const mathSpan = el.createSpan(); 97 | 98 | try { 99 | let example = symbol.name; 100 | // check the type of examples, if string and not empty then use it, if array and not empty then use the first element 101 | if (typeof symbol.examples === "string" && symbol.examples !== "") { 102 | example = symbol.examples; 103 | } else if (Array.isArray(symbol.examples) && symbol.examples.length > 0) { 104 | example = symbol.examples[0]; 105 | } 106 | 107 | //Logger.instance.info(example) 108 | const mathEl = renderMath(example, false); 109 | await finishRenderMath(); 110 | mathSpan.addClass("better-mathjax-suggestion-math-entry"); 111 | mathSpan.appendChild(mathEl); 112 | } catch (ReferenceError) { 113 | new Notice("Error rendering mathjax"); 114 | Logger.instance.error("Error rendering mathjax"); 115 | } 116 | 117 | 118 | } 119 | 120 | selectSuggestion(suggestion: MathJaxSymbolQuery, evt: MouseEvent | KeyboardEvent): void { 121 | const symbol = getSymbolFromQuery(suggestion); 122 | const pos = this.startPos; 123 | // pos.ch = pos.ch - 1; 124 | if (this.settings.useSnippetFirst && symbol.snippet !== undefined && symbol.snippet !== "") { 125 | this.editor.replaceRange(symbol.snippet, this.startPos, this.endPos); 126 | this.editor.setCursor(pos); 127 | 128 | this.selectNextPlaceholder(); 129 | } else { 130 | this.editor.replaceRange(symbol.name, pos, this.endPos); 131 | } 132 | this.close(); 133 | this.suggestionTired = true; 134 | } 135 | getCurrentLineBeforeCursor(pos: EditorPosition): string { 136 | return this.editor.getLine(pos.line).slice(0, pos.ch); 137 | } 138 | 139 | // Function to get the text before the cursor and after a backslash 140 | // if there is one space before the cursor and after the backslash 141 | // then return "" 142 | getWord(text: string): string { 143 | 144 | // Regex to match a word after a backslash and before the end of the line 145 | const regex = /(\\\w+)$/; 146 | const match = text.match(regex); 147 | if (!match) { 148 | return ""; 149 | } 150 | return match[1]; 151 | } 152 | 153 | getTextBeforeCursor(pos: EditorPosition): string { 154 | let text = ""; 155 | for (let i = 0; i < pos.line; i++) { 156 | text += this.editor.getLine(i) + " "; 157 | } 158 | 159 | text += this.getCurrentLineBeforeCursor(pos); 160 | return text; 161 | } 162 | 163 | 164 | checkMathjaxEnvironment(text: string): boolean { 165 | // check if the text is in a mathjax environment using stack 166 | // the start of a mathjax environment is $ or $$ 167 | // the end of a mathjax environment is $ or $$ 168 | // if the stack is empty then we are not in a mathjax environment 169 | // if the stack is not empty then we are in a mathjax environment 170 | const stack: string[] = []; 171 | const regex = /(\$\$|\$)/g; 172 | let match; 173 | while ((match = regex.exec(text)) !== null) { 174 | if (stack.length === 0) { 175 | stack.push(match[1]); 176 | } else { 177 | if (stack[stack.length - 1] === match[1]) { 178 | stack.pop(); 179 | } else { 180 | stack.push(match[1]); 181 | } 182 | } 183 | } 184 | // Logger.instance.info("DEBUG: stack length:", stack.length); 185 | return stack.length !== 0; 186 | } 187 | 188 | 189 | selectNextSuggestion(): void { 190 | // Thanks to github@tth05 for this hack 191 | // And thanks to Obsidian team who made this hack possible by not providing a doc for this (yet) 192 | 193 | /* eslint-disable */ 194 | (this as any).suggestions.setSelectedItem((this as any).suggestions.selectedItem + 1, new KeyboardEvent("keydown")); 195 | /* eslint-enable */ 196 | } 197 | 198 | selectPreviousSuggestion(): void { 199 | /* eslint-disable */ 200 | (this as any).suggestions.setSelectedItem((this as any).suggestions.selectedItem - 1, new KeyboardEvent("keydown")); 201 | /* eslint-enable */ 202 | } 203 | 204 | 205 | selectNextPlaceholder(): void { 206 | const pos = this.editor.getCursor(); 207 | // If already selected, move to the next placeholder by adding the length of the placeholder to the pos 208 | if (this.editor.somethingSelected()) { 209 | const selectedText = this.editor.getSelection(); 210 | pos.ch += selectedText.length - 1; 211 | } 212 | 213 | const currentLineNumber = pos.line; 214 | const maxLineNumber = this.editor.lastLine(); 215 | 216 | let bracketPositions; 217 | let firstBracketPosition = true; 218 | // Iterate over each line unless find a placeholder in format @1@, @2@, @3@, etc. 219 | for (let lineNumber = currentLineNumber; lineNumber <= maxLineNumber; lineNumber++) { 220 | let line = ""; 221 | if (lineNumber !== currentLineNumber) { 222 | line = this.editor.getLine(lineNumber); 223 | } else { 224 | // get the text after the cursor 225 | line = this.editor.getLine(lineNumber).slice(pos.ch); 226 | } 227 | 228 | Logger.instance.info("lineNumber:", lineNumber); 229 | Logger.instance.info("currentLineNumber:", currentLineNumber); 230 | Logger.instance.info("pos.ch:", pos.ch); 231 | 232 | const placeHolderRegex = /@(\S)@/g; 233 | let match; 234 | if ((match = placeHolderRegex.exec(line)) !== null) { 235 | Logger.instance.info("Placeholder found"); 236 | const placeholderStartPos = {line: lineNumber, ch: pos.ch + match.index}; 237 | const placeholderEndPos = {line: lineNumber, ch: pos.ch + match.index + match[0].length}; 238 | this.editor.setSelection(placeholderStartPos, placeholderEndPos); 239 | return; 240 | } 241 | 242 | const endBracketRegex = /}/g; 243 | while ((match = endBracketRegex.exec(line)) !== null && firstBracketPosition) { 244 | Logger.instance.info("End bracket found"); 245 | bracketPositions = {line: lineNumber, ch: pos.ch + match.index+1}; 246 | firstBracketPosition = false; 247 | } 248 | pos.ch = 0; 249 | } 250 | 251 | if (bracketPositions) { 252 | this.editor.setCursor(bracketPositions); 253 | } 254 | 255 | } 256 | 257 | selectPreviousPlaceholder(): void { 258 | const pos = this.editor.getCursor(); 259 | // If already selected, move to the next placeholder by adding the length of the placeholder to the pos 260 | if (this.editor.somethingSelected()) { 261 | const selectedText = this.editor.getSelection(); 262 | pos.ch -= selectedText.length - 1; 263 | } 264 | 265 | const currentLineNumber = pos.line; 266 | const minLineNumber = 0; 267 | 268 | // Iterate over each line unless find a placeholder in format @1@, @2@, @3@, etc. 269 | for (let lineNumber = currentLineNumber; lineNumber >= minLineNumber; lineNumber--) { 270 | let line = ""; 271 | if (lineNumber !== currentLineNumber) { 272 | line = this.editor.getLine(lineNumber); 273 | } else { 274 | // get the text before the cursor 275 | line = this.editor.getLine(lineNumber).slice(0, pos.ch); 276 | } 277 | // match the last placeholder in the line 278 | const regex = /@(\d+)@/g; 279 | let match; 280 | let lastMatch; 281 | while ((match = regex.exec(line)) !== null) { 282 | lastMatch = match; 283 | } 284 | if (lastMatch !== undefined) { 285 | const placeholderStartPos = {line: lineNumber, ch: lastMatch.index}; 286 | const placeholderEndPos = {line: lineNumber, ch: lastMatch.index + lastMatch[0].length}; 287 | this.editor.setSelection(placeholderStartPos, placeholderEndPos); 288 | return; 289 | } 290 | } 291 | } 292 | 293 | showMathjaxHelperOnCurrentSelection(): void { 294 | 295 | /* eslint-disable */ 296 | const selectedIndex = (this as any).suggestions.selectedItem; 297 | /* eslint-enable */ 298 | 299 | // show the mathjax helper 300 | this.mathjaxHelper.showHelperBySelectedItemIndex(selectedIndex); 301 | 302 | } 303 | } 304 | --------------------------------------------------------------------------------