├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── docs ├── clever-referencing.html ├── commands.html ├── credits.html ├── equations.html ├── extending-obsidian's-math-rendering-functionalities │ ├── multi-line-equation-support-inside-blockquotes.html │ └── rendering-equations-inside-callouts.html ├── index.html ├── lib │ ├── scripts │ │ ├── generated.js │ │ ├── graph-render-worker.js │ │ ├── graph_view.js │ │ ├── graph_wasm.js │ │ ├── graph_wasm.wasm │ │ ├── tinycolor.js │ │ └── webpage.js │ └── styles │ │ ├── generated-styles.css │ │ ├── obsidian-styles.css │ │ ├── plugin-styles.css │ │ ├── snippets.css │ │ └── theme.css ├── migration-from-math-booster-version-1.html ├── proof-environment.html ├── search-&-link-auto-completion │ ├── assets │ │ └── enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp │ ├── auto-completion.html │ ├── custom-link-autocomplete.html │ ├── custom-link-completion.html │ ├── editor-auto-completion.html │ ├── enhancing-obsidian's-built-in-link-autocomplete.html │ ├── enhancing-obsidian's-built-in-link-completion.html │ ├── search-&-link-auto-completion.html │ ├── search-&-link-autocomplete.html │ └── search-modal.html ├── search-&-link-autocomplete │ ├── assets │ │ └── enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp │ ├── custom-link-autocomplete.html │ ├── enhancing-obsidian's-built-in-link-autocomplete.html │ ├── search-&-link-autocomplete.html │ └── search-modal.html ├── settings │ ├── local-settings.html │ ├── prefix-inference │ │ ├── 5.6-sample-note.html │ │ ├── a-sample-note.html │ │ └── prefix-inference.html │ ├── profiles.html │ └── settings.html └── theorem-callouts │ ├── prefix-inference.html │ ├── style-your-theorems.html │ ├── styling.html │ └── theorem-callouts.html ├── esbuild.config.mjs ├── manifest-beta.json ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── cleveref.ts ├── env.ts ├── equations │ ├── common.ts │ ├── live-preview.ts │ └── reading-view.ts ├── file-io.ts ├── index │ ├── README.md │ ├── expression │ │ ├── link.ts │ │ └── literal.ts │ ├── import │ │ └── markdown.ts │ ├── manager.ts │ ├── math-index.ts │ ├── storage │ │ └── inverted.ts │ ├── typings │ │ ├── indexable.ts │ │ ├── json.ts │ │ └── markdown.ts │ ├── utils │ │ ├── deferred.ts │ │ └── normalizers.ts │ └── web-worker │ │ ├── importer.ts │ │ ├── importer.worker.ts │ │ ├── message.ts │ │ └── transferable.ts ├── main.ts ├── notice.ts ├── patches │ ├── link-completion.ts │ └── page-preview.ts ├── proof │ ├── common.ts │ ├── live-preview.ts │ └── reading-view.ts ├── search │ ├── core.ts │ ├── editor-suggest.ts │ └── modal.ts ├── settings │ ├── helper.ts │ ├── modals.ts │ ├── profile.ts │ ├── settings.ts │ └── tab.ts ├── theorem-callouts │ ├── renderer.ts │ ├── state-field.ts │ └── view-plugin.ts ├── typings │ ├── type.d.ts │ └── workers.d.ts └── utils │ ├── editor.ts │ ├── format.ts │ ├── general.ts │ ├── obsidian.ts │ ├── parse.ts │ ├── plugin.ts │ └── render.ts ├── styles.scss ├── styles ├── framed.scss ├── main.css ├── mathwiki.scss ├── plain.scss └── vivid.scss ├── tsconfig.json ├── version-bump.mjs └── versions.json /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: RyotaUshio 2 | custom: ['https://www.buymeacoffee.com/ryotaushio'] 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install -g sass 23 | npm install 24 | npm run build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | --draft \ 35 | main.js manifest.json styles.css 36 | -------------------------------------------------------------------------------- /.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 | # This is auto-generated by the css files in the styles directory. 16 | styles.css 17 | 18 | # Exclude sourcemaps 19 | *.map 20 | 21 | # obsidian 22 | data.json 23 | 24 | # Exclude macOS Finder (System Explorer) View States 25 | .DS_Store 26 | 27 | # Docs 28 | docs/_site/ 29 | 30 | # Unused 31 | src/callout_decoration.ts -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ryota Ushio 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LaTeX-like Theorem & Equation Referencer for Obsidian 2 | 3 | > [!important] 4 | > This plugin had been called **Math Booster** until version 2.1.4, but has been renamed for better clarity and discoverability. A big thank you to those who shared their thoughts [here](https://github.com/RyotaUshio/obsidian-math-booster/issues/210). 5 | 6 | **LaTeX-like Theorem & Equation Referencer** is an [Obsidian.md](https://obsidian.md/) plugin that provides a powerful indexing & referencing system for theorems & equations in your vault, bringing $\LaTeX$-like workflow into Obsidian. 7 | 8 | ![Screenshot](https://raw.githubusercontent.com/RyotaUshio/obsidian-math-booster/1c7b106fcfbddccdcda8451de1c21a094994b686/docs/fig/screenshot.png) 9 | 10 | (The theorem in the screenshot is cited from [Tao, Terence, ed. An introduction to measure theory. Vol. 126. American Mathematical Soc., 2011.](https://terrytao.files.wordpress.com/2012/12/gsm-126-tao5-measure-book.pdf)) 11 | 12 | ## Docs 13 | 14 | https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/ 15 | 16 | ## Features 17 | 18 | - [Theorem environments](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/theorem-callouts.html) 19 | - [Automatic equation numbering](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/equations.html) 20 | - [Clever referencing](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/clever-referencing.html) 21 | - [Search & link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/search-&-link-autocomplete.html) 22 | - [Custom link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/custom-link-autocomplete.html) 23 | - Easily find & insert link to theorems & equations. 24 | - Filter theorems & equations based on their locations (*entire vault/recent notes/active note*) 25 | - [Search modal](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/search-modal.html): more control & flexibility than editor autocomplete, including *Dataview queries* 26 | - [Proof environment (experimental)](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/proof-environment.html) 27 | 28 | > [!note] 29 | > For more modular and focused enhancements, some features are planned to be transitioned from this plugin to dedicated, specialized plugins in the near future. Below are the upcoming changes: 30 | > 31 | > #### Transitioning to [**Better Math in Callouts & Blockquotes**](https://github.com/RyotaUshio/obsidian-math-in-callout) 32 | > 33 | > - Rendering equations inside callouts 34 | > - Multi-line equation support inside blockquotes 35 | > 36 | > #### Transitioning to [**Rendered Block Link Suggestions**](https://github.com/RyotaUshio/obsidian-rendered-block-link-suggestions) 37 | > 38 | > - [Render equations in Obsidian's built-in link suggestions](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/enhancing-obsidian's-built-in-link-autocomplete.html) 39 | 40 | Theorems & equations can be **dynamically/automatically numbered**, while you can statically/manually number them if you want. 41 | The number prefix can be either explicitly specified or automatically inferred from the note title. 42 | 43 | Thanks to the integration with [MathLinks](https://github.com/zhaoshenzhai/obsidian-mathlinks), links to theorems/equations are displayed with their title or number, similarly to the `cleveref` package in LaTeX. (No need for manually typing aliases!) 44 | 45 | You can also customize the appearance of theorem callouts using CSS snippets; see [here](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/styling.html). 46 | 47 | ## Companion plugins 48 | 49 | Here's a list of other math-related plugins I've developed: 50 | 51 | - [No More Flickering Inline Math](https://github.com/RyotaUshio/obsidian-inline-math) 52 | - [Better Math in Callouts & Blockquotes](https://github.com/RyotaUshio/obsidian-math-in-callout) 53 | - [MathJax Preamble Manager](https://github.com/RyotaUshio/obsidian-mathjax-preamble-manager) 54 | - [Auto-\\displaystyle Inline Math](https://github.com/RyotaUshio/obsidian-auto-displaystyle-inline-math) 55 | 56 | ## Installation 57 | 58 | You can install this plugin via Obsidian's community plugin browser (see [here](https://help.obsidian.md/Extending+Obsidian/Community+plugins#Install+a+community+plugin) for instructions). 59 | 60 | Also, you can test the latest beta release using [BRAT](https://github.com/TfTHacker/obsidian42-brat): 61 | 62 | 1. Install BRAT and enable it. 63 | 2. Go to **Options**. In the **Beta Plugin List** section, click on the **Add Beta plugin** button. 64 | 3. Copy and paste `RyotaUshio/obsidian-latex-theorem-equation-referencer` in the pop-up prompt and click on **Add Plugin**. 65 | 4. _(Optional)_ Turn on **Auto-update plugins at startup** at the top of the page. 66 | 5. Go to **Community plugins > Installed plugins**. You will find "LaTeX-like Theorem & Equation Referencer" in the list. Click on the toggle button to enable it. 67 | Since version 2 is still beta, it's not on the community plugin browser yet. 68 | 69 | ## Dependencies 70 | 71 | ### Obsidian plugins 72 | 73 | This plugin requires [MathLinks](https://github.com/zhaoshenzhai/obsidian-mathlinks) version 0.5.3 or higher installed to work properly ([Clever referencing](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/clever-referencing.html)). 74 | 75 | In version 2, [Dataview](https://github.com/blacksmithgu/obsidian-dataview) is no longer required. But I strongly recommend installing it because it enhances this plugin's [search](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-auto-completion/search-modal.html) functionality significantly. 76 | 77 | ### Fonts 78 | 79 | You have to install [CMU Serif](https://www.cufonfonts.com/font/cmu-serif) to get some of the [preset styles for theorem callouts](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/styling.html) displayed properly. 80 | 81 | Additionally, [Noto Sans JP](https://fonts.google.com/noto/specimen/Noto+Sans+JP) is required for render the preset styles properly in Japanese. 82 | 83 | ## Contributing 84 | 85 | - Feel free to create a new issue if something is not working well. Questions are also welcomed. 86 | - Please send a pull request if you have any ideas to improve this plugin and our experience! 87 | - Contribution to the docs is also highly appreciated: see [here](https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer-docs). 88 | 89 | ## Roadmaps 90 | 91 | - Import from LaTeX: ArXiv papers, research/literature notes written in LaTeX, ... 92 | - Export to LaTeX: Write research notes in Obsidian, and then export them into LaTeX. 93 | 94 | ## Support development 95 | 96 | If you find this plugin useful, please support my work by buying me a coffee! 97 | 98 | Buy Me A Coffee 99 | -------------------------------------------------------------------------------- /docs/lib/scripts/generated.js: -------------------------------------------------------------------------------- 1 | let nodes={paths:["extending-obsidian's-math-rendering-functionalities/multi-line-equation-support-inside-blockquotes.html","extending-obsidian's-math-rendering-functionalities/rendering-equations-inside-callouts.html","search-&-link-autocomplete/custom-link-autocomplete.html","search-&-link-autocomplete/enhancing-obsidian's-built-in-link-autocomplete.html","search-&-link-autocomplete/search-&-link-autocomplete.html","search-&-link-autocomplete/search-modal.html","settings/prefix-inference/5.6-sample-note.html","settings/prefix-inference/prefix-inference.html","settings/local-settings.html","settings/profiles.html","settings/settings.html","theorem-callouts/styling.html","theorem-callouts/theorem-callouts.html","clever-referencing.html","commands.html","credits.html","equations.html","index.html","migration-from-math-booster-version-1.html","proof-environment.html"],nodeCount:20,linkSources:[4,4,4,5,7,9,9,9,10,10,11,12,12,12,12,13,13,13,14,14,14,14,14,14,15,15,16,16,17,17,17,17,17,17,17,17,17,17,18,18,18,18,18,19,19],linkTargets:[3,2,5,2,6,8,12,19,8,9,9,13,9,16,11,12,16,4,5,19,8,12,18,16,5,11,12,13,18,12,16,13,4,3,2,5,19,15,12,3,2,5,16,12,9],labels:["Multi-line equation support inside blockquotes","Rendering equations inside callouts","Custom link autocomplete","Enhancing Obsidian's built-in link autocomplete","Search & link autocomplete","Search modal","5.6. Sample note","Prefix inference","Local settings","Profiles","Settings","Styling","Theorem callouts","Clever referencing","Commands","Credits","Equations","index","Migration from Math Booster version 1","Proof environment"],radii:[3,3,5.809917355371901,5.2623966942148765,6.2541322314049586,6.595041322314049,3.857438016528926,3.857438016528926,5.2623966942148765,6.832644628099173,4.6115702479338845,5.2623966942148765,7,6.595041322314049,6.595041322314049,5.2623966942148765,6.832644628099173,7,6.832644628099173,6.2541322314049586],linkCount:45},attractionForce=1,linkLength=10,repulsionForce=150,centralForce=3,edgePruning=100 -------------------------------------------------------------------------------- /docs/lib/scripts/graph-render-worker.js: -------------------------------------------------------------------------------- 1 | if("function"==typeof importScripts){let e,t,o;importScripts("https://d157l7jdn8e5sf.cloudfront.net/v7.2.0/webworker.js","./tinycolor.js"),addEventListener("message",onMessage),isDrawing=!1;let n=0,a=[],r=[],i=0,l=[],c=[],d=[],s=[],u=[],g={x:0,y:0},p=new Float32Array(0),h=0,f=0,y={background:2302755,link:11184810,node:13421772,outline:11184810,text:16777215,accent:4203434},S=-1,x=-1,v=-1,m=!1,w=[],b=-1,C=1,k=1;function toScreenSpace(e,t,o=!0){return o?{x:Math.floor(e*C+g.x),y:Math.floor(t*C+g.y)}:{x:e*C+g.x,y:t*C+g.y}}function vecToScreenSpace({x:e,y:t},o=!0){return toScreenSpace(e,t,o)}function toWorldspace(e,t){return{x:(e-g.x)/C,y:(t-g.y)/C}}function vecToWorldspace({x:e,y:t}){return toWorldspace(e,t)}function setCameraCenterWorldspace({x:e,y:t}){g.x=canvas.width/2-e*C,g.y=canvas.height/2-t*C}function getCameraCenterWorldspace(){return toWorldspace(canvas.width/2,canvas.height/2)}function getNodeScreenRadius(e){return e*k}function getNodeWorldspaceRadius(e){return e/k}function getPosition(e){return{x:p[2*e],y:p[2*e+1]}}function mixColors(e,t,o){return tinycolor.mix(tinycolor(e.toString(16)),tinycolor(t.toString(16)),o).toHexNumber()}function darkenColor(e,t){return tinycolor(e.toString(16)).darken(t).toHexNumber()}function lightenColor(e,t){return tinycolor(e.toString(16)).lighten(t).toHexNumber()}function invertColor(e,t){if(0===(e=e.toString(16)).indexOf("#")&&(e=e.slice(1)),3===e.length&&(e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]),6!==e.length)throw new Error("Invalid HEX color.");var o=parseInt(e.slice(0,2),16),n=parseInt(e.slice(2,4),16),a=parseInt(e.slice(4,6),16);return t?.299*o+.587*n+.114*a>186?"#000000":"#FFFFFF":(o=(255-o).toString(16),n=(255-n).toString(16),a=(255-a).toString(16),"#"+padZero(o)+padZero(n)+padZero(a))}function clamp(e,t,o){return Math.min(Math.max(e,t),o)}function lerp(e,t,o){return e+(t-e)*o}let N=0,T=.2,F=15,M=12,P=F/M;function showLabel(e,t,o=!1){let n=u[e];if(d[e]=t,!(t>.01))return void hideLabel(e);n.visible=!0,n.style.fontSize=o?F:M;let a=vecToScreenSpace(getPosition(e)),r=s[e]*(o?P:1)/2;n.x=a.x-r,n.y=a.y+getNodeScreenRadius(l[e])+9,n.alpha=t}function hideLabel(e){u[e].visible=!1}function draw(){o.clear();let e=[];m&&(w=[]),N=-1!=S||-1!=v?Math.min(1,N+T):Math.max(0,N-T),o.lineStyle(1,mixColors(y.link,y.background,50*N),.7);for(let t=0;t2){showLabel(e,lerp(0,(t-4)/10-1/k/6*.9,Math.max(1-N,.2)))}else hideLabel(e);if(S==e||x==e&&0!=N||-1!=S&&w.includes(e))continue;let n=vecToScreenSpace(getPosition(e));o.drawCircle(n.x,n.y,t)}o.endFill(),t=.7*N,o.lineStyle(1,mixColors(mixColors(y.link,y.accent,100*N),y.background,20),t);for(let t=0;t{throw n},ENVIRONMENT_IS_WEB="object"==typeof window,ENVIRONMENT_IS_WORKER="function"==typeof importScripts,ENVIRONMENT_IS_NODE="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,scriptDirectory="";function locateFile(e){return Module.locateFile?Module.locateFile(e,scriptDirectory):scriptDirectory+e}if(ENVIRONMENT_IS_NODE){var fs=require("fs"),nodePath=require("path");scriptDirectory=ENVIRONMENT_IS_WORKER?nodePath.dirname(scriptDirectory)+"/":__dirname+"/",read_=(e,n)=>(e=isFileURI(e)?new URL(e):nodePath.normalize(e),fs.readFileSync(e,n?void 0:"utf8")),readBinary=e=>{var n=read_(e,!0);return n.buffer||(n=new Uint8Array(n)),n},readAsync=(e,n,t)=>{e=isFileURI(e)?new URL(e):nodePath.normalize(e),fs.readFile(e,(function(e,r){e?t(e):n(r.buffer)}))},!Module.thisProgram&&process.argv.length>1&&(thisProgram=process.argv[1].replace(/\\/g,"/")),arguments_=process.argv.slice(2),"undefined"!=typeof module&&(module.exports=Module),process.on("uncaughtException",(function(e){if(!("unwind"===e||e instanceof ExitStatus||e.context instanceof ExitStatus))throw e}));var nodeMajor=process.versions.node.split(".")[0];nodeMajor<15&&process.on("unhandledRejection",(function(e){throw e})),quit_=(e,n)=>{throw process.exitCode=e,n},Module.inspect=function(){return"[Emscripten Module object]"}}else(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)&&(ENVIRONMENT_IS_WORKER?scriptDirectory=self.location.href:"undefined"!=typeof document&&document.currentScript&&(scriptDirectory=document.currentScript.src),scriptDirectory=0!==scriptDirectory.indexOf("blob:")?scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1):"",read_=e=>{var n=new XMLHttpRequest;return n.open("GET",e,!1),n.send(null),n.responseText},ENVIRONMENT_IS_WORKER&&(readBinary=e=>{var n=new XMLHttpRequest;return n.open("GET",e,!1),n.responseType="arraybuffer",n.send(null),new Uint8Array(n.response)}),readAsync=(e,n,t)=>{var r=new XMLHttpRequest;r.open("GET",e,!0),r.responseType="arraybuffer",r.onload=()=>{200==r.status||0==r.status&&r.response?n(r.response):t()},r.onerror=t,r.send(null)},setWindowTitle=e=>document.title=e);var wasmBinary,out=Module.print||console.log.bind(console),err=Module.printErr||console.warn.bind(console);Object.assign(Module,moduleOverrides),moduleOverrides=null,Module.arguments&&(arguments_=Module.arguments),Module.thisProgram&&(thisProgram=Module.thisProgram),Module.quit&&(quit_=Module.quit),Module.wasmBinary&&(wasmBinary=Module.wasmBinary);var wasmMemory,noExitRuntime=Module.noExitRuntime||!0;"object"!=typeof WebAssembly&&abort("no native wasm support detected");var EXITSTATUS,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64,wasmTable,ABORT=!1;function updateMemoryViews(){var e=wasmMemory.buffer;Module.HEAP8=HEAP8=new Int8Array(e),Module.HEAP16=HEAP16=new Int16Array(e),Module.HEAP32=HEAP32=new Int32Array(e),Module.HEAPU8=HEAPU8=new Uint8Array(e),Module.HEAPU16=HEAPU16=new Uint16Array(e),Module.HEAPU32=HEAPU32=new Uint32Array(e),Module.HEAPF32=HEAPF32=new Float32Array(e),Module.HEAPF64=HEAPF64=new Float64Array(e)}var __ATPRERUN__=[],__ATINIT__=[],__ATPOSTRUN__=[],runtimeInitialized=!1;function preRun(){if(Module.preRun)for("function"==typeof Module.preRun&&(Module.preRun=[Module.preRun]);Module.preRun.length;)addOnPreRun(Module.preRun.shift());callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=!0,callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module.postRun)for("function"==typeof Module.postRun&&(Module.postRun=[Module.postRun]);Module.postRun.length;)addOnPostRun(Module.postRun.shift());callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(e){__ATPRERUN__.unshift(e)}function addOnInit(e){__ATINIT__.unshift(e)}function addOnPostRun(e){__ATPOSTRUN__.unshift(e)}var runDependencies=0,runDependencyWatcher=null,dependenciesFulfilled=null;function addRunDependency(e){runDependencies++,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies)}function removeRunDependency(e){if(runDependencies--,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies),0==runDependencies&&(null!==runDependencyWatcher&&(clearInterval(runDependencyWatcher),runDependencyWatcher=null),dependenciesFulfilled)){var n=dependenciesFulfilled;dependenciesFulfilled=null,n()}}function abort(e){throw Module.onAbort&&Module.onAbort(e),err(e="Aborted("+e+")"),ABORT=!0,EXITSTATUS=1,e+=". Build with -sASSERTIONS for more info.",new WebAssembly.RuntimeError(e)}var wasmBinaryFile,tempDouble,tempI64,dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(e){return e.startsWith(dataURIPrefix)}function isFileURI(e){return e.startsWith("file://")}function getBinary(e){try{if(e==wasmBinaryFile&&wasmBinary)return new Uint8Array(wasmBinary);if(readBinary)return readBinary(e);throw"both async and sync fetching of the wasm failed"}catch(e){abort(e)}}function getBinaryPromise(e){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if("function"==typeof fetch&&!isFileURI(e))return fetch(e,{credentials:"same-origin"}).then((function(n){if(!n.ok)throw"failed to load wasm binary file at '"+e+"'";return n.arrayBuffer()})).catch((function(){return getBinary(e)}));if(readAsync)return new Promise((function(n,t){readAsync(e,(function(e){n(new Uint8Array(e))}),t)}))}return Promise.resolve().then((function(){return getBinary(e)}))}function instantiateArrayBuffer(e,n,t){return getBinaryPromise(e).then((function(e){return WebAssembly.instantiate(e,n)})).then((function(e){return e})).then(t,(function(e){err("failed to asynchronously prepare wasm: "+e),abort(e)}))}function instantiateAsync(e,n,t,r){return e||"function"!=typeof WebAssembly.instantiateStreaming||isDataURI(n)||isFileURI(n)||ENVIRONMENT_IS_NODE||"function"!=typeof fetch?instantiateArrayBuffer(n,t,r):fetch(n,{credentials:"same-origin"}).then((function(e){return WebAssembly.instantiateStreaming(e,t).then(r,(function(e){return err("wasm streaming compile failed: "+e),err("falling back to ArrayBuffer instantiation"),instantiateArrayBuffer(n,t,r)}))}))}function createWasm(){var e={a:wasmImports};function n(e,n){var t=e.exports;return Module.asm=t,wasmMemory=Module.asm.f,updateMemoryViews(),wasmTable=Module.asm.r,addOnInit(Module.asm.g),removeRunDependency("wasm-instantiate"),t}if(addRunDependency("wasm-instantiate"),Module.instantiateWasm)try{return Module.instantiateWasm(e,n)}catch(e){return err("Module.instantiateWasm callback failed with error: "+e),!1}return instantiateAsync(wasmBinary,wasmBinaryFile,e,(function(e){n(e.instance)})),{}}isDataURI(wasmBinaryFile="graph_wasm.wasm")||(wasmBinaryFile=locateFile(wasmBinaryFile));var ASM_CONSTS={2304:e=>{console.log(UTF8ToString(e))}};function ExitStatus(e){this.name="ExitStatus",this.message="Program terminated with exit("+e+")",this.status=e}function callRuntimeCallbacks(e){for(;e.length>0;)e.shift()(Module)}function getValue(e,n="i8"){switch(n.endsWith("*")&&(n="*"),n){case"i1":case"i8":return HEAP8[e>>0];case"i16":return HEAP16[e>>1];case"i32":case"i64":return HEAP32[e>>2];case"float":return HEAPF32[e>>2];case"double":return HEAPF64[e>>3];case"*":return HEAPU32[e>>2];default:abort("invalid type for getValue: "+n)}}function setValue(e,n,t="i8"){switch(t.endsWith("*")&&(t="*"),t){case"i1":case"i8":HEAP8[e>>0]=n;break;case"i16":HEAP16[e>>1]=n;break;case"i32":HEAP32[e>>2]=n;break;case"i64":tempI64=[n>>>0,(tempDouble=n,+Math.abs(tempDouble)>=1?tempDouble>0?(0|Math.min(+Math.floor(tempDouble/4294967296),4294967295))>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[e>>2]=tempI64[0],HEAP32[e+4>>2]=tempI64[1];break;case"float":HEAPF32[e>>2]=n;break;case"double":HEAPF64[e>>3]=n;break;case"*":HEAPU32[e>>2]=n;break;default:abort("invalid type for setValue: "+t)}}function _abort(){abort("")}var readEmAsmArgsArray=[];function readEmAsmArgs(e,n){var t;for(readEmAsmArgsArray.length=0,n>>=2;t=HEAPU8[e++];)n+=105!=t&n,readEmAsmArgsArray.push(105==t?HEAP32[n]:HEAPF64[n++>>1]),++n;return readEmAsmArgsArray}function runEmAsmFunction(e,n,t){var r=readEmAsmArgs(n,t);return ASM_CONSTS[e].apply(null,r)}function _emscripten_asm_const_int(e,n,t){return runEmAsmFunction(e,n,t)}function _emscripten_date_now(){return Date.now()}function _emscripten_memcpy_big(e,n,t){HEAPU8.copyWithin(e,n,n+t)}function getHeapMax(){return 2147483648}function emscripten_realloc_buffer(e){var n=wasmMemory.buffer;try{return wasmMemory.grow(e-n.byteLength+65535>>>16),updateMemoryViews(),1}catch(e){}}function _emscripten_resize_heap(e){var n=HEAPU8.length;e>>>=0;var t=getHeapMax();if(e>t)return!1;for(var r=1;r<=4;r*=2){var o=n*(1+.2/r);if(o=Math.min(o,e+100663296),emscripten_realloc_buffer(Math.min(t,(a=Math.max(e,o))+((i=65536)-a%i)%i)))return!0}var a,i;return!1}function getCFunc(e){return Module["_"+e]}function writeArrayToMemory(e,n){HEAP8.set(e,n)}function lengthBytesUTF8(e){for(var n=0,t=0;t=55296&&r<=57343?(n+=4,++t):n+=3}return n}function stringToUTF8Array(e,n,t,r){if(!(r>0))return 0;for(var o=t,a=t+r-1,i=0;i=55296&&u<=57343)u=65536+((1023&u)<<10)|1023&e.charCodeAt(++i);if(u<=127){if(t>=a)break;n[t++]=u}else if(u<=2047){if(t+1>=a)break;n[t++]=192|u>>6,n[t++]=128|63&u}else if(u<=65535){if(t+2>=a)break;n[t++]=224|u>>12,n[t++]=128|u>>6&63,n[t++]=128|63&u}else{if(t+3>=a)break;n[t++]=240|u>>18,n[t++]=128|u>>12&63,n[t++]=128|u>>6&63,n[t++]=128|63&u}}return n[t]=0,t-o}function stringToUTF8(e,n,t){return stringToUTF8Array(e,HEAPU8,n,t)}function stringToUTF8OnStack(e){var n=lengthBytesUTF8(e)+1,t=stackAlloc(n);return stringToUTF8(e,t,n),t}var UTF8Decoder="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;function UTF8ArrayToString(e,n,t){for(var r=n+t,o=n;e[o]&&!(o>=r);)++o;if(o-n>16&&e.buffer&&UTF8Decoder)return UTF8Decoder.decode(e.subarray(n,o));for(var a="";n>10,56320|1023&s)}}else a+=String.fromCharCode((31&i)<<6|u)}else a+=String.fromCharCode(i)}return a}function UTF8ToString(e,n){return e?UTF8ArrayToString(HEAPU8,e,n):""}function ccall(e,n,t,r,o){var a={string:e=>{var n=0;return null!=e&&0!==e&&(n=stringToUTF8OnStack(e)),n},array:e=>{var n=stackAlloc(e.length);return writeArrayToMemory(e,n),n}};var i=getCFunc(e),u=[],l=0;if(r)for(var s=0;s"number"===e||"boolean"===e));return"string"!==n&&o&&!r?getCFunc(e):function(){return ccall(e,n,t,arguments,r)}}var calledRun,wasmImports={b:_abort,e:_emscripten_asm_const_int,d:_emscripten_date_now,c:_emscripten_memcpy_big,a:_emscripten_resize_heap},asm=createWasm(),___wasm_call_ctors=function(){return(___wasm_call_ctors=Module.asm.g).apply(null,arguments)},_SetBatchFractionSize=Module._SetBatchFractionSize=function(){return(_SetBatchFractionSize=Module._SetBatchFractionSize=Module.asm.h).apply(null,arguments)},_SetAttractionForce=Module._SetAttractionForce=function(){return(_SetAttractionForce=Module._SetAttractionForce=Module.asm.i).apply(null,arguments)},_SetLinkLength=Module._SetLinkLength=function(){return(_SetLinkLength=Module._SetLinkLength=Module.asm.j).apply(null,arguments)},_SetRepulsionForce=Module._SetRepulsionForce=function(){return(_SetRepulsionForce=Module._SetRepulsionForce=Module.asm.k).apply(null,arguments)},_SetCentralForce=Module._SetCentralForce=function(){return(_SetCentralForce=Module._SetCentralForce=Module.asm.l).apply(null,arguments)},_SetDt=Module._SetDt=function(){return(_SetDt=Module._SetDt=Module.asm.m).apply(null,arguments)},_Init=Module._Init=function(){return(_Init=Module._Init=Module.asm.n).apply(null,arguments)},_Update=Module._Update=function(){return(_Update=Module._Update=Module.asm.o).apply(null,arguments)},_SetPosition=Module._SetPosition=function(){return(_SetPosition=Module._SetPosition=Module.asm.p).apply(null,arguments)},_FreeMemory=Module._FreeMemory=function(){return(_FreeMemory=Module._FreeMemory=Module.asm.q).apply(null,arguments)},___errno_location=function(){return(___errno_location=Module.asm.__errno_location).apply(null,arguments)},_malloc=Module._malloc=function(){return(_malloc=Module._malloc=Module.asm.s).apply(null,arguments)},_free=Module._free=function(){return(_free=Module._free=Module.asm.t).apply(null,arguments)},stackSave=function(){return(stackSave=Module.asm.u).apply(null,arguments)},stackRestore=function(){return(stackRestore=Module.asm.v).apply(null,arguments)},stackAlloc=function(){return(stackAlloc=Module.asm.w).apply(null,arguments)},___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module.asm.__cxa_is_pointer_type).apply(null,arguments)};function run(){function e(){calledRun||(calledRun=!0,Module.calledRun=!0,ABORT||(initRuntime(),Module.onRuntimeInitialized&&Module.onRuntimeInitialized(),postRun()))}runDependencies>0||(preRun(),runDependencies>0||(Module.setStatus?(Module.setStatus("Running..."),setTimeout((function(){setTimeout((function(){Module.setStatus("")}),1),e()}),1)):e()))}if(Module.cwrap=cwrap,Module.setValue=setValue,Module.getValue=getValue,dependenciesFulfilled=function e(){calledRun||run(),calledRun||(dependenciesFulfilled=e)},Module.preInit)for("function"==typeof Module.preInit&&(Module.preInit=[Module.preInit]);Module.preInit.length>0;)Module.preInit.pop()();run() -------------------------------------------------------------------------------- /docs/lib/scripts/graph_wasm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/978f8dc28459fb8c96bf5b732c0f0fa301e2eeea/docs/lib/scripts/graph_wasm.wasm -------------------------------------------------------------------------------- /docs/lib/styles/generated-styles.css: -------------------------------------------------------------------------------- 1 | body{--line-width:50em;--line-width-adaptive:50em;--file-line-width:50em;--content-width:500em;--sidebar-width:calc(min(22em, 80vw));--collapse-arrow-size:0.35em;--tree-horizontal-spacing:0.6em;--tree-vertical-spacing:0.6em;--sidebar-margin:24px}body{--zoom-factor:1!important;--font-text-override:'CMU Serif','Noto Serif JP'!important;--font-print-override:'CMU Serif','Noto Serif JP'!important;--font-monospace-override:'CMU Typewriter Text'!important;--font-text-size:16px} -------------------------------------------------------------------------------- /docs/lib/styles/snippets.css: -------------------------------------------------------------------------------- 1 | .cm-blockid{opacity:.2}.cm-active .cm-blockid{opacity:.7}.markdown-source-view.mod-cm6 div.edit-block-button{display:none}.better-search-views-file-match{font-size:var(--nav-item-size)!important}.theme-dark{filter:brightness(1.3)}.HyperMD-codeblock,code{line-height:1.3!important}.HyperMD-footnote,li[data-footnote-id]{color:var(--text-muted)}.cm-comment{font-family:var(--font-monospace)!important;color:var(--code-comment)!important}.theorem-callout{--callout-color:248,248,255;background-color:rgb(255,255,255,.03);border:solid;border-radius:6px}.theorem-callout .callout-icon{display:none}.theorem-callout .callout-title-inner{font-style:bold}.theorem-callout-subtitle{font-weight:400}.webpage-container{--h1-size:1.8em;--h2-size:1.5em;--h3-size:1.324em;--h4-size:1.266em;--h5-size:1.266em;--h6-size:1em}.callout .callout-content>*{margin-block-start:0.7rem;margin-block-end:0.7rem}.file-tree>.tree-scroll-area{top:3em} -------------------------------------------------------------------------------- /docs/search-&-link-auto-completion/assets/enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/978f8dc28459fb8c96bf5b732c0f0fa301e2eeea/docs/search-&-link-auto-completion/assets/enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp -------------------------------------------------------------------------------- /docs/search-&-link-autocomplete/assets/enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/978f8dc28459fb8c96bf5b732c0f0fa301e2eeea/docs/search-&-link-autocomplete/assets/enhancing-obsidian's-built-in-link-autocomplete-20231130210116619.webp -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import inlineWorkerPlugin from "esbuild-plugin-inline-worker"; 5 | 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["src/main.ts"], 20 | bundle: true, 21 | plugins: [inlineWorkerPlugin()], 22 | external: [ 23 | "obsidian", 24 | "electron", 25 | "@codemirror/autocomplete", 26 | "@codemirror/collab", 27 | "@codemirror/commands", 28 | "@codemirror/language", 29 | "@codemirror/lint", 30 | "@codemirror/search", 31 | "@codemirror/state", 32 | "@codemirror/view", 33 | "@lezer/common", 34 | "@lezer/highlight", 35 | "@lezer/lr", 36 | ...builtins], 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | sourcemap: prod ? false : "inline", 41 | treeShaking: true, 42 | outfile: "main.js", 43 | }); 44 | 45 | if (prod) { 46 | await context.rebuild(); 47 | process.exit(0); 48 | } else { 49 | await context.watch(); 50 | } -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "math-booster", 3 | "name": "LaTeX-like Theorem & Equation Referencer", 4 | "version": "2.2.0", 5 | "minAppVersion": "1.3.5", 6 | "description": "A powerful indexing & referencing system for theorems & equations in your vault. Bring LaTeX-like workflow into Obsidian with theorem environments, automatic equation numbering, and more.", 7 | "author": "Ryota Ushio", 8 | "authorUrl": "https://github.com/RyotaUshio", 9 | "fundingUrl": "https://www.buymeacoffee.com/ryotaushio", 10 | "helpUrl": "https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/", 11 | "isDesktopOnly": false 12 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "math-booster", 3 | "name": "LaTeX-like Theorem & Equation Referencer", 4 | "version": "2.2.0", 5 | "minAppVersion": "1.3.5", 6 | "description": "A powerful indexing & referencing system for theorems & equations in your vault. Bring LaTeX-like workflow into Obsidian with theorem environments, automatic equation numbering, and more.", 7 | "author": "Ryota Ushio", 8 | "authorUrl": "https://github.com/RyotaUshio", 9 | "fundingUrl": "https://www.buymeacoffee.com/ryotaushio", 10 | "helpUrl": "https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/", 11 | "isDesktopOnly": false 12 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-math-booster", 3 | "version": "2.2.0", 4 | "description": "An Obsidian.md plugin that provides a powerful indexing & referencing system for theorems & equations in your vault. Bring LaTeX-like workflow into Obsidian with theorem environments, automatic equation numbering, and more.", 5 | "scripts": { 6 | "dev": "node esbuild.config.mjs", 7 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && sass styles.scss styles.css", 8 | "dev-style": "sass --watch styles.scss styles.css", 9 | "build-style": "sass --watch styles.scss styles.css", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "author": "Ryota Ushio", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "^16.11.6", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "^0.19.4", 21 | "obsidian": "latest", 22 | "obsidian-mathlinks": "^0.5.1", 23 | "obsidian-quick-preview": "latest", 24 | "tslib": "2.4.0", 25 | "typescript": "4.7.4" 26 | }, 27 | "dependencies": { 28 | "@codemirror/language": "^6.0.0", 29 | "@lezer/common": "^1.0.3", 30 | "esbuild-plugin-inline-worker": "^0.1.1", 31 | "flatqueue": "^2.0.3", 32 | "monkey-around": "^2.3.0", 33 | "sorted-btree": "^1.8.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/cleveref.ts: -------------------------------------------------------------------------------- 1 | import { EquationBlock } from 'index/typings/markdown'; 2 | import { TFile, HeadingSubpathResult, BlockSubpathResult, App } from 'obsidian'; 3 | import * as MathLinks from 'obsidian-mathlinks'; 4 | 5 | import LatexReferencer from 'main'; 6 | import { MathIndex } from 'index/math-index'; 7 | import { MarkdownPage, MathBlock, TheoremCalloutBlock } from 'index/typings/markdown'; 8 | 9 | 10 | export class CleverefProvider extends MathLinks.Provider { 11 | app: App; 12 | index: MathIndex; 13 | 14 | constructor(mathLinks: any, public plugin: LatexReferencer) { 15 | super(mathLinks); 16 | this.app = plugin.app; 17 | this.index = plugin.indexManager.index; 18 | } 19 | 20 | provide( 21 | parsedLinktext: { path: string; subpath: string; }, 22 | targetFile: TFile | null, 23 | targetSubpathResult: HeadingSubpathResult | BlockSubpathResult | null, 24 | ): string | null { 25 | const { path, subpath } = parsedLinktext; 26 | if (targetFile === null) return null; 27 | const page = this.index.load(targetFile.path); 28 | if (!MarkdownPage.isMarkdownPage(page)) return null 29 | 30 | // only path, no subpath: return page.$refName if it exists, otherwise there's nothing to do 31 | if (!subpath) return page.$refName ?? null; 32 | 33 | const processedPath = path ? page.$refName ?? path : ''; 34 | 35 | // subpath resolution failed, do nothing 36 | if (targetSubpathResult === null) return null; 37 | 38 | // subpath resolution succeeded 39 | if (targetSubpathResult.type === 'block') { 40 | // handle block links 41 | 42 | // get the target block 43 | const block = page.$blocks.get(targetSubpathResult.block.id); 44 | 45 | if (MathBlock.isMathBlock(block)) { 46 | // display text set manually: higher priority 47 | if (block.$display) return path && this.shouldShowNoteTitle(block) ? processedPath + ' > ' + block.$display : block.$display; 48 | // display text computed automatically: lower priority 49 | if (block.$refName) return path && this.shouldShowNoteTitle(block) ? processedPath + ' > ' + block.$refName : block.$refName; 50 | } 51 | } else { 52 | // handle heading links 53 | // just ignore (return null) if we don't need to perform any particular processing 54 | if (path && page.$refName) { 55 | return processedPath + ' > ' + subpath; 56 | } 57 | } 58 | 59 | return null; 60 | } 61 | 62 | shouldShowNoteTitle(block: MathBlock): boolean { 63 | if (TheoremCalloutBlock.isTheoremCalloutBlock(block)) return this.plugin.extraSettings.noteTitleInTheoremLink; 64 | if (EquationBlock.isEquationBlock(block)) return this.plugin.extraSettings.noteTitleInEquationLink; 65 | return true; 66 | } 67 | } -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | // length must be >= 5 2 | export const THEOREM_LIKE_ENV_IDs = [ 3 | "axiom", 4 | "definition", 5 | "lemma", 6 | "proposition", 7 | "theorem", 8 | "corollary", 9 | "claim", 10 | "assumption", 11 | "example", 12 | "exercise", 13 | "conjecture", 14 | "hypothesis", 15 | "remark", 16 | ] as const; 17 | 18 | export const PROOF_LIKE_ENV_IDs = [ 19 | "proof", 20 | "solution", 21 | ] as const; 22 | 23 | export const ENV_IDs = [...THEOREM_LIKE_ENV_IDs, ...PROOF_LIKE_ENV_IDs,] as const; 24 | 25 | // length must be <= 4 26 | export const THEOREM_LIKE_ENV_PREFIXES = [ 27 | "axm", 28 | "def", 29 | "lem", 30 | "prp", 31 | "thm", 32 | "cor", 33 | "clm", 34 | "asm", 35 | "exm", 36 | "exr", 37 | "cnj", 38 | "hyp", 39 | "rmk", 40 | ] as const; 41 | 42 | export type TheoremLikeEnvID = typeof THEOREM_LIKE_ENV_IDs[number]; 43 | export type TheoremLikeEnvPrefix = typeof THEOREM_LIKE_ENV_PREFIXES[number]; 44 | 45 | export interface TheoremLikeEnv { 46 | id: TheoremLikeEnvID, 47 | prefix: TheoremLikeEnvPrefix, 48 | } 49 | 50 | export const THEOREM_LIKE_ENVs = {} as Record; 51 | THEOREM_LIKE_ENV_IDs.forEach((id, index) => { 52 | THEOREM_LIKE_ENVs[id] = {id, prefix: THEOREM_LIKE_ENV_PREFIXES[index]}; 53 | }); 54 | 55 | export const THEOREM_LIKE_ENV_PREFIX_ID_MAP = {} as Record; 56 | THEOREM_LIKE_ENV_PREFIXES.forEach((prefix, index) => { 57 | THEOREM_LIKE_ENV_PREFIX_ID_MAP[prefix] = THEOREM_LIKE_ENV_IDs[index]; 58 | }); 59 | 60 | export const THEOREM_LIKE_ENV_ID_PREFIX_MAP = {} as Record; 61 | THEOREM_LIKE_ENV_IDs.forEach((id, index) => { 62 | THEOREM_LIKE_ENV_ID_PREFIX_MAP[id] = THEOREM_LIKE_ENV_PREFIXES[index]; 63 | }); 64 | -------------------------------------------------------------------------------- /src/equations/common.ts: -------------------------------------------------------------------------------- 1 | import { EquationBlock } from "index/typings/markdown"; 2 | import { finishRenderMath, renderMath } from "obsidian"; 3 | import { MathContextSettings } from "settings/settings"; 4 | import { parseLatexComment } from "utils/parse"; 5 | 6 | 7 | 8 | export function replaceMathTag(displayMathEl: HTMLElement, equation: EquationBlock, settings: Required) { 9 | if (equation.$manualTag) return; // respect a tag (\tag{...}) manually set by the user 10 | 11 | const taggedText = getMathTextWithTag(equation, settings.lineByLine); 12 | if (taggedText) { 13 | const mjxContainerEl = renderMath(taggedText, true); 14 | if (equation.$printName !== null) { 15 | displayMathEl.setAttribute('width', 'full'); 16 | displayMathEl.style.cssText = mjxContainerEl.style.cssText; 17 | } else { 18 | displayMathEl.removeAttribute('width'); 19 | displayMathEl.removeAttribute('style'); 20 | } 21 | displayMathEl.replaceChildren(...mjxContainerEl.childNodes); 22 | } 23 | } 24 | 25 | export function getMathTextWithTag(equation: EquationBlock, lineByLine?: boolean): string | undefined { 26 | if (equation.$printName !== null) { 27 | const tagResult = equation.$printName.match(/^\((.*)\)$/); 28 | if (tagResult) { 29 | const tagContent = tagResult[1]; 30 | return insertTagInMathText(equation.$mathText, tagContent, lineByLine); 31 | } 32 | } 33 | return equation.$mathText; 34 | } 35 | 36 | export function insertTagInMathText(text: string, tagContent: string, lineByLine?: boolean): string { 37 | if (!lineByLine) return text + `\\tag{${tagContent}}`; 38 | 39 | const alignResult = text.match(/^\s*\\begin\{align\}([\s\S]*)\\end\{align\}\s*$/); 40 | if (!alignResult) return text + `\\tag{${tagContent}}`; 41 | 42 | const envStack: string[] = []; 43 | 44 | // remove comments 45 | let alignContent = alignResult[1] 46 | .split('\n') 47 | .map(line => parseLatexComment(line).nonComment) 48 | .join('\n'); 49 | // add tags 50 | let index = 1; 51 | alignContent = alignContent 52 | .split("\\\\") 53 | .map((alignLine) => { 54 | const pattern = /\\(?begin|end)\{(?.*?)\}/g; 55 | let result; 56 | while (result = pattern.exec(alignLine)) { 57 | const { which, env } = result.groups!; 58 | if (which === 'begin') envStack.push(env); 59 | else if (envStack.last() === env) envStack.pop(); 60 | } 61 | if (envStack.length || !alignLine.trim() || alignLine.contains("\\nonumber")) return alignLine; 62 | return alignLine + `\\tag{${tagContent}-${index++}}`; 63 | }).join("\\\\"); 64 | 65 | if (index <= 2) return text + `\\tag{${tagContent}}`; 66 | 67 | return "\\begin{align}" + alignContent + "\\end{align}"; 68 | } 69 | -------------------------------------------------------------------------------- /src/equations/live-preview.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Display equation numbers in Live Preview. 3 | */ 4 | 5 | import { EditorState, StateEffect } from '@codemirror/state'; 6 | import { PluginValue, ViewPlugin, EditorView, ViewUpdate } from '@codemirror/view'; 7 | import { EquationBlock, MarkdownBlock, MarkdownPage } from 'index/typings/markdown'; 8 | import LatexReferencer from 'main'; 9 | import { MarkdownView, TFile, editorInfoField, finishRenderMath } from 'obsidian'; 10 | import { resolveSettings } from 'utils/plugin'; 11 | import { replaceMathTag } from './common'; 12 | import { DEFAULT_SETTINGS, MathContextSettings } from 'settings/settings'; 13 | 14 | 15 | export function createEquationNumberPlugin(plugin: LatexReferencer) { 16 | 17 | const { app, indexManager: { index } } = plugin; 18 | 19 | const forceUpdateEffect = StateEffect.define(); 20 | 21 | plugin.registerEvent(plugin.indexManager.on('index-updated', (file) => { 22 | app.workspace.iterateAllLeaves((leaf) => { 23 | if ( 24 | leaf.view instanceof MarkdownView 25 | && leaf.view.file?.path === file.path 26 | && leaf.view.getMode() === 'source' 27 | ) { 28 | leaf.view.editor.cm?.dispatch({ effects: forceUpdateEffect.of(null) }); 29 | } 30 | }); 31 | })); 32 | 33 | return ViewPlugin.fromClass(class implements PluginValue { 34 | file: TFile | null; 35 | page: MarkdownPage | null; 36 | settings: Required; 37 | 38 | constructor(view: EditorView) { 39 | this.file = view.state.field(editorInfoField).file; 40 | this.page = null; 41 | this.settings = DEFAULT_SETTINGS; 42 | 43 | if (this.file) { 44 | this.settings = resolveSettings(undefined, plugin, this.file); 45 | const page = index.load(this.file.path); 46 | if (MarkdownPage.isMarkdownPage(page)) { 47 | this.page = page; 48 | this.updateEquationNumber(view, this.page); 49 | } 50 | } 51 | } 52 | 53 | updateFile(state: EditorState) { 54 | this.file = state.field(editorInfoField).file; 55 | if (this.file) this.settings = resolveSettings(undefined, plugin, this.file); 56 | } 57 | 58 | async updatePage(file: TFile): Promise { 59 | const page = index.load(file.path); 60 | if (MarkdownPage.isMarkdownPage(page)) this.page = page; 61 | if (!this.page) { 62 | this.page = await plugin.indexManager.reload(file); 63 | } 64 | return this.page; 65 | } 66 | 67 | update(update: ViewUpdate) { 68 | if (!this.file) this.updateFile(update.state); 69 | if (!this.file) return; 70 | 71 | if (update.transactions.some(tr => tr.effects.some(effect => effect.is(forceUpdateEffect)))) { 72 | // index updated 73 | this.settings = resolveSettings(undefined, plugin, this.file); 74 | this.updatePage(this.file).then((updatedPage) => this.updateEquationNumber(update.view, updatedPage)) 75 | } else if (update.geometryChanged) { 76 | if (this.page) this.updateEquationNumber(update.view, this.page); 77 | else this.updatePage(this.file).then((updatedPage) => this.updateEquationNumber(update.view, updatedPage)); 78 | } 79 | } 80 | 81 | async updateEquationNumber(view: EditorView, page: MarkdownPage) { 82 | const mjxContainerElements = view.contentDOM.querySelectorAll(':scope > .cm-embed-block.math > mjx-container.MathJax[display="true"]'); 83 | 84 | for (const mjxContainerEl of mjxContainerElements) { 85 | 86 | // skip if the equation is being edited to avoid the delay of preview 87 | const mightBeClosingDollars = mjxContainerEl.parentElement?.previousElementSibling?.lastElementChild; 88 | const isBeingEdited = mightBeClosingDollars?.matches('span.cm-formatting-math-end'); 89 | if (isBeingEdited) continue; 90 | 91 | const pos = view.posAtDOM(mjxContainerEl); 92 | let block: MarkdownBlock | undefined; 93 | try { 94 | const line = view.state.doc.lineAt(pos).number - 1; // sometimes throws an error for reasons that I don't understand 95 | block = page.getBlockByLineNumber(line); 96 | } catch (err) { 97 | block = page.getBlockByOffset(pos); 98 | } 99 | if (!(block instanceof EquationBlock)) continue; 100 | 101 | // only update if necessary 102 | if (mjxContainerEl.getAttribute('data-equation-print-name') !== block.$printName) { 103 | replaceMathTag(mjxContainerEl, block, this.settings); 104 | } 105 | if (block.$printName !== null) mjxContainerEl.setAttribute('data-equation-print-name', block.$printName); 106 | else mjxContainerEl.removeAttribute('data-equation-print-name'); 107 | } 108 | // DON'T FOREGET THIS CALL!! 109 | // https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/203 110 | // https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/200 111 | finishRenderMath(); 112 | } 113 | 114 | destroy() { 115 | // I don't know if this is really necessary, but just in case... 116 | finishRenderMath(); 117 | } 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /src/equations/reading-view.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Display equation numbers in reading view, embeds, hover page preview, and PDF export. 3 | */ 4 | 5 | import { App, MarkdownRenderChild, finishRenderMath, MarkdownPostProcessorContext, TFile, Notice } from "obsidian"; 6 | 7 | import LatexReferencer from 'main'; 8 | import { resolveSettings } from 'utils/plugin'; 9 | import { EquationBlock, MarkdownPage } from "index/typings/markdown"; 10 | import { MathIndex } from "index/math-index"; 11 | import { isPdfExport, resolveLinktext } from "utils/obsidian"; 12 | import { replaceMathTag } from "./common"; 13 | 14 | 15 | export const createEquationNumberProcessor = (plugin: LatexReferencer) => async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => { 16 | if (isPdfExport(el)) preprocessForPdfExport(plugin, el, ctx); 17 | 18 | const sourceFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath); 19 | if (!(sourceFile instanceof TFile)) return; 20 | 21 | const mjxContainerElements = el.querySelectorAll('mjx-container.MathJax[display="true"]'); 22 | for (const mjxContainerEl of mjxContainerElements) { 23 | ctx.addChild( 24 | new EquationNumberRenderer(mjxContainerEl, plugin, sourceFile, ctx) 25 | ); 26 | } 27 | finishRenderMath(); 28 | } 29 | 30 | 31 | /** 32 | * As a preprocessing for displaying equation numbers in the exported PDF, 33 | * add an attribute representing a block ID to each numbered equation element 34 | * so that EquationNumberRenderer can find the corresponding block from the index 35 | * without relying on the line number. 36 | */ 37 | function preprocessForPdfExport(plugin: LatexReferencer, el: HTMLElement, ctx: MarkdownPostProcessorContext) { 38 | 39 | try { 40 | const topLevelMathDivs = el.querySelectorAll(':scope > div.math.math-block > mjx-container.MathJax[display="true"]'); 41 | 42 | const page = plugin.indexManager.index.getMarkdownPage(ctx.sourcePath); 43 | if (!page) { 44 | new Notice(`${plugin.manifest.name}: Failed to fetch the metadata for PDF export; equation numbers will not be displayed in the exported PDF.`); 45 | return; 46 | } 47 | 48 | let equationIndex = 0; 49 | for (const section of page.$sections) { 50 | for (const block of section.$blocks) { 51 | if (!EquationBlock.isEquationBlock(block)) continue; 52 | 53 | const div = topLevelMathDivs[equationIndex++]; 54 | if (block.$printName) div.setAttribute('data-equation-id', block.$id); 55 | } 56 | } 57 | 58 | if (topLevelMathDivs.length != equationIndex) { 59 | new Notice(`${plugin.manifest.name}: Something unexpected occured while preprocessing for PDF export. Equation numbers might not be displayed properly in the exported PDF.`); 60 | } 61 | } catch (err) { 62 | new Notice(`${plugin.manifest.name}: Something unexpected occured while preprocessing for PDF export. See the developer console for the details. Equation numbers might not be displayed properly in the exported PDF.`); 63 | console.error(err); 64 | } 65 | } 66 | 67 | 68 | export class EquationNumberRenderer extends MarkdownRenderChild { 69 | app: App 70 | index: MathIndex; 71 | 72 | constructor(containerEl: HTMLElement, public plugin: LatexReferencer, public file: TFile, public context: MarkdownPostProcessorContext) { 73 | // containerEl, currentEL are mjx-container.MathJax elements 74 | super(containerEl); 75 | this.app = plugin.app; 76 | this.index = this.plugin.indexManager.index; 77 | 78 | this.registerEvent(this.plugin.indexManager.on("index-initialized", () => { 79 | setTimeout(() => this.update()); 80 | })); 81 | 82 | this.registerEvent(this.plugin.indexManager.on("index-updated", (file) => { 83 | setTimeout(() => { 84 | if (file.path === this.file.path) this.update(); 85 | }); 86 | })); 87 | } 88 | 89 | getEquationCache(lineOffset: number = 0): EquationBlock | null { 90 | const info = this.context.getSectionInfo(this.containerEl); 91 | const page = this.index.getMarkdownPage(this.file.path); 92 | if (!info || !page) return null; 93 | 94 | // get block ID 95 | const block = page.getBlockByLineNumber(info.lineStart + lineOffset) ?? page.getBlockByLineNumber(info.lineEnd + lineOffset); 96 | if (EquationBlock.isEquationBlock(block)) return block; 97 | 98 | return null; 99 | } 100 | 101 | async onload() { 102 | setTimeout(() => this.update()); 103 | } 104 | 105 | onunload() { 106 | // I don't know if this is really necessary, but just in case... 107 | finishRenderMath(); 108 | } 109 | 110 | update() { 111 | // for PDF export 112 | const id = this.containerEl.getAttribute('data-equation-id'); 113 | 114 | const equation = id ? this.index.getEquationBlock(id) : this.getEquationCacheCaringHoverAndEmbed(); 115 | if (!equation) return; 116 | const settings = resolveSettings(undefined, this.plugin, this.file); 117 | replaceMathTag(this.containerEl, equation, settings); 118 | } 119 | 120 | getEquationCacheCaringHoverAndEmbed(): EquationBlock | null { 121 | /** 122 | * https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/179 123 | * 124 | * In the case of embeds or hover popovers, the line numbers contained 125 | * in the result of MarkdownPostProcessorContext.getSectionInfo() is 126 | * relative to the content included in the embed. 127 | * In other words, they does not always represent the offset from the beginning of the file. 128 | * So they require special handling. 129 | */ 130 | 131 | const equation = this.getEquationCache(); 132 | 133 | let linktext = this.containerEl.closest('[src]')?.getAttribute('src'); // in the case of embeds 134 | 135 | if (!linktext) { 136 | const hoverEl = this.containerEl.closest('.hover-popover:not(.hover-editor)'); 137 | if (hoverEl) { 138 | // The current context is hover page preview; read the linktext saved in the plugin instance. 139 | linktext = this.plugin.lastHoverLinktext; 140 | } 141 | } 142 | 143 | if (linktext) { // linktext was found 144 | const { file, subpathResult } = resolveLinktext(this.app, linktext, this.context.sourcePath) ?? {}; 145 | 146 | if (!file || !subpathResult) return null; 147 | 148 | const page = this.index.load(file.path); 149 | if (!MarkdownPage.isMarkdownPage(page)) return null; 150 | 151 | if (subpathResult.type === "block") { 152 | const block = page.$blocks.get(subpathResult.block.id); 153 | if (!EquationBlock.isEquationBlock(block)) return null; 154 | return block; 155 | } else { 156 | return this.getEquationCache(subpathResult.start.line); 157 | } 158 | } 159 | 160 | return equation; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/file-io.ts: -------------------------------------------------------------------------------- 1 | import { CachedMetadata, Editor, MarkdownView, Pos, TFile } from "obsidian"; 2 | 3 | import LatexReferencer from "./main"; 4 | import { isEditingView, locToEditorPosition } from "utils/editor"; 5 | import { insertAt, splitIntoLines } from "utils/general"; 6 | 7 | 8 | export abstract class FileIO { 9 | constructor(public plugin: LatexReferencer, public file: TFile) { } 10 | abstract setLine(lineNumber: number, text: string): Promise; 11 | abstract setRange(position: Pos, text: string): Promise; 12 | abstract insertLine(lineNumber: number, text: string): Promise; 13 | abstract getLine(lineNumber: number): Promise; 14 | abstract getRange(position: Pos): Promise; 15 | } 16 | 17 | 18 | export class ActiveNoteIO extends FileIO { 19 | /** 20 | * File IO for the currently active markdown view. 21 | * Uses the Editor interface instead of Vault. 22 | * (See https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines#Prefer+the+Editor+API+instead+of+%60Vault.modify%60) 23 | * @param editor 24 | */ 25 | constructor(plugin: LatexReferencer, file: TFile, public editor: Editor) { 26 | super(plugin, file); 27 | } 28 | 29 | async setLine(lineNumber: number, text: string): Promise { 30 | this.editor.setLine(lineNumber, text); 31 | } 32 | 33 | async setRange(position: Pos, text: string): Promise { 34 | const from = locToEditorPosition(position.start); 35 | const to = locToEditorPosition(position.end); 36 | this.editor.replaceRange(text, from, to); 37 | } 38 | 39 | async insertLine(lineNumber: number, text: string): Promise { 40 | this.editor.replaceRange(text + "\n", { line: lineNumber, ch: 0 }); 41 | } 42 | 43 | async getLine(lineNumber: number): Promise { 44 | return this.editor.getLine(lineNumber); 45 | } 46 | 47 | async getRange(position: Pos): Promise { 48 | const from = locToEditorPosition(position.start); 49 | const to = locToEditorPosition(position.end); 50 | const text = this.editor.getRange(from, to); 51 | return text; 52 | } 53 | } 54 | 55 | 56 | export class NonActiveNoteIO extends FileIO { 57 | _data: string | null = null; 58 | 59 | /** 60 | * File IO for non-active (= currently not opened / currently opened but not focused) notes. 61 | * Uses the Vault interface instead of Editor. 62 | */ 63 | constructor(plugin: LatexReferencer, file: TFile) { 64 | super(plugin, file); 65 | } 66 | 67 | async setLine(lineNumber: number, text: string): Promise { 68 | this.plugin.app.vault.process(this.file, (data: string): string => { 69 | const lines = splitIntoLines(data); 70 | lines[lineNumber] = text; 71 | return lines.join('\n'); 72 | }) 73 | } 74 | 75 | async setRange(position: Pos, text: string): Promise { 76 | this.plugin.app.vault.process(this.file, (data: string): string => { 77 | return data.slice(0, position.start.offset) + text + data.slice(position.end.offset + 1, data.length); 78 | }) 79 | } 80 | 81 | async insertLine(lineNumber: number, text: string): Promise { 82 | this.plugin.app.vault.process(this.file, (data: string): string => { 83 | const lines = splitIntoLines(data); 84 | insertAt(lines, text, lineNumber); 85 | return lines.join('\n'); 86 | }) 87 | } 88 | 89 | async getLine(lineNumber: number): Promise { 90 | const data = await this.plugin.app.vault.cachedRead(this.file); 91 | const lines = splitIntoLines(data); 92 | return lines[lineNumber]; 93 | } 94 | 95 | async getRange(position: Pos): Promise { 96 | const content = await this.plugin.app.vault.cachedRead(this.file); 97 | return content.slice(position.start.offset, position.end.offset); 98 | } 99 | } 100 | 101 | 102 | /** 103 | * Automatically judges which of ActiveNoteIO or NonActiveNoteIO 104 | * should be used for the given file. 105 | */ 106 | export function getIO(plugin: LatexReferencer, file: TFile, activeMarkdownView?: MarkdownView | null) { 107 | activeMarkdownView = activeMarkdownView ?? plugin.app.workspace.getActiveViewOfType(MarkdownView); 108 | if (activeMarkdownView && activeMarkdownView.file == file && isEditingView(activeMarkdownView)) { 109 | return new ActiveNoteIO(plugin, file, activeMarkdownView.editor); 110 | } else { 111 | return new NonActiveNoteIO(plugin, file); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/index/README.md: -------------------------------------------------------------------------------- 1 | The source code contained in this directory was taken from the Datacore plugin (https://github.com/blacksmithgu/datacore) and adapted. 2 | 3 | MIT License 4 | 5 | Copyright (c) 2023 Michael Brenan 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /src/index/expression/link.ts: -------------------------------------------------------------------------------- 1 | import { getFileTitle } from "../utils/normalizers"; 2 | 3 | /** The Obsidian 'link', used for uniquely describing a file, header, or block. */ 4 | export class Link { 5 | /** The file path this link points to. */ 6 | public path: string; 7 | /** The display name associated with the link. */ 8 | public display?: string; 9 | /** The block ID or header this link points to within a file, if relevant. */ 10 | public subpath?: string; 11 | /** Is this link an embedded link (of form '![[hello]]')? */ 12 | public embed: boolean; 13 | /** The type of this link, which determines what 'subpath' refers to, if anything. */ 14 | public type: "file" | "header" | "block"; 15 | 16 | /** Create a link to a specific file. */ 17 | public static file(path: string, embed: boolean = false, display?: string): Link { 18 | return new Link({ 19 | path, 20 | embed, 21 | display, 22 | subpath: undefined, 23 | type: "file", 24 | }); 25 | } 26 | 27 | /** Infer the type of the link from the full internal link path. */ 28 | public static infer(linkpath: string, embed: boolean = false, display?: string): Link { 29 | if (linkpath.includes("#^")) { 30 | let split = linkpath.split("#^"); 31 | return Link.block(split[0], split[1], embed, display); 32 | } else if (linkpath.includes("#")) { 33 | let split = linkpath.split("#"); 34 | return Link.header(split[0], split[1], embed, display); 35 | } else return Link.file(linkpath, embed, display); 36 | } 37 | 38 | /** Create a link to a specific file and header in that file. */ 39 | public static header(path: string, header: string, embed?: boolean, display?: string): Link { 40 | // Headers need to be normalized to alpha-numeric & with extra spacing removed. 41 | return new Link({ 42 | path, 43 | embed, 44 | display, 45 | subpath: header, //normalizeHeaderForLink(header), 46 | type: "header", 47 | }); 48 | } 49 | 50 | /** Create a link to a specific file and block in that file. */ 51 | public static block(path: string, blockId: string, embed?: boolean, display?: string): Link { 52 | return new Link({ 53 | path, 54 | embed, 55 | display, 56 | subpath: blockId, 57 | type: "block", 58 | }); 59 | } 60 | 61 | /** Load a link from it's raw JSON representation. */ 62 | public static fromObject(object: Record): Link { 63 | return new Link(object); 64 | } 65 | 66 | /** Create a link by parsing it's interior part (inside of the '[[]]'). */ 67 | public static parseInner(rawlink: string): Link { 68 | let [link, display] = splitOnUnescapedPipe(rawlink); 69 | return Link.infer(link, false, display); 70 | } 71 | 72 | private constructor(fields: Partial) { 73 | Object.assign(this, fields); 74 | } 75 | 76 | /** Update this link with a new path. */ 77 | public withPath(path: string): Link { 78 | return new Link(Object.assign({}, this, { path })); 79 | } 80 | 81 | /** Return a new link which points to the same location but with a new display value. */ 82 | public withDisplay(display?: string): Link { 83 | return new Link(Object.assign({}, this, { display })); 84 | } 85 | 86 | /** Return a new link which has the given embedded status. */ 87 | public withEmbed(embed: boolean): Link { 88 | if (this.embed == embed) return this; 89 | 90 | return new Link(Object.assign({}, this, { embed })); 91 | } 92 | 93 | /** Convert a file link into a link to a specific header. */ 94 | public withHeader(header: string): Link { 95 | return Link.header(this.path, header, this.embed, this.display); 96 | } 97 | 98 | /** Convert a file link into a link to a specificb lock. */ 99 | public withBlock(block: string): Link { 100 | return Link.block(this.path, block, this.embed, this.display); 101 | } 102 | 103 | /** Checks for link equality (i.e., that the links are pointing to the same exact location). */ 104 | public equals(other: Link): boolean { 105 | if (other == undefined || other == null) return false; 106 | 107 | return this.path == other.path && this.type == other.type && this.subpath == other.subpath; 108 | } 109 | 110 | /** Convert this link to it's markdown representation. */ 111 | public toString(): string { 112 | return this.markdown(); 113 | } 114 | 115 | /** Convert this link to a raw object which is serialization-friendly. */ 116 | public toObject(): Record { 117 | return { 118 | path: this.path, 119 | type: this.type, 120 | subpath: this.subpath, 121 | display: this.display, 122 | embed: this.embed, 123 | }; 124 | } 125 | 126 | /** Convert any link into a link to its file. */ 127 | public toFile() { 128 | return Link.file(this.path, this.embed, this.display); 129 | } 130 | 131 | /** Convert this link into an embedded link. */ 132 | public toEmbed(): Link { 133 | return this.withEmbed(true); 134 | } 135 | 136 | /** Convert this link into a non-embedded link. */ 137 | public fromEmbed(): Link { 138 | return this.withEmbed(false); 139 | } 140 | 141 | /** Convert this link to markdown so it can be rendered. */ 142 | public markdown(): string { 143 | let result = (this.embed ? "!" : "") + "[[" + this.obsidianLink(); 144 | result += this.displayOrDefault(); 145 | result += "]]"; 146 | return result; 147 | } 148 | 149 | /** Obtain the display for this link, or return a simple default display. */ 150 | public displayOrDefault() { 151 | if (this.display) { 152 | return this.display; 153 | } else { 154 | let result = getFileTitle(this.path); 155 | if (this.type == "header" || this.type == "block") result += " > " + this.subpath; 156 | 157 | return result; 158 | } 159 | } 160 | 161 | /** Convert the inner part of the link to something that Obsidian can open / understand. */ 162 | public obsidianLink(): string { 163 | const escaped = this.path.replace("|", "\\|"); 164 | if (this.type == "header") return escaped + "#" + this.subpath?.replace("|", "\\|"); 165 | if (this.type == "block") return escaped + "#^" + this.subpath?.replace("|", "\\|"); 166 | else return escaped; 167 | } 168 | 169 | /** The stripped name of the file this link points to. */ 170 | public fileName(): string { 171 | return getFileTitle(this.path); 172 | } 173 | } 174 | 175 | /** Split on unescaped pipes in an inner link. */ 176 | export function splitOnUnescapedPipe(link: string): [string, string | undefined] { 177 | let pipe = -1; 178 | while ((pipe = link.indexOf("|", pipe + 1)) >= 0) { 179 | if (pipe > 0 && link[pipe - 1] == "\\") continue; 180 | return [link.substring(0, pipe).replace(/\\\|/g, "|"), link.substring(pipe + 1)]; 181 | } 182 | 183 | return [link.replace(/\\\|/g, "|"), undefined]; 184 | } 185 | -------------------------------------------------------------------------------- /src/index/import/markdown.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "index/expression/link"; 2 | import { getFileTitle } from "index/utils/normalizers"; 3 | import { CachedMetadata, SectionCache } from "obsidian"; 4 | import BTree from "sorted-btree"; 5 | import { 6 | JsonMarkdownBlock, 7 | JsonMarkdownPage, 8 | JsonMarkdownSection, 9 | JsonTheoremCalloutBlock, 10 | JsonEquationBlock, 11 | } from "index/typings/json"; 12 | import { MinimalTheoremCalloutSettings } from "settings/settings"; 13 | import { parseMarkdownComment, parseYamlLike, readTheoremCalloutSettings, trimMathText } from "utils/parse"; 14 | import { parseLatexComment } from "utils/parse"; 15 | 16 | 17 | /** 18 | * Given the raw source and Obsidian metadata for a given markdown file, 19 | * return full markdown file metadata. 20 | */ 21 | export function markdownImport( 22 | path: string, 23 | markdown: string, 24 | metadata: CachedMetadata, 25 | excludeExample: boolean 26 | ): JsonMarkdownPage { 27 | // Total length of the file. 28 | const lines = markdown.split("\n"); 29 | const empty = !lines.some((line) => line.trim() !== ""); 30 | 31 | ////////////// 32 | // Sections // 33 | ////////////// 34 | 35 | const metaheadings = metadata.headings ?? []; 36 | metaheadings.sort((a, b) => a.position.start.line - b.position.start.line); 37 | 38 | const sections = new BTree(undefined, (a, b) => a - b); 39 | for (let index = 0; index < metaheadings.length; index++) { 40 | const section = metaheadings[index]; 41 | const start = section.position.start.line; 42 | const end = 43 | index == metaheadings.length - 1 ? lines.length - 1 : metaheadings[index + 1].position.start.line - 1; 44 | 45 | sections.set(start, { 46 | $ordinal: index + 1, 47 | $title: section.heading, 48 | $level: section.level, 49 | $position: { start, end }, 50 | $blocks: [], 51 | $links: [], 52 | }); 53 | } 54 | 55 | // Add an implicit section for the "heading" section of the page if there is not an immediate header but there is 56 | // some content in the file. If there are other sections, then go up to that, otherwise, go for the entire file. 57 | const firstSection: [number, JsonMarkdownSection] | undefined = sections.getPairOrNextHigher(0); 58 | if ((!firstSection && !empty) || (firstSection && !emptylines(lines, 0, firstSection[1].$position.start))) { 59 | const end = firstSection ? firstSection[1].$position.start - 1 : lines.length; 60 | sections.set(0, { 61 | $ordinal: 0, 62 | $title: getFileTitle(path), 63 | $level: 1, 64 | $position: { start: 0, end }, 65 | $blocks: [], 66 | $links: [], 67 | }); 68 | } 69 | 70 | //////////// 71 | // Blocks // 72 | //////////// 73 | 74 | // All blocks; we will assign tags and other metadata to blocks as we encounter them. At the end, only blocks that 75 | // have actual metadata will be stored to save on memory pressure. 76 | const blocks = new BTree(undefined, (a, b) => a - b); 77 | let blockOrdinal = 1; 78 | for (const block of metadata.sections || []) { 79 | // Skip headings blocks, we handle them specially as sections. 80 | if (block.type === "heading") continue; 81 | 82 | const start = block.position.start.line; 83 | const end = block.position.end.line; 84 | 85 | let theoremCalloutSettings: MinimalTheoremCalloutSettings | null = null; 86 | let v1 = false; 87 | if (block.type === "callout") { 88 | const settings = readTheoremCalloutSettings(lines[start], excludeExample); 89 | theoremCalloutSettings = settings ?? null; 90 | v1 = !!(settings?.legacy); 91 | } 92 | 93 | if (block.type === "math") { 94 | // Read the LaTeX source 95 | const mathText = trimMathText(getBlockText(markdown, block)); 96 | 97 | // If manually tagged (`\tag{...}`), extract the tag 98 | const tagMatch = mathText.match(/\\tag\{(.*)\}/); 99 | 100 | // Parse additional metadata from LaTeX comments 101 | const metadata: Record = {}; 102 | for (const line of mathText.split('\n')) { 103 | const { comment } = parseLatexComment(line); 104 | if (!comment) continue; 105 | Object.assign(metadata, parseYamlLike(comment)); 106 | } 107 | 108 | blocks.set(start, { 109 | $ordinal: blockOrdinal++, 110 | $position: { start, end }, 111 | $pos: block.position, 112 | $links: [], 113 | $blockId: block.id, 114 | $manualTag: tagMatch?.[1] ?? null, 115 | $mathText: mathText, 116 | $type: "equation", 117 | $label: metadata.label, 118 | $display: metadata.display, 119 | } as JsonEquationBlock); 120 | } else if (theoremCalloutSettings) { 121 | 122 | // Parse additional metadata from Markdown comments 123 | const contentText = lines.slice(start + 1, end + 1).join('\n'); 124 | const commentLines = parseMarkdownComment(contentText); 125 | const metadata: Record = {}; 126 | for (let line of commentLines) { 127 | if (line.startsWith('>')) line = line.slice(1).trim(); 128 | if (!line) continue; 129 | if (line === 'main') metadata.main = 'true'; // %% main %% is the same as %% main: true %% 130 | else Object.assign(metadata, parseYamlLike(line)); 131 | } 132 | 133 | blocks.set(start, { 134 | $ordinal: blockOrdinal++, 135 | $position: { start, end }, 136 | $pos: block.position, 137 | $links: [], 138 | $blockId: block.id, 139 | $settings: theoremCalloutSettings, 140 | $type: "theorem", 141 | $label: metadata.label, 142 | $display: metadata.display, 143 | $main: metadata.main === 'true', 144 | $v1: v1, 145 | } as JsonTheoremCalloutBlock); 146 | } else { 147 | blocks.set(start, { 148 | $ordinal: blockOrdinal++, 149 | $position: { start, end }, 150 | $pos: block.position, 151 | $links: [], 152 | $blockId: block.id, 153 | $type: block.type, 154 | }); 155 | } 156 | } 157 | 158 | // Add blocks to sections. 159 | for (const block of blocks.values() as Iterable) { 160 | const section = sections.getPairOrNextLower(block.$position.start); 161 | 162 | if (section && section[1].$position.end >= block.$position.end) { 163 | section[1].$blocks.push(block); 164 | } 165 | } 166 | 167 | /////////// 168 | // Links // 169 | /////////// 170 | 171 | const links: Link[] = []; 172 | for (let linkdef of metadata.links ?? []) { 173 | const link = Link.infer(linkdef.link); 174 | const line = linkdef.position.start.line; 175 | addLink(links, link); 176 | 177 | const section = sections.getPairOrNextLower(line); 178 | if (section && section[1].$position.end >= line) addLink(section[1].$links, link); 179 | 180 | const block = blocks.getPairOrNextLower(line); 181 | if (block && block[1].$position.end >= line) addLink(block[1].$links, link); 182 | 183 | const listItem = blocks.getPairOrNextHigher(line); 184 | if (listItem && listItem[1].$position.end >= line) addLink(listItem[1].$links, link); 185 | } 186 | 187 | /////////////////////// 188 | // Frontmatter Links // 189 | /////////////////////// 190 | 191 | // Frontmatter links are only assigned to the page. 192 | for (const linkdef of metadata.frontmatterLinks ?? []) { 193 | const link = Link.infer(linkdef.link, false, linkdef.displayText); 194 | addLink(links, link); 195 | } 196 | 197 | return { 198 | $path: path, 199 | $links: links, 200 | $sections: sections.valuesArray(), 201 | $extension: "md", 202 | $position: { start: 0, end: lines.length }, 203 | }; 204 | } 205 | 206 | /** Check if the given line range is all empty. Start is inclusive, end exclusive. */ 207 | function emptylines(lines: string[], start: number, end: number): boolean { 208 | for (let index = start; index < end; index++) { 209 | if (lines[index].trim() !== "") return false; 210 | } 211 | 212 | return true; 213 | } 214 | 215 | /** 216 | * Mutably add the given link to the list only if it is not already present. 217 | * This is O(n) but should be fine for most files; we could eliminate the O(n) by instead 218 | * using intermediate sets but not worth the complexity. 219 | */ 220 | function addLink(target: Link[], incoming: Link) { 221 | if (target.find((v) => v.equals(incoming))) return; 222 | target.push(incoming); 223 | } 224 | 225 | function getBlockText(data: string, block: SectionCache) { 226 | return data.slice(block.position.start.offset, block.position.end.offset); 227 | } -------------------------------------------------------------------------------- /src/index/storage/inverted.ts: -------------------------------------------------------------------------------- 1 | /** Tracks an inverted index of value -> set. */ 2 | export class InvertedIndex { 3 | private inverted: Map>; 4 | 5 | public constructor() { 6 | this.inverted = new Map(); 7 | } 8 | 9 | /** Set the key to the given values. */ 10 | public set(key: string, values: Iterable) { 11 | for (let value of values) { 12 | if (!this.inverted.has(value)) this.inverted.set(value, new Set()); 13 | this.inverted.get(value)!.add(key); 14 | } 15 | } 16 | 17 | /** Get all keys that map to the given value. */ 18 | public get(value: V): Set { 19 | return this.inverted.get(value) ?? InvertedIndex.EMPTY_SET; 20 | } 21 | 22 | /** Delete a key from the set of associated values. */ 23 | public delete(key: string, values: Iterable) { 24 | for (let value of values) { 25 | const set = this.inverted.get(value); 26 | if (set) { 27 | set.delete(key); 28 | } 29 | 30 | if (set && set.size == 0) { 31 | this.inverted.delete(value); 32 | } 33 | } 34 | } 35 | 36 | public clear() { 37 | this.inverted.clear(); 38 | } 39 | 40 | private static EMPTY_SET: Set = new Set(); 41 | } 42 | -------------------------------------------------------------------------------- /src/index/typings/indexable.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "../expression/link"; 2 | // import { DateTime } from "luxon"; 3 | 4 | /** The names of all index fields that are present on ALL indexed types. */ 5 | export const INDEX_FIELDS = new Set([ 6 | "$types", 7 | // "$typename", 8 | "$id", 9 | "$revision" 10 | ]); 11 | 12 | /** Any indexable field, which must have a few index-relevant properties. */ 13 | export interface Indexable { 14 | /** The object types that this indexable is. */ 15 | $types: string[]; 16 | // /** Textual description of the object, such as `Page` or `Section`. Used in visualizations. */ 17 | // $typename: string; 18 | /** The unique index ID for this object. */ 19 | $id: string; 20 | /** 21 | * The indexable object that is the parent of this object. Only set after the object is actually indexed. 22 | */ 23 | $parent?: Indexable; 24 | /** If present, the revision in the index of this object. */ 25 | $revision?: number; 26 | /** The file that this indexable was derived from, if file-backed. */ 27 | $file?: string; 28 | } 29 | 30 | /** Metadata for objects which support linking. */ 31 | export const LINKABLE_TYPE = "linkable"; 32 | export interface Linkable { 33 | /** A link to this linkable object. */ 34 | $link: Link; 35 | } 36 | 37 | /** General metadata for any file. */ 38 | export const FILE_TYPE = "file"; 39 | export interface File extends Linkable { 40 | /** The path this file exists at. */ 41 | $path: string; 42 | // /** Obsidian-provided date this page was created. */ 43 | // $ctime: DateTime; 44 | // /** Obsidian-provided date this page was modified. */ 45 | // $mtime: DateTime; 46 | // /** Obsidian-provided size of this page in bytes. */ 47 | // $size: number; 48 | /** The extension of the file. */ 49 | $extension: string; 50 | } 51 | 52 | // /** Metadata for taggable objects. */ 53 | // export const TAGGABLE_TYPE = "taggable"; 54 | // export interface Taggable { 55 | // /** The exact tags on this object. (#a/b/c or #foo/bar). */ 56 | // $tags: string[]; 57 | // } 58 | 59 | /** Metadata for objects which can link to other things. */ 60 | export const LINKBEARING_TYPE = "links"; 61 | export interface Linkbearing { 62 | /** The links in this file. */ 63 | $links: Link[]; 64 | } 65 | -------------------------------------------------------------------------------- /src/index/typings/json.ts: -------------------------------------------------------------------------------- 1 | //! Note: These are "serialization" types for metadata, which contain 2 | // the absolute minimum information needed to save and load data. 3 | 4 | import { Link } from "index/expression/literal"; 5 | import { Pos } from "obsidian"; 6 | import { MinimalTheoremCalloutSettings, TheoremCalloutSettings } from "settings/settings"; 7 | 8 | /** A span of contiguous lines. */ 9 | export interface LineSpan { 10 | /** The inclusive start line. */ 11 | start: number; 12 | /** The inclusive end line. */ 13 | end: number; 14 | } 15 | 16 | /** Stores just the minimal information needed to create a markdown file; used for saving and loading these files. */ 17 | export interface JsonMarkdownPage { 18 | /** The path this file exists at. */ 19 | $path: string; 20 | /** The extension; for markdown files, almost always '.md'. */ 21 | $extension: string; 22 | /** The full extent of the file (start 0, end the number of lines in the file.) */ 23 | $position: LineSpan; 24 | /** All links in the file. */ 25 | $links: Link[]; 26 | /** 27 | * All child markdown sections of this markdown file. The initial section before any content is special and is 28 | * named with the title of the file. 29 | */ 30 | $sections: JsonMarkdownSection[]; 31 | } 32 | 33 | export interface JsonMarkdownSection { 34 | /** The index of this section in the file. */ 35 | $ordinal: number; 36 | /** The title of the section; the root (implicit) section will have the title of the page. */ 37 | $title: string; 38 | /** The indentation level of the section (1 - 6). */ 39 | $level: number; 40 | /** The span of lines indicating the position of the section. */ 41 | $position: LineSpan; 42 | // /** All tags on the file. */ 43 | // $tags: string[]; 44 | /** All links in the file. */ 45 | /** -> All links in the SECTION? */ 46 | $links: Link[]; 47 | /** All of the markdown blocks in this section. */ 48 | $blocks: JsonMarkdownBlock[]; 49 | } 50 | 51 | export interface JsonMarkdownBlock { 52 | /** The index of this block in the file. */ 53 | $ordinal: number; 54 | /** The position/extent of the block. */ 55 | $position: LineSpan; 56 | $pos: Pos; 57 | /** All links in the file. */ 58 | $links: Link[]; 59 | /** If present, the distinct block ID for this block. */ 60 | $blockId?: string; 61 | /** The type of block - paragraph, list, and so on. */ 62 | $type: string; 63 | } 64 | 65 | export interface JsonMathBlock extends JsonMarkdownBlock { 66 | $type: "theorem" | "equation"; 67 | $label?: string; 68 | $display?: string; 69 | } 70 | 71 | export interface JsonTheoremCalloutBlock extends JsonMathBlock { 72 | $type: "theorem"; 73 | $settings: MinimalTheoremCalloutSettings; 74 | $main: boolean; 75 | $v1: boolean; 76 | } 77 | 78 | export interface JsonEquationBlock extends JsonMathBlock { 79 | $type: "equation"; 80 | $manualTag: string | null; 81 | $mathText: string; 82 | } 83 | -------------------------------------------------------------------------------- /src/index/utils/deferred.ts: -------------------------------------------------------------------------------- 1 | /** A promise that can be resolved directly. */ 2 | export type Deferred = Promise & { 3 | resolve: (value: T) => void; 4 | reject: (error: any) => void; 5 | }; 6 | 7 | /** Create a new deferred object, which is a resolvable promise. */ 8 | export function deferred(): Deferred { 9 | let resolve: (value: T) => void; 10 | let reject: (error: any) => void; 11 | 12 | const promise = new Promise((res, rej) => { 13 | resolve = res; 14 | reject = rej; 15 | }); 16 | 17 | const deferred = promise as any as Deferred; 18 | deferred.resolve = resolve!; 19 | deferred.reject = reject!; 20 | 21 | return deferred; 22 | } 23 | -------------------------------------------------------------------------------- /src/index/utils/normalizers.ts: -------------------------------------------------------------------------------- 1 | /** Get the "title" for a file, by stripping other parts of the path as well as the extension. */ 2 | export function getFileTitle(path: string): string { 3 | if (path.includes("/")) path = path.substring(path.lastIndexOf("/") + 1); 4 | if (path.endsWith(".md")) path = path.substring(0, path.length - 3); 5 | return path; 6 | } 7 | -------------------------------------------------------------------------------- /src/index/web-worker/importer.ts: -------------------------------------------------------------------------------- 1 | /** Controls and creates Dataview file importers, allowing for asynchronous loading and parsing of files. */ 2 | 3 | import { Component, MetadataCache, TFile, Vault } from "obsidian"; 4 | import LatexReferencer from 'main'; 5 | import { Transferable } from "./transferable"; 6 | import ImportWorker from "index/web-worker/importer.worker"; 7 | import { ImportCommand } from "./message"; 8 | 9 | /** Settings for throttling import. */ 10 | export interface ImportThrottle { 11 | /** The number of workers to use for imports. */ 12 | workers: number; 13 | /** A number between 0.1 and 1 which indicates total cpu utilization target; 0.1 means spend 10% of time */ 14 | utilization: number; 15 | } 16 | 17 | /** Default throttle configuration. */ 18 | export const DEFAULT_THROTTLE: ImportThrottle = { 19 | workers: 2, 20 | utilization: 0.75, 21 | }; 22 | 23 | /** Multi-threaded file parser which debounces rapid file requests automatically. */ 24 | export class MathImporter extends Component { 25 | /* Background workers which do the actual file parsing. */ 26 | workers: Map; 27 | /** The next worker ID to hand out. */ 28 | nextWorkerId: number; 29 | /** Is the importer active? */ 30 | shutdown: boolean; 31 | 32 | /** List of files which have been queued for a reload. */ 33 | queue: [TFile, (success: any) => void, (failure: any) => void][]; 34 | /** Outstanding loads indexed by path. */ 35 | outstanding: Map>; 36 | /** Throttle settings. */ 37 | throttle: () => ImportThrottle; 38 | 39 | public constructor(public plugin: LatexReferencer, public vault: Vault, public metadataCache: MetadataCache, throttle?: () => ImportThrottle) { 40 | super(); 41 | this.workers = new Map(); 42 | this.shutdown = false; 43 | this.nextWorkerId = 0; 44 | this.throttle = throttle ?? (() => DEFAULT_THROTTLE); 45 | 46 | this.queue = []; 47 | this.outstanding = new Map(); 48 | } 49 | 50 | /** 51 | * Queue the given file for importing. Multiple import requests for the same file in a short time period will be de-bounced 52 | * and all be resolved by a single actual file reload. 53 | */ 54 | public import(file: TFile): Promise { 55 | // De-bounce repeated requests for the same file. 56 | let existing = this.outstanding.get(file.path); 57 | if (existing) return existing; 58 | 59 | let promise: Promise = new Promise((resolve, reject) => { 60 | this.queue.push([file, resolve, reject]); 61 | }); 62 | 63 | this.outstanding.set(file.path, promise); 64 | this.schedule(); 65 | return promise; 66 | } 67 | 68 | /** Reset any active throttles on the importer (such as if the utilization changes). */ 69 | public unthrottle() { 70 | for (let worker of this.workers.values()) { 71 | worker.availableAt = Date.now(); 72 | } 73 | } 74 | 75 | /** Poll from the queue and execute if there is an available worker. */ 76 | private schedule() { 77 | if (this.queue.length == 0 || this.shutdown) return; 78 | 79 | const worker = this.availableWorker(); 80 | if (!worker) return; 81 | 82 | const [file, resolve, reject] = this.queue.shift()!; 83 | 84 | worker.active = [file, resolve, reject, Date.now()]; 85 | this.vault.cachedRead(file).then((c) => 86 | worker!.worker.postMessage( 87 | Transferable.transferable({ 88 | type: "markdown", 89 | path: file.path, 90 | contents: c, 91 | metadata: this.metadataCache.getFileCache(file), 92 | excludeExampleCallout: this.plugin.extraSettings.excludeExampleCallout, 93 | } as ImportCommand) 94 | ) 95 | ); 96 | } 97 | 98 | /** Finish the parsing of a file, potentially queueing a new file. */ 99 | private finish(worker: PoolWorker, data: any) { 100 | let [file, resolve, reject] = worker.active!; 101 | 102 | // Resolve promises to let users know this file has finished. 103 | if ("$error" in data) reject(data["$error"]); 104 | else resolve(data); 105 | 106 | // Remove file from outstanding. 107 | this.outstanding.delete(file.path); 108 | 109 | // Remove this worker if we are over capacity. 110 | // Otherwise, notify the queue this file is available for new work. 111 | if (this.workers.size > this.throttle().workers) { 112 | this.workers.delete(worker.id); 113 | terminate(worker); 114 | } else { 115 | const now = Date.now(); 116 | const start = worker.active![3]; 117 | const throttle = Math.max(0.1, this.throttle().utilization) - 1.0; 118 | const delay = (now - start) * throttle; 119 | 120 | worker.active = undefined; 121 | 122 | if (delay <= 1e-10) { 123 | worker.availableAt = now; 124 | this.schedule(); 125 | } else { 126 | worker.availableAt = now + delay; 127 | 128 | // Note: I'm pretty sure this will garauntee that this executes AFTER delay milliseconds, 129 | // so this should be fine; if it's not, we'll have to swap to an external timeout loop 130 | // which infinitely reschedules itself to the next available execution time. 131 | setTimeout(this.schedule.bind(this), delay); 132 | } 133 | } 134 | } 135 | 136 | /** Obtain an available worker, returning undefined if one does not exist. */ 137 | private availableWorker(): PoolWorker | undefined { 138 | const now = Date.now(); 139 | for (let worker of this.workers.values()) { 140 | if (!worker.active && worker.availableAt <= now) { 141 | return worker; 142 | } 143 | } 144 | 145 | // Make a new worker if we can. 146 | if (this.workers.size < this.throttle().workers) { 147 | let worker = this.newWorker(); 148 | this.workers.set(worker.id, worker); 149 | return worker; 150 | } 151 | 152 | return undefined; 153 | } 154 | 155 | /** Create a new worker bound to this importer. */ 156 | private newWorker(): PoolWorker { 157 | let worker: PoolWorker = { 158 | id: this.nextWorkerId++, 159 | availableAt: Date.now(), 160 | worker: new ImportWorker(), 161 | }; 162 | 163 | worker.worker.onmessage = (evt) => this.finish(worker, Transferable.value(evt.data)); 164 | return worker; 165 | } 166 | 167 | /** Reject all outstanding promises and close all workers on close. */ 168 | public onunload(): void { 169 | for (let worker of this.workers.values()) { 170 | terminate(worker); 171 | } 172 | 173 | for (let [_file, _success, reject] of this.queue) { 174 | reject("Terminated"); 175 | } 176 | 177 | this.shutdown = true; 178 | } 179 | } 180 | 181 | /** A worker in the pool of executing workers. */ 182 | interface PoolWorker { 183 | /** The id of this worker. */ 184 | id: number; 185 | /** The raw underlying worker. */ 186 | worker: Worker; 187 | /** UNIX time indicating the next time this worker is available for execution according to target utilization. */ 188 | availableAt: number; 189 | /** The active promise this worker is working on, if any. */ 190 | active?: [TFile, (success: any) => void, (failure: any) => void, number]; 191 | } 192 | 193 | /** Terminate a pool worker. */ 194 | function terminate(worker: PoolWorker) { 195 | worker.worker.terminate(); 196 | 197 | if (worker.active) worker.active[2]("Terminated"); 198 | worker.active = undefined; 199 | } 200 | -------------------------------------------------------------------------------- /src/index/web-worker/importer.worker.ts: -------------------------------------------------------------------------------- 1 | import { markdownImport } from "index/import/markdown"; 2 | import { ImportCommand, MarkdownImportResult } from "index/web-worker/message"; 3 | import { Transferable } from "index/web-worker/transferable"; 4 | 5 | /** Web worker entry point for importing. */ 6 | onmessage = (event) => { 7 | try { 8 | const message = Transferable.value(event.data) as ImportCommand; 9 | 10 | if (message.type === "markdown") { 11 | const markdown = markdownImport(message.path, message.contents, message.metadata, message.excludeExampleCallout); 12 | 13 | postMessage( 14 | Transferable.transferable({ 15 | type: "markdown", 16 | result: markdown, 17 | } as MarkdownImportResult) 18 | ); 19 | } else { 20 | postMessage({ $error: "Unsupported import method." }); 21 | } 22 | } catch (error) { 23 | postMessage({ $error: error.message }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/index/web-worker/message.ts: -------------------------------------------------------------------------------- 1 | import { JsonMarkdownPage } from "index/typings/json"; 2 | import { CachedMetadata, FileStats } from "obsidian"; 3 | 4 | /** A command to import a markdown file. */ 5 | export interface MarkdownImport { 6 | type: "markdown"; 7 | 8 | /** The path we are importing. */ 9 | path: string; 10 | /** The file contents to import. */ 11 | contents: string; 12 | // /** The stats for the file. */ 13 | // stat: FileStats; 14 | /** Metadata for the file. */ 15 | metadata: CachedMetadata; 16 | excludeExampleCallout: boolean; 17 | } 18 | 19 | 20 | /** Available import commands to be sent to an import web worker. */ 21 | export type ImportCommand = MarkdownImport; //| CanvasImport; 22 | 23 | /** The result of importing a file of some variety. */ 24 | export interface MarkdownImportResult { 25 | /** The type of import. */ 26 | type: "markdown"; 27 | /** The result of importing. */ 28 | result: JsonMarkdownPage; 29 | } 30 | 31 | export interface ImportFailure { 32 | /** Failed to import. */ 33 | type: "error"; 34 | 35 | /** The error that the worker indicated on failure. */ 36 | $error: string; 37 | } 38 | 39 | export type ImportResult = MarkdownImportResult | ImportFailure; 40 | -------------------------------------------------------------------------------- /src/index/web-worker/transferable.ts: -------------------------------------------------------------------------------- 1 | import { Link, Literals } from "index/expression/literal"; 2 | // import { DateTime, Duration, SystemZone } from "luxon"; 3 | 4 | /** Simplifies passing complex values across the JS web worker barrier. */ 5 | export namespace Transferable { 6 | /** Convert a literal value to a serializer-friendly transferable value. */ 7 | export function transferable(value: any): any { 8 | // Handle simple universal types first. 9 | if (value instanceof Map) { 10 | let copied = new Map(); 11 | for (let [key, val] of value.entries()) copied.set(transferable(key), transferable(val)); 12 | return copied; 13 | } else if (value instanceof Set) { 14 | let copied = new Set(); 15 | for (let val of value) copied.add(transferable(val)); 16 | return copied; 17 | } 18 | 19 | let wrapped = Literals.wrapValue(value); 20 | if (wrapped === undefined) throw Error("Unrecognized transferable value: " + value); 21 | 22 | switch (wrapped.type) { 23 | case "null": 24 | case "number": 25 | case "string": 26 | case "boolean": 27 | return wrapped.value; 28 | // case "date": 29 | // return { 30 | // "$transfer-type": "date", 31 | // value: transferable(wrapped.value.toObject()), 32 | // options: { 33 | // zone: wrapped.value.zone.equals(SystemZone.instance) ? undefined : wrapped.value.zoneName, 34 | // }, 35 | // }; 36 | // case "duration": 37 | // return { 38 | // "$transfer-type": "duration", 39 | // value: transferable(wrapped.value.toObject()), 40 | // }; 41 | case "array": 42 | return wrapped.value.map((v) => transferable(v)); 43 | case "link": 44 | return { 45 | "$transfer-type": "link", 46 | value: transferable(wrapped.value.toObject()), 47 | }; 48 | case "object": 49 | let result: Record = {}; 50 | 51 | // Only copy owned properties, and not derived/readonly properties like getters/computed fields. 52 | for (let key of Object.getOwnPropertyNames(wrapped.value)) 53 | result[key] = transferable(wrapped.value[key]); 54 | return result; 55 | } 56 | } 57 | 58 | /** Convert a transferable value back to a literal value we can work with. */ 59 | export function value(transferable: any): any { 60 | if (transferable === null) { 61 | return null; 62 | } else if (transferable === undefined) { 63 | return undefined; 64 | } else if (transferable instanceof Map) { 65 | let real = new Map(); 66 | for (let [key, val] of transferable.entries()) real.set(value(key), value(val)); 67 | return real; 68 | } else if (transferable instanceof Set) { 69 | let real = new Set(); 70 | for (let val of transferable) real.add(value(val)); 71 | return real; 72 | } else if (Array.isArray(transferable)) { 73 | return transferable.map((v) => value(v)); 74 | } else if (typeof transferable === "object") { 75 | if ("$transfer-type" in transferable) { 76 | switch (transferable["$transfer-type"]) { 77 | // case "date": 78 | // let dateOpts = value(transferable.options); 79 | // let dateData = value(transferable.value) as any; 80 | 81 | // return DateTime.fromObject(dateData, { zone: dateOpts.zone }); 82 | // case "duration": 83 | // return Duration.fromObject(value(transferable.value)); 84 | case "link": 85 | return Link.fromObject(value(transferable.value)); 86 | default: 87 | throw Error(`Unrecognized transfer type '${transferable["$transfer-type"]}'`); 88 | } 89 | } 90 | 91 | let result: Record = {}; 92 | for (let [key, val] of Object.entries(transferable)) result[key] = value(val); 93 | return result; 94 | } 95 | 96 | return transferable; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/patches/link-completion.ts: -------------------------------------------------------------------------------- 1 | import { EditorSuggest, Notice, TFile, renderMath } from "obsidian"; 2 | import { around } from "monkey-around"; 3 | 4 | import LatexReferencer from "main"; 5 | import { MarkdownPage, TheoremCalloutBlock } from 'index/typings/markdown'; 6 | import { isTheoremCallout, resolveSettings } from 'utils/plugin'; 7 | import { formatTitle } from 'utils/format'; 8 | import { _readTheoremCalloutSettings } from 'utils/parse'; 9 | import { capitalize } from 'utils/general'; 10 | import { renderTextWithMath } from "utils/render"; 11 | 12 | export const patchLinkCompletion = (plugin: LatexReferencer) => { 13 | const suggest = (plugin.app.workspace as any).editorSuggest.suggests[0]; // built-in link completion 14 | if (!Object.hasOwn(suggest, 'suggestManager')) new Notice(`Failed to patch Obsidian\'s built-in link completion. Please reload ${plugin.manifest.name}.`); 15 | const prototype = suggest.constructor.prototype as EditorSuggest; 16 | 17 | plugin.register(around(prototype, { 18 | renderSuggestion(old) { 19 | return function (item: any, el: HTMLElement) { 20 | old.call(this, item, el); 21 | 22 | if (plugin.extraSettings.showTheoremTitleinBuiltin && item.type === 'block' && item.node.type === 'callout' && isTheoremCallout(plugin, item.node.callout.type)) { 23 | let title: string = item.node.children.find((child: any) => child.type === 'callout-title')?.children.map((child: any) => child.value).join('') ?? ''; 24 | const content = item.display.slice(title.length); 25 | const page = plugin.indexManager.index.load(item.file.path); 26 | if (MarkdownPage.isMarkdownPage(page)) { 27 | const block = page.getBlockByLineNumber(item.node.position.start.line - 1); // line number starts from 1 28 | if (TheoremCalloutBlock.isTheoremCalloutBlock(block)) { 29 | renderInSuggestionTitleEl(el, (suggestionTitleEl) => { 30 | el.addClass('math-booster', 'suggestion-item-theorem-callout'); 31 | suggestionTitleEl.replaceChildren(); 32 | const children = renderTextWithMath(block.$printName); 33 | suggestionTitleEl 34 | .createDiv() 35 | .replaceChildren(...children); 36 | if (plugin.extraSettings.showTheoremContentinBuiltin && content) suggestionTitleEl.createDiv({ text: content }); 37 | }); 38 | return; 39 | } 40 | } 41 | const parsed = _readTheoremCalloutSettings({ type: item.node.callout.type, metadata: item.node.callout.data }, plugin.extraSettings.excludeExampleCallout); 42 | if (parsed) { 43 | const { type, number } = parsed; 44 | if (title === capitalize(type)) title = ''; 45 | const formattedTitle = formatTitle(plugin, item.file as TFile, resolveSettings({ type, number, title }, plugin, item.file as TFile), true); 46 | renderInSuggestionTitleEl(el, (suggestionTitleEl) => { 47 | el.addClass('math-booster', 'suggestion-item-theorem-callout'); 48 | suggestionTitleEl.replaceChildren(); 49 | const children = renderTextWithMath(formattedTitle); 50 | suggestionTitleEl 51 | .createDiv() 52 | .replaceChildren(...children); 53 | if (plugin.extraSettings.showTheoremContentinBuiltin && content) suggestionTitleEl.createDiv({ text: content }); 54 | }); 55 | return; 56 | } 57 | } 58 | } 59 | } 60 | })); 61 | }; 62 | 63 | 64 | function renderInSuggestionTitleEl(el: HTMLElement, cb: (suggestionTitleEl: HTMLElement) => void) { 65 | // setTimeout(() => { 66 | const suggestionTitleEl = el.querySelector('.suggestion-title'); 67 | if (suggestionTitleEl) cb(suggestionTitleEl); 68 | // }); 69 | } -------------------------------------------------------------------------------- /src/patches/page-preview.ts: -------------------------------------------------------------------------------- 1 | import { HoverParent } from 'obsidian'; 2 | import { around } from 'monkey-around'; 3 | import LatexReferencer from '../main'; 4 | 5 | // Inspired by Hover Editor (https://github.com/nothingislost/obsidian-hover-editor/blob/c038424acb15c542f0ad5f901d74c75d4316f553/src/main.ts#L396) 6 | 7 | // Save the last linktext that triggered hover page preview in the plugin instance to display theorem/equation numbers in it 8 | 9 | export const patchPagePreview = (plugin: LatexReferencer) => { 10 | const { app } = plugin; 11 | 12 | plugin.register( 13 | // @ts-ignore 14 | around(app.internalPlugins.plugins['page-preview'].instance.constructor.prototype, { 15 | onLinkHover(old: Function) { 16 | return function (parent: HoverParent, targetEl: HTMLElement, linktext: string, ...args: unknown[]) { 17 | old.call(this, parent, targetEl, linktext, ...args); 18 | // Save the linktext in the plugin instance 19 | plugin.lastHoverLinktext = linktext; 20 | } 21 | } 22 | }) 23 | ); 24 | } -------------------------------------------------------------------------------- /src/proof/common.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from "settings/profile"; 2 | 3 | export function makeProofClasses(which: "begin" | "end", profile: Profile) { 4 | return [ 5 | "math-booster-" + which + "-proof", // deprecated 6 | "latex-referencer-" + which + "-proof", 7 | ...profile.meta.tags.map((tag) => "math-booster-" + which + "-proof-" + tag), // deprecated 8 | ...profile.meta.tags.map((tag) => "latex-referencer-" + which + "-proof-" + tag) 9 | ]; 10 | } 11 | 12 | export function makeProofElement(which: "begin" | "end", profile: Profile) { 13 | return createSpan({ 14 | text: profile.body.proof[which], 15 | cls: makeProofClasses(which, profile) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/proof/live-preview.ts: -------------------------------------------------------------------------------- 1 | import { editorInfoField } from 'obsidian'; 2 | import { RangeSetBuilder } from '@codemirror/state'; 3 | import { Decoration, DecorationSet, EditorView, PluginValue, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view'; 4 | import { SyntaxNodeRef } from '@lezer/common'; 5 | import { syntaxTree } from '@codemirror/language'; 6 | 7 | import LatexReferencer from 'main'; 8 | import { nodeText, rangesHaveOverlap } from 'utils/editor'; 9 | import { Profile } from 'settings/profile'; 10 | import { renderMarkdown } from 'utils/render'; 11 | import { resolveSettings } from 'utils/plugin'; 12 | import { makeProofClasses, makeProofElement } from './common'; 13 | 14 | export const INLINE_CODE = "inline-code"; 15 | export const LINK_BEGIN = "formatting-link_formatting-link-start"; 16 | export const LINK = "hmd-internal-link"; 17 | export const LINK_END = "formatting-link_formatting-link-end"; 18 | 19 | 20 | abstract class ProofWidget extends WidgetType { 21 | containerEl: HTMLElement | null; 22 | 23 | constructor(public plugin: LatexReferencer, public profile: Profile) { 24 | super(); 25 | this.containerEl = null; 26 | } 27 | 28 | eq(other: EndProofWidget): boolean { 29 | return this.profile.id === other.profile.id; 30 | } 31 | 32 | toDOM(): HTMLElement { 33 | return this.containerEl ?? (this.containerEl = this.initDOM()); 34 | } 35 | 36 | abstract initDOM(): HTMLElement; 37 | 38 | ignoreEvent(event: Event): boolean { 39 | // the DOM element won't respond to clicks without this 40 | return false; 41 | } 42 | } 43 | 44 | class BeginProofWidget extends ProofWidget { 45 | containerEl: HTMLElement | null; 46 | 47 | constructor( 48 | plugin: LatexReferencer, profile: Profile, 49 | public display: string | null, 50 | public linktext: string | null, 51 | public sourcePath: string 52 | ) { 53 | super(plugin, profile); 54 | } 55 | 56 | eq(other: BeginProofWidget): boolean { 57 | return this.profile.id === other.profile.id && this.display === other.display && this.linktext === other.linktext && this.sourcePath == other.sourcePath; 58 | } 59 | 60 | initDOM(): HTMLElement { 61 | let display = this.linktext 62 | ? `${this.profile.body.proof.linkedBeginPrefix} [[${this.linktext}]]${this.profile.body.proof.linkedBeginSuffix}` 63 | : this.display; 64 | 65 | if (display) { 66 | const el = createSpan({ cls: makeProofClasses("begin", this.profile) }); 67 | BeginProofWidget.renderDisplay(el, display, this.sourcePath, this.plugin); 68 | return el; 69 | } 70 | 71 | return makeProofElement("begin", this.profile); 72 | } 73 | 74 | static async renderDisplay(el: HTMLElement, display: string, sourcePath: string, plugin: LatexReferencer) { 75 | const children = await renderMarkdown(display, sourcePath, plugin); 76 | if (children) { 77 | el.replaceChildren(...children); 78 | } 79 | } 80 | } 81 | 82 | 83 | class EndProofWidget extends ProofWidget { 84 | containerEl: HTMLElement | null; 85 | 86 | initDOM(): HTMLElement { 87 | return makeProofElement("end", this.profile); 88 | } 89 | } 90 | 91 | 92 | export interface ProofPosition { 93 | display?: string, 94 | linktext?: string, 95 | linknodes?: { linkBegin: SyntaxNodeRef, link: SyntaxNodeRef, linkEnd: SyntaxNodeRef }, 96 | } 97 | 98 | 99 | export const createProofDecoration = (plugin: LatexReferencer) => ViewPlugin.fromClass( 100 | class implements PluginValue { 101 | decorations: DecorationSet; 102 | 103 | constructor(view: EditorView) { 104 | this.decorations = this.makeDeco(view); 105 | } 106 | 107 | update(update: ViewUpdate) { 108 | if (update.docChanged || update.viewportChanged || update.selectionSet) { 109 | if (update.view.composing) { 110 | this.decorations = this.decorations.map(update.changes); // User is using IME 111 | } else { 112 | this.decorations = this.makeDeco(update.view); 113 | } 114 | } 115 | } 116 | 117 | makeDeco(view: EditorView): DecorationSet { 118 | const { state } = view; 119 | const { app } = plugin; 120 | const tree = syntaxTree(state); 121 | const ranges = state.selection.ranges; 122 | 123 | const file = state.field(editorInfoField).file; 124 | const sourcePath = file?.path ?? ""; 125 | const settings = resolveSettings(undefined, plugin, file ?? app.vault.getRoot()); 126 | const profile = plugin.extraSettings.profiles[settings.profile]; 127 | 128 | const builder = new RangeSetBuilder(); 129 | 130 | for (const { from, to } of view.visibleRanges) { 131 | tree.iterate({ 132 | from, to, 133 | enter(node) { 134 | if (node.name !== INLINE_CODE) return; 135 | 136 | let start = -1; 137 | let end = -1; 138 | let display: string | null = null; 139 | let linktext: string | null = null; 140 | 141 | const text = nodeText(node, state); 142 | 143 | if (text.startsWith(settings.beginProof)) { 144 | // handle "\begin{proof}" 145 | const rest = text.slice(settings.beginProof.length); 146 | if (!rest) { // only "\begin{proof}" 147 | start = node.from - 1; 148 | end = node.to + 1; // 1 = "`".length 149 | display = null; 150 | } else { 151 | const match = rest.match(/^\[(.*)\]$/); 152 | if (match) { // custom display text is given, e.g. "\begin{proof}[Solutions.]" 153 | start = node.from - 1; 154 | end = node.to + 1; // 1 = "`".length 155 | display = match[1]; 156 | } 157 | } 158 | 159 | if (start === -1 || end === -1) return; // not proof 160 | 161 | if (state.sliceDoc(node.to + 1, node.to + 2) == "@") { // Check if "`\begin{proof}`@[[link]]" or "`\begin{proof}[display]`@[[link]]" 162 | const next = node.node.nextSibling?.nextSibling; 163 | const afterNext = node.node.nextSibling?.nextSibling?.nextSibling; 164 | const afterAfterNext = node.node.nextSibling?.nextSibling?.nextSibling?.nextSibling; 165 | if (next?.name === LINK_BEGIN && afterNext?.name === LINK && afterAfterNext?.name === LINK_END) { 166 | linktext = nodeText(afterNext, state); 167 | end = afterAfterNext.to; 168 | } 169 | } 170 | 171 | if (!rangesHaveOverlap(ranges, start, end)) { 172 | builder.add( 173 | start, end, 174 | Decoration.replace({ 175 | widget: new BeginProofWidget(plugin, profile, display, linktext, sourcePath) 176 | }) 177 | ); 178 | } 179 | 180 | } else if (text === settings.endProof) { 181 | // handle "\end{proof}" 182 | start = node.from - 1; 183 | end = node.to + 1; // 1 = "`".length 184 | 185 | if (!rangesHaveOverlap(ranges, start, end)) { 186 | builder.add( 187 | start, end, 188 | Decoration.replace({ 189 | widget: new EndProofWidget(plugin, profile) 190 | }) 191 | ); 192 | } 193 | } 194 | } 195 | }); 196 | } 197 | return builder.finish(); 198 | } 199 | }, { 200 | decorations: instance => instance.decorations 201 | }); 202 | 203 | // export const proofFoldFactory = (plugin: LatexReferencer) => foldService.of((state: EditorState, lineStart: number, lineEnd: number) => { 204 | // const positions = state.field(plugin.proofPositionField); 205 | // for (const pos of positions) { 206 | // if (pos.begin && pos.end && lineStart <= pos.begin.from && pos.begin.to <= lineEnd) { 207 | // return { from: pos.begin.to, to: pos.end.to }; 208 | // } 209 | // } 210 | // return null; 211 | // }); 212 | -------------------------------------------------------------------------------- /src/proof/reading-view.ts: -------------------------------------------------------------------------------- 1 | import LatexReferencer from "main"; 2 | import { App, MarkdownPostProcessorContext, MarkdownRenderChild, TFile } from "obsidian"; 3 | import { resolveSettings } from "utils/plugin"; 4 | import { makeProofClasses, makeProofElement } from "./common"; 5 | import { renderMarkdown } from "utils/render"; 6 | import { Profile } from "settings/profile"; 7 | 8 | export const createProofProcessor = (plugin: LatexReferencer) => (element: HTMLElement, context: MarkdownPostProcessorContext) => { 9 | if (!plugin.extraSettings.enableProof) return; 10 | 11 | const { app } = plugin; 12 | 13 | const file = app.vault.getAbstractFileByPath(context.sourcePath); 14 | if (!(file instanceof TFile)) return; 15 | 16 | const settings = resolveSettings(undefined, plugin, file); 17 | const codes = element.querySelectorAll("code"); 18 | for (const code of codes) { 19 | const text = code.textContent; 20 | if (!text) continue; 21 | 22 | if (text.startsWith(settings.beginProof)) { 23 | const rest = text.slice(settings.beginProof.length); 24 | let displayMatch; 25 | if (!rest) { 26 | context.addChild(new ProofRenderer(app, plugin, code, "begin", file)); 27 | } else if (displayMatch = rest.match(/^\[(.*)\]$/)) { 28 | const display = displayMatch[1]; 29 | context.addChild(new ProofRenderer(app, plugin, code, "begin", file, display)); 30 | } 31 | } else if (code.textContent == settings.endProof) { 32 | context.addChild(new ProofRenderer(app, plugin, code, "end", file)); 33 | } 34 | } 35 | }; 36 | 37 | 38 | function parseAtSignLink(codeEl: HTMLElement) { 39 | const next = codeEl.nextSibling; 40 | const afterNext = next?.nextSibling; 41 | const afterAfterNext = afterNext?.nextSibling; 42 | if (afterNext) { 43 | if (next.nodeType == Node.TEXT_NODE && next.textContent == "@" 44 | && afterNext instanceof HTMLElement && afterNext.matches("a.original-internal-link") 45 | && afterAfterNext instanceof HTMLElement && afterAfterNext.matches("a.mathLink-internal-link")) { 46 | return { atSign: next, links: [afterNext, afterAfterNext] }; 47 | } 48 | } 49 | } 50 | 51 | export class ProofRenderer extends MarkdownRenderChild { 52 | atSignParseResult: { atSign: ChildNode, links: HTMLElement[] } | undefined; 53 | 54 | constructor(public app: App, public plugin: LatexReferencer, containerEl: HTMLElement, public which: "begin" | "end", public file: TFile, public display?: string) { 55 | super(containerEl); 56 | this.atSignParseResult = parseAtSignLink(this.containerEl); 57 | } 58 | 59 | onload(): void { 60 | this.update(); 61 | this.registerEvent( 62 | this.plugin.indexManager.on("local-settings-updated", (file) => { 63 | if (file == this.file) { 64 | this.update(); 65 | } 66 | }) 67 | ); 68 | this.registerEvent( 69 | this.plugin.indexManager.on("global-settings-updated", () => { 70 | this.update(); 71 | }) 72 | ); 73 | } 74 | 75 | update(): void { 76 | const settings = resolveSettings(undefined, this.plugin, this.file); 77 | const profile = this.plugin.extraSettings.profiles[settings.profile]; 78 | 79 | /** 80 | * `\begin{proof}`@[[]] => Proof of Theorem 1. 81 | */ 82 | if (this.atSignParseResult) { 83 | const { atSign, links } = this.atSignParseResult; 84 | const newEl = createSpan({ cls: makeProofClasses(this.which, profile) }); 85 | newEl.replaceChildren(profile.body.proof.linkedBeginPrefix, ...links, profile.body.proof.linkedBeginSuffix); 86 | this.containerEl.replaceWith(newEl); 87 | this.containerEl = newEl; 88 | atSign.textContent = ""; 89 | return; 90 | } 91 | 92 | /** 93 | * `\begin{proof}[Foo.]` => Foo. 94 | */ 95 | if (this.display) { 96 | this.renderDisplay(profile); 97 | return; 98 | } 99 | 100 | /** 101 | * `\begin{proof}` => Proof. 102 | */ 103 | const newEl = makeProofElement(this.which, profile); 104 | this.containerEl.replaceWith(newEl); 105 | this.containerEl = newEl; 106 | } 107 | 108 | async renderDisplay(profile: Profile) { 109 | if (this.display) { 110 | const children = await renderMarkdown(this.display, this.file.path, this.plugin); 111 | if (children) { 112 | const el = createSpan({ cls: makeProofClasses(this.which, profile) }); 113 | el.replaceChildren(...children); 114 | this.containerEl.replaceWith(el); 115 | this.containerEl = el; 116 | } 117 | } 118 | } 119 | } 120 | 121 | -------------------------------------------------------------------------------- /src/search/editor-suggest.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorPosition, EditorSuggest, EditorSuggestContext, EditorSuggestTriggerInfo, Keymap, UserEvent } from "obsidian"; 2 | 3 | import LatexReferencer from "main"; 4 | import { MathBlock } from "index/typings/markdown"; 5 | import { MathSearchCore, SuggestParent, WholeVaultTheoremEquationSearchCore } from "./core"; 6 | import { QueryType, SearchRange } from './core'; 7 | 8 | 9 | export class LinkAutocomplete extends EditorSuggest implements SuggestParent { 10 | queryType: QueryType; 11 | range: SearchRange; 12 | core: MathSearchCore; 13 | triggers: Map; 14 | 15 | /** 16 | * @param type The type of the block to search for. See: index/typings/markdown.ts 17 | */ 18 | constructor(public plugin: LatexReferencer) { 19 | super(plugin.app); 20 | this.setTriggers(); 21 | this.core = new WholeVaultTheoremEquationSearchCore(this); 22 | this.core.setScope(); 23 | } 24 | 25 | get dvQuery(): string { 26 | return this.plugin.extraSettings.autocompleteDvQuery; 27 | } 28 | 29 | getContext() { 30 | return this.context; 31 | } 32 | 33 | getSelectedItem() { 34 | // Reference: https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/be4a12c3f861c31f2be3c0f81809cfc5ab6bb5fd/src/ui/AutoCompleteSuggest.ts#L595-L619 35 | return this.suggestions.values[this.suggestions.selectedItem]; 36 | } 37 | 38 | onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo | null { 39 | for (const [trigger, { range, queryType }] of this.triggers) { 40 | const text = editor.getLine(cursor.line); 41 | const index = text.lastIndexOf(trigger); 42 | if (index >= 0) { 43 | const query = text.slice(index + trigger.length); 44 | if (query.startsWith("[[")) return null; // avoid conflict with the built-in link auto-completion 45 | this.queryType = queryType; 46 | this.range = range; 47 | const core = MathSearchCore.getCore(this); 48 | if (!core) return null; 49 | this.core = core; 50 | this.limit = this.plugin.extraSettings.suggestNumber; 51 | return { 52 | start: { line: cursor.line, ch: index }, 53 | end: cursor, 54 | query 55 | }; 56 | } 57 | } 58 | 59 | return null; 60 | } 61 | 62 | getSuggestions(context: EditorSuggestContext): Promise { 63 | return this.core.getSuggestions(context.query); 64 | } 65 | 66 | renderSuggestion(block: MathBlock, el: HTMLElement): void { 67 | this.core.renderSuggestion(block, el); 68 | } 69 | 70 | selectSuggestion(item: MathBlock, evt: MouseEvent | KeyboardEvent): void { 71 | this.core.selectSuggestion(item, evt); 72 | } 73 | 74 | setTriggers() { 75 | const unsortedTriggers = {} as Record; 76 | if (this.plugin.extraSettings.enableSuggest) { 77 | unsortedTriggers[this.plugin.extraSettings.triggerSuggest] = { range: "vault", queryType: "both" }; 78 | } 79 | if (this.plugin.extraSettings.enableTheoremSuggest) { 80 | unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggest] = { range: "vault", queryType: "theorem" }; 81 | } 82 | if (this.plugin.extraSettings.enableEquationSuggest) { 83 | unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggest] = { range: "vault", queryType: "equation" }; 84 | } 85 | if (this.plugin.extraSettings.enableSuggestRecentNotes) { 86 | unsortedTriggers[this.plugin.extraSettings.triggerSuggestRecentNotes] = { range: "recent", queryType: "both" }; 87 | } 88 | if (this.plugin.extraSettings.enableTheoremSuggestRecentNotes) { 89 | unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestRecentNotes] = { range: "recent", queryType: "theorem" }; 90 | } 91 | if (this.plugin.extraSettings.enableEquationSuggestRecentNotes) { 92 | unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestRecentNotes] = { range: "recent", queryType: "equation" }; 93 | } 94 | if (this.plugin.extraSettings.enableSuggestActiveNote) { 95 | unsortedTriggers[this.plugin.extraSettings.triggerSuggestActiveNote] = { range: "active", queryType: "both" }; 96 | } 97 | if (this.plugin.extraSettings.enableTheoremSuggestActiveNote) { 98 | unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestActiveNote] = { range: "active", queryType: "theorem" }; 99 | } 100 | if (this.plugin.extraSettings.enableEquationSuggestActiveNote) { 101 | unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestActiveNote] = { range: "active", queryType: "equation" }; 102 | } 103 | if (this.plugin.extraSettings.enableSuggestDataview) { 104 | unsortedTriggers[this.plugin.extraSettings.triggerSuggestDataview] = { range: "dataview", queryType: "both" }; 105 | } 106 | if (this.plugin.extraSettings.enableTheoremSuggestDataview) { 107 | unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestDataview] = { range: "dataview", queryType: "theorem" }; 108 | } 109 | if (this.plugin.extraSettings.enableEquationSuggestDataview) { 110 | unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestDataview] = { range: "dataview", queryType: "equation" }; 111 | } 112 | const sortedTriggers = new Map; 113 | 114 | Object.entries(unsortedTriggers) 115 | .sort((a, b) => b[0].length - a[0].length) // sort by descending order of trigger length 116 | .forEach(([k, v]) => sortedTriggers.set(k, v)); 117 | 118 | this.triggers = sortedTriggers; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/search/modal.ts: -------------------------------------------------------------------------------- 1 | import { DataviewQuerySearchCore, QueryType, SearchRange, WholeVaultTheoremEquationSearchCore } from 'search/core'; 2 | 3 | import LatexReferencer from "main"; 4 | import { App, EditorSuggestContext, MarkdownView, Setting, SuggestModal, TextAreaComponent } from "obsidian"; 5 | import { MathSearchCore, SuggestParent } from "./core"; 6 | import { MathBlock } from "index/typings/markdown"; 7 | 8 | export class MathSearchModal extends SuggestModal implements SuggestParent { 9 | app: App; 10 | core: MathSearchCore; 11 | queryType: QueryType; 12 | range: SearchRange; 13 | dvQueryField: Setting; 14 | topEl: HTMLElement; 15 | 16 | constructor(public plugin: LatexReferencer) { 17 | super(plugin.app); 18 | this.app = plugin.app; 19 | this.core = new WholeVaultTheoremEquationSearchCore(this); 20 | this.core.setScope(); 21 | this.setPlaceholder('Type here...'); 22 | 23 | this.queryType = this.plugin.extraSettings.searchModalQueryType; 24 | this.range = this.plugin.extraSettings.searchModalRange; 25 | 26 | this.topEl = this.modalEl.createDiv({ cls: 'math-booster-modal-top' }); 27 | this.modalEl.insertBefore(this.topEl, this.modalEl.firstChild) 28 | this.inputEl.addClass('math-booster-search-input'); 29 | 30 | this.limit = this.plugin.extraSettings.suggestNumber; 31 | 32 | new Setting(this.topEl) 33 | .setName('Query type') 34 | .addDropdown((dropdown) => { 35 | dropdown.addOption('both', 'Theorems and equations') 36 | .addOption('theorem', 'Theorems') 37 | .addOption('equation', 'Equations'); 38 | 39 | // recover the last state 40 | dropdown.setValue(this.plugin.extraSettings.searchModalQueryType) 41 | 42 | dropdown.onChange((value: QueryType) => { 43 | this.queryType = value; 44 | this.resetCore(); 45 | // @ts-ignore 46 | this.onInput(); 47 | 48 | // remember the last state 49 | this.plugin.extraSettings.searchModalQueryType = value; 50 | this.plugin.saveSettings(); 51 | }) 52 | }); 53 | 54 | new Setting(this.topEl) 55 | .setName('Search range') 56 | .addDropdown((dropdown) => { 57 | dropdown.addOption('vault', 'Vault') 58 | .addOption('recent', 'Recent notes') 59 | .addOption('active', 'Active note') 60 | .addOption('dataview', 'Dataview query'); 61 | 62 | // recover the last state 63 | dropdown.setValue(this.plugin.extraSettings.searchModalRange) 64 | dropdown.onChange((value: SearchRange) => { 65 | this.range = value; 66 | this.resetCore(); 67 | // @ts-ignore 68 | this.onInput(); 69 | 70 | // remember the last state 71 | this.plugin.extraSettings.searchModalRange = value; 72 | this.plugin.saveSettings(); 73 | }) 74 | }); 75 | 76 | this.dvQueryField = new Setting(this.topEl) 77 | .setName('Dataview query') 78 | .setDesc('Only LIST queries are supported.') 79 | .then(setting => { 80 | setting.controlEl.style.width = '60%'; 81 | }) 82 | .addTextArea((text) => { 83 | text.inputEl.addClass('math-booster-dv-query') 84 | text.inputEl.style.width = '100%'; 85 | 86 | text.setValue(this.plugin.extraSettings.searchModalDvQuery) // recover the last state 87 | .setPlaceholder('LIST ...') 88 | .onChange((dvQuery) => { 89 | if (this.core instanceof DataviewQuerySearchCore) { 90 | this.core.dvQuery = dvQuery; 91 | // @ts-ignore 92 | this.onInput(); 93 | 94 | // remember the last state 95 | this.plugin.extraSettings.searchModalDvQuery = dvQuery; 96 | this.plugin.saveSettings(); 97 | } 98 | }) 99 | }); 100 | 101 | this.modalEl.insertBefore(this.inputEl, this.modalEl.firstChild); 102 | 103 | this.resetCore(); 104 | } 105 | 106 | resetCore() { 107 | const core = MathSearchCore.getCore(this); 108 | if (core) this.core = core; 109 | if (this.range === 'dataview') { 110 | if (!core) { 111 | this.dvQueryField.setDisabled(true); 112 | this.dvQueryField.setDesc('Retry after enabling Dataview.') 113 | .then(setting => setting.descEl.style.color = '#ea5555'); 114 | } 115 | this.dvQueryField.settingEl.show(); 116 | } 117 | else this.dvQueryField.settingEl.hide(); 118 | } 119 | 120 | get dvQuery(): string { 121 | return (this.dvQueryField.components[0] as TextAreaComponent).getValue(); 122 | } 123 | 124 | getContext(): Omit | null { 125 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 126 | if (!view?.file) return null; 127 | 128 | const start = view.editor.getCursor('from'); 129 | const end = view.editor.getCursor('to'); 130 | 131 | return { file: view.file, editor: view.editor, start, end } 132 | } 133 | 134 | getSelectedItem(): MathBlock { 135 | return this.chooser.values![this.chooser.selectedItem]; 136 | }; 137 | 138 | getSuggestions(query: string) { 139 | return this.core.getSuggestions(query); 140 | } 141 | 142 | renderSuggestion(value: MathBlock, el: HTMLElement) { 143 | this.core.renderSuggestion(value, el); 144 | } 145 | 146 | onChooseSuggestion(item: MathBlock, evt: MouseEvent | KeyboardEvent) { 147 | this.core.selectSuggestion(item, evt); 148 | } 149 | } -------------------------------------------------------------------------------- /src/settings/modals.ts: -------------------------------------------------------------------------------- 1 | import { TAbstractFile, TFile, App, Modal, Setting, FuzzySuggestModal, TFolder, Component } from 'obsidian'; 2 | 3 | import LatexReferencer from 'main'; 4 | import { MathSettings, MathContextSettings, DEFAULT_SETTINGS, MinimalTheoremCalloutSettings } from 'settings/settings'; 5 | import { MathSettingTab } from "settings/tab"; 6 | import { TheoremCalloutSettingsHelper, MathContextSettingsHelper } from "settings/helper"; 7 | import { isEqualToOrChildOf } from 'utils/obsidian'; 8 | import { resolveSettings } from 'utils/plugin'; 9 | 10 | 11 | abstract class MathSettingModal extends Modal { 12 | settings: SettingsType; 13 | 14 | constructor( 15 | app: App, 16 | public plugin: LatexReferencer, 17 | public callback?: (settings: SettingsType) => void, 18 | ) { 19 | super(app); 20 | } 21 | 22 | onClose(): void { 23 | this.contentEl.empty(); 24 | } 25 | 26 | addButton(buttonText: string) { 27 | const { contentEl } = this; 28 | new Setting(contentEl) 29 | .addButton((btn) => { 30 | btn.setButtonText(buttonText) 31 | .setCta() 32 | .onClick(() => { 33 | this.close(); 34 | if (this.callback) { 35 | this.callback(this.settings); 36 | } 37 | }); 38 | btn.buttonEl.classList.add("insert-math-item-button"); 39 | }); 40 | 41 | const button = contentEl.querySelector(".insert-math-item-button"); 42 | const settingTextboxes = contentEl.querySelectorAll("input"); 43 | if (button) { 44 | settingTextboxes.forEach((textbox) => { 45 | /** 46 | * 'keypress' is deprecated, but here we need to use it. 47 | * 'keydown' causes a serious inconvenience at least for Japanese users. 48 | * See https://qiita.com/ledsun/items/31e43a97413dd3c8e38e 49 | */ 50 | this.plugin.registerDomEvent(textbox, "keypress", (event) => { 51 | if (event.key === "Enter") { 52 | (button as HTMLElement).click(); 53 | } 54 | }); 55 | }); 56 | } 57 | } 58 | } 59 | 60 | 61 | export class TheoremCalloutModal extends MathSettingModal { 62 | defaultSettings: Required; 63 | 64 | constructor( 65 | app: App, 66 | plugin: LatexReferencer, 67 | public file: TFile, 68 | callback: (settings: MathSettings) => void, 69 | public buttonText: string, 70 | public headerText: string, 71 | public currentCalloutSettings?: MinimalTheoremCalloutSettings, 72 | ) { 73 | super(app, plugin, callback); 74 | } 75 | 76 | resolveDefaultSettings(currentFile: TAbstractFile) { 77 | // The if statement is redundant, but probably necessary for the Typescript compiler to work 78 | if (this.currentCalloutSettings === undefined) { 79 | this.defaultSettings = resolveSettings(this.currentCalloutSettings, this.plugin, currentFile) 80 | } else { 81 | this.defaultSettings = resolveSettings(this.currentCalloutSettings, this.plugin, currentFile) 82 | } 83 | } 84 | 85 | onOpen(): void { 86 | this.settings = this.currentCalloutSettings ?? {} as MathSettings; 87 | const { contentEl } = this; 88 | contentEl.empty(); 89 | 90 | this.resolveDefaultSettings(this.file); 91 | 92 | if (this.headerText) { 93 | // contentEl.createEl("h4", { text: this.headerText }); 94 | this.titleEl.setText(this.headerText); 95 | } 96 | 97 | const helper = new TheoremCalloutSettingsHelper(contentEl, this.settings, this.defaultSettings, this.plugin, this.file); 98 | helper.makeSettingPane(); 99 | 100 | new Setting(contentEl) 101 | .setName('Open local settings for the current note') 102 | .addButton((button) => { 103 | button.setButtonText("Open") 104 | .onClick(() => { 105 | const modal = new ContextSettingModal( 106 | this.app, 107 | this.plugin, 108 | this.file, 109 | undefined, 110 | this 111 | ); 112 | modal.open(); 113 | }) 114 | }); 115 | 116 | this.addButton(this.buttonText); 117 | } 118 | } 119 | 120 | 121 | export class ContextSettingModal extends MathSettingModal { 122 | component: Component; 123 | 124 | constructor( 125 | app: App, 126 | plugin: LatexReferencer, 127 | public file: TAbstractFile, 128 | callback?: (settings: MathContextSettings) => void, 129 | public parent?: TheoremCalloutModal | undefined 130 | ) { 131 | super(app, plugin, callback); 132 | this.component = new Component(); 133 | } 134 | 135 | onOpen(): void { 136 | const { contentEl } = this; 137 | contentEl.empty(); 138 | this.component.load(); 139 | 140 | // contentEl.createEl('h4', { text: 'Local settings for ' + this.file.path }); 141 | this.titleEl.setText('Local settings for ' + this.file.path); 142 | 143 | contentEl.createDiv({ 144 | text: "If you want the change to apply to the entire vault, go to the plugin settings.", 145 | cls: ["setting-item-description", "math-booster-setting-item-description"], 146 | }); 147 | 148 | if (this.plugin.settings[this.file.path] === undefined) { 149 | this.plugin.settings[this.file.path] = {} as MathContextSettings; 150 | } 151 | 152 | const defaultSettings = this.file.parent ? resolveSettings(undefined, this.plugin, this.file.parent) : DEFAULT_SETTINGS; 153 | 154 | const contextSettingsHelper = new MathContextSettingsHelper(contentEl, this.plugin.settings[this.file.path], defaultSettings, this.plugin, this.file); 155 | this.component.addChild(contextSettingsHelper); 156 | 157 | // if (!(this.file instanceof TFolder && this.file.isRoot())) { 158 | // new ProjectSettingsHelper(contentEl, this).makeSettingPane(); 159 | // } 160 | 161 | this.addButton('Save'); 162 | } 163 | 164 | onClose(): void { 165 | super.onClose(); 166 | 167 | this.plugin.saveSettings(); 168 | this.plugin.indexManager.trigger('local-settings-updated', this.file); 169 | 170 | if (this.parent) { 171 | this.parent.open(); 172 | } 173 | 174 | this.component.unload(); 175 | } 176 | } 177 | 178 | 179 | abstract class FileSuggestModal extends FuzzySuggestModal { 180 | 181 | constructor(app: App, public plugin: LatexReferencer) { 182 | super(app); 183 | } 184 | 185 | getItems(): TAbstractFile[] { 186 | return this.app.vault 187 | .getAllLoadedFiles() 188 | .filter(this.filterCallback.bind(this)); 189 | } 190 | 191 | getItemText(file: TAbstractFile): string { 192 | return file.path; 193 | } 194 | 195 | filterCallback(abstractFile: TAbstractFile): boolean { 196 | if (abstractFile instanceof TFile && abstractFile.extension != 'md') { 197 | return false; 198 | } 199 | if (abstractFile instanceof TFolder && abstractFile.isRoot()) { 200 | return false; 201 | } 202 | for (const path of this.plugin.excludedFiles) { 203 | const file = this.app.vault.getAbstractFileByPath(path) 204 | if (file && isEqualToOrChildOf(abstractFile, file)) { 205 | return false 206 | } 207 | } 208 | return true; 209 | } 210 | } 211 | 212 | 213 | 214 | export class LocalContextSettingsSuggestModal extends FileSuggestModal { 215 | constructor(app: App, plugin: LatexReferencer, public settingTab: MathSettingTab) { 216 | super(app, plugin); 217 | } 218 | 219 | onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) { 220 | const modal = new ContextSettingModal(this.app, this.plugin, file); 221 | modal.open(); 222 | } 223 | } 224 | 225 | 226 | 227 | export class FileExcludeSuggestModal extends FileSuggestModal { 228 | constructor(app: App, plugin: LatexReferencer, public manageModal: ExcludedFileManageModal) { 229 | super(app, plugin); 230 | } 231 | 232 | onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) { 233 | this.plugin.excludedFiles.push(file.path); 234 | this.manageModal.newDisplay(); 235 | } 236 | 237 | filterCallback(abstractFile: TAbstractFile): boolean { 238 | for (const path in this.plugin.settings) { 239 | if (path == abstractFile.path) { 240 | return false; 241 | } 242 | } 243 | return super.filterCallback(abstractFile); 244 | } 245 | } 246 | 247 | 248 | export class ExcludedFileManageModal extends Modal { 249 | constructor(app: App, public plugin: LatexReferencer) { 250 | super(app); 251 | } 252 | 253 | onOpen() { 254 | this.newDisplay(); 255 | } 256 | 257 | async newDisplay() { 258 | await this.plugin.saveSettings(); 259 | const { contentEl } = this; 260 | contentEl.empty(); 261 | contentEl.createEl('h3', { text: 'Excluded files/folders' }); 262 | 263 | new Setting(contentEl) 264 | .setName('The files/folders in this list and their descendants will be excluded from suggestion for local settings.') 265 | .addButton((btn) => { 266 | btn.setIcon("plus") 267 | .onClick((event) => { 268 | new FileExcludeSuggestModal(this.app, this.plugin, this).open(); 269 | }); 270 | }); 271 | 272 | if (this.plugin.excludedFiles.length) { 273 | const list = contentEl.createEl('ul'); 274 | for (const path of this.plugin.excludedFiles) { 275 | const item = list.createEl('li').createDiv(); 276 | new Setting(item).setName(path).addExtraButton((btn) => { 277 | btn.setIcon('x').onClick(async () => { 278 | this.plugin.excludedFiles.remove(path); 279 | this.newDisplay(); 280 | await this.plugin.saveSettings(); 281 | }); 282 | }); 283 | } 284 | } 285 | 286 | } 287 | 288 | onClose() { 289 | const { contentEl } = this; 290 | contentEl.empty(); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import { Modifier } from "obsidian"; 2 | 3 | import { DEFAULT_PROFILES, Profile } from "./profile"; 4 | import { LeafArgs } from "../typings/type"; 5 | import { QueryType, SearchRange } from "search/core"; 6 | 7 | // Types 8 | 9 | export const NUMBER_STYLES = [ 10 | "arabic", 11 | "alph", 12 | "Alph", 13 | "roman", 14 | "Roman" 15 | ] as const; 16 | export type NumberStyle = typeof NUMBER_STYLES[number]; 17 | 18 | export const THEOREM_CALLOUT_STYLES = [ 19 | "Custom", 20 | "Plain", 21 | "Framed", 22 | "MathWiki", 23 | "Vivid", 24 | ] as const; 25 | export type TheoremCalloutStyle = typeof THEOREM_CALLOUT_STYLES[number]; 26 | 27 | export const THEOREM_REF_FORMATS = [ 28 | "[type] [number] ([title])", 29 | "[type] [number]", 30 | "[title] ([type] [number]) if title exists, [type] [number] otherwise", 31 | "[title] if title exists, [type] [number] otherwise", 32 | ] as const; 33 | export type TheoremRefFormat = typeof THEOREM_REF_FORMATS[number]; 34 | 35 | export const LEAF_OPTIONS = [ 36 | "Current tab", 37 | "Split right", 38 | "Split down", 39 | "New tab", 40 | "New window", 41 | ] as const; 42 | export type LeafOption = typeof LEAF_OPTIONS[number]; 43 | export const LEAF_OPTION_TO_ARGS: Record = { 44 | "Current tab": [false], 45 | "Split right": ["split", "vertical"], 46 | "Split down": ["split", "horizontal"], 47 | "New tab": ["tab"], 48 | "New window": ["window"], 49 | } 50 | 51 | export const SEARCH_METHODS = [ 52 | "Fuzzy", 53 | "Simple" 54 | ] as const; 55 | export type SearchMethod = typeof SEARCH_METHODS[number]; 56 | 57 | 58 | // Context settings are called "local settings" in the documentation and UI. 59 | // Sorry for confusion, this is due to a historical reason. I'll fix it later. 60 | export interface MathContextSettings { 61 | profile: string; 62 | titleSuffix: string; 63 | inferNumberPrefix: boolean; 64 | inferNumberPrefixFromProperty: string; 65 | inferNumberPrefixRegExp: string; 66 | numberPrefix: string; 67 | numberSuffix: string; 68 | numberInit: number; 69 | numberStyle: NumberStyle; 70 | numberDefault: string; 71 | refFormat: TheoremRefFormat; 72 | noteMathLinkFormat: TheoremRefFormat; 73 | ignoreMainTheoremCalloutWithoutTitle: boolean; 74 | numberOnlyReferencedEquations: boolean; 75 | inferEqNumberPrefix: boolean; 76 | inferEqNumberPrefixFromProperty: string; 77 | inferEqNumberPrefixRegExp: string; 78 | eqNumberPrefix: string; 79 | eqNumberSuffix: string; 80 | eqNumberInit: number; 81 | eqNumberStyle: NumberStyle; 82 | eqRefPrefix: string; 83 | eqRefSuffix: string; 84 | labelPrefix: string; 85 | lineByLine: boolean; 86 | theoremCalloutStyle: TheoremCalloutStyle; 87 | theoremCalloutFontInherit: boolean; 88 | beginProof: string; 89 | endProof: string; 90 | insertSpace: boolean; 91 | } 92 | 93 | export const UNION_TYPE_MATH_CONTEXT_SETTING_KEYS: {[k in keyof Partial]: readonly string[]} = { 94 | "numberStyle": NUMBER_STYLES, 95 | "refFormat": THEOREM_REF_FORMATS, 96 | "noteMathLinkFormat": THEOREM_REF_FORMATS, 97 | "eqNumberStyle": NUMBER_STYLES, 98 | "theoremCalloutStyle": THEOREM_CALLOUT_STYLES, 99 | }; 100 | 101 | export type FoldOption = '' | '+' | '-'; 102 | 103 | export type MinimalTheoremCalloutSettings = { 104 | type: string; 105 | number: string; 106 | title?: string; 107 | fold?: FoldOption; 108 | } 109 | 110 | export type TheoremCalloutSettings = MinimalTheoremCalloutSettings & { 111 | // type: string; 112 | // number: string; 113 | // title?: string; 114 | label?: string; 115 | // setAsNoteMathLink: boolean; 116 | } 117 | 118 | // legacy interface; currently only used temporarily when computing formatted theorem titles 119 | // TODO: refactor 120 | export interface TheoremCalloutPrivateFields { 121 | _index?: number; 122 | } 123 | 124 | export interface ImporterSettings { 125 | importerNumThreads: number; 126 | importerUtilization: number; 127 | } 128 | 129 | export type ExtraSettings = ImporterSettings & { 130 | foldDefault: FoldOption; 131 | noteTitleInTheoremLink: boolean; 132 | noteTitleInEquationLink: boolean; 133 | profiles: Record; 134 | showTheoremTitleinBuiltin: boolean; 135 | showTheoremContentinBuiltin: boolean; 136 | triggerSuggest: string; 137 | triggerTheoremSuggest: string; 138 | triggerEquationSuggest: string; 139 | triggerSuggestActiveNote: string; 140 | triggerTheoremSuggestActiveNote: string; 141 | triggerEquationSuggestActiveNote: string; 142 | triggerSuggestRecentNotes: string; 143 | triggerTheoremSuggestRecentNotes: string; 144 | triggerEquationSuggestRecentNotes: string; 145 | triggerSuggestDataview: string; 146 | triggerTheoremSuggestDataview: string; 147 | triggerEquationSuggestDataview: string; 148 | enableSuggest: boolean; 149 | enableTheoremSuggest: boolean; 150 | enableEquationSuggest: boolean; 151 | enableSuggestActiveNote: boolean; 152 | enableTheoremSuggestActiveNote: boolean; 153 | enableEquationSuggestActiveNote: boolean; 154 | enableSuggestRecentNotes: boolean; 155 | enableTheoremSuggestRecentNotes: boolean; 156 | enableEquationSuggestRecentNotes: boolean; 157 | enableSuggestDataview: boolean; 158 | enableTheoremSuggestDataview: boolean; 159 | enableEquationSuggestDataview: boolean; 160 | renderMathInSuggestion: boolean; 161 | suggestNumber: number; 162 | searchMethod: SearchMethod; 163 | upWeightRecent: number; 164 | searchLabel: boolean; 165 | modifierToJump: Modifier; 166 | modifierToNoteLink: Modifier; 167 | showModifierInstruction: boolean; 168 | suggestLeafOption: LeafOption; 169 | // projectInfix: string; 170 | // projectSep: string; 171 | showTheoremCalloutEditButton: boolean; 172 | setOnlyTheoremAsMain: boolean; 173 | setLabelInModal: boolean; 174 | excludeExampleCallout: boolean; 175 | enableProof: boolean; 176 | autocompleteDvQuery: string; 177 | // searchModal*: not congigurable from the setting tab, just remenbers the last state 178 | searchModalQueryType: QueryType; 179 | searchModalRange: SearchRange; 180 | searchModalDvQuery: string; 181 | } 182 | 183 | export const UNION_TYPE_EXTRA_SETTING_KEYS: {[k in keyof Partial]: readonly string[]} = { 184 | "searchMethod": SEARCH_METHODS, 185 | "suggestLeafOption": LEAF_OPTIONS, 186 | }; 187 | 188 | export type MathSettings = Partial & TheoremCalloutSettings & TheoremCalloutPrivateFields; 189 | export type ResolvedMathSettings = Required & TheoremCalloutSettings & TheoremCalloutPrivateFields; 190 | 191 | export const DEFAULT_SETTINGS: Required = { 192 | profile: Object.keys(DEFAULT_PROFILES)[0], 193 | titleSuffix: ".", 194 | inferNumberPrefix: true, 195 | inferNumberPrefixFromProperty: "", 196 | inferNumberPrefixRegExp: "^[0-9]+(\\.[0-9]+)*", 197 | numberPrefix: "", 198 | numberSuffix: "", 199 | numberInit: 1, 200 | numberStyle: "arabic", 201 | numberDefault: "auto", 202 | refFormat: "[type] [number] ([title])", 203 | noteMathLinkFormat: "[title] if title exists, [type] [number] otherwise", 204 | ignoreMainTheoremCalloutWithoutTitle: false, 205 | numberOnlyReferencedEquations: true, 206 | inferEqNumberPrefix: true, 207 | inferEqNumberPrefixFromProperty: "", 208 | inferEqNumberPrefixRegExp: "^[0-9]+(\\.[0-9]+)*", 209 | eqNumberPrefix: "", 210 | eqNumberSuffix: "", 211 | eqNumberInit: 1, 212 | eqNumberStyle: "arabic", 213 | eqRefPrefix: "", 214 | eqRefSuffix: "", 215 | labelPrefix: "", 216 | lineByLine: true, 217 | theoremCalloutStyle: "Framed", 218 | theoremCalloutFontInherit: false, 219 | beginProof: "\\begin{proof}", 220 | endProof: "\\end{proof}", 221 | insertSpace: true, 222 | } 223 | 224 | export const DEFAULT_EXTRA_SETTINGS: Required = { 225 | foldDefault: '', 226 | noteTitleInTheoremLink: true, 227 | noteTitleInEquationLink: true, 228 | profiles: DEFAULT_PROFILES, 229 | showTheoremTitleinBuiltin: true, 230 | showTheoremContentinBuiltin: false, 231 | triggerSuggest: "\\ref", 232 | triggerTheoremSuggest: "\\tref", 233 | triggerEquationSuggest: "\\eqref", 234 | triggerSuggestActiveNote: "\\ref:a", 235 | triggerTheoremSuggestActiveNote: "\\tref:a", 236 | triggerEquationSuggestActiveNote: "\\eqref:a", 237 | triggerSuggestRecentNotes: "\\ref:r", 238 | triggerTheoremSuggestRecentNotes: "\\tref:r", 239 | triggerEquationSuggestRecentNotes: "\\eqref:r", 240 | triggerSuggestDataview: "\\ref:d", 241 | triggerTheoremSuggestDataview: "\\tref:d", 242 | triggerEquationSuggestDataview: "\\eqref:d", 243 | enableSuggest: true, 244 | enableTheoremSuggest: true, 245 | enableEquationSuggest: true, 246 | enableSuggestActiveNote: true, 247 | enableTheoremSuggestActiveNote: true, 248 | enableEquationSuggestActiveNote: true, 249 | enableSuggestRecentNotes: true, 250 | enableTheoremSuggestRecentNotes: true, 251 | enableEquationSuggestRecentNotes: true, 252 | enableSuggestDataview: true, 253 | enableTheoremSuggestDataview: true, 254 | enableEquationSuggestDataview: true, 255 | renderMathInSuggestion: true, 256 | suggestNumber: 20, 257 | searchMethod: "Fuzzy", 258 | upWeightRecent: 0.1, 259 | searchLabel: false, 260 | modifierToJump: "Mod", 261 | modifierToNoteLink: "Shift", 262 | showModifierInstruction: true, 263 | suggestLeafOption: "Current tab", 264 | // projectInfix: " > ", 265 | // projectSep: "/", 266 | importerNumThreads: 2, 267 | importerUtilization: 0.75, 268 | showTheoremCalloutEditButton: false, 269 | setOnlyTheoremAsMain: false, 270 | setLabelInModal: false, 271 | excludeExampleCallout: false, 272 | enableProof: true, 273 | autocompleteDvQuery: '', 274 | searchModalQueryType: 'both', 275 | searchModalRange: 'recent', 276 | searchModalDvQuery: '', 277 | }; 278 | -------------------------------------------------------------------------------- /src/settings/tab.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, PluginSettingTab, Setting } from "obsidian"; 2 | 3 | import LatexReferencer, { VAULT_ROOT } from "../main"; 4 | import { DEFAULT_EXTRA_SETTINGS, DEFAULT_SETTINGS } from "./settings"; 5 | import { ExtraSettingsHelper, MathContextSettingsHelper } from "./helper"; 6 | import { ExcludedFileManageModal, LocalContextSettingsSuggestModal } from "settings/modals"; 7 | // import { PROJECT_DESCRIPTION } from "project"; 8 | 9 | 10 | export class MathSettingTab extends PluginSettingTab { 11 | component: Component; 12 | 13 | constructor(app: App, public plugin: LatexReferencer) { 14 | super(app, plugin); 15 | this.component = new Component(); 16 | } 17 | 18 | addRestoreDefaultsButton() { 19 | new Setting(this.containerEl) 20 | .addButton((btn) => { 21 | btn.setButtonText("Restore defaults"); 22 | btn.onClick(async () => { 23 | Object.assign(this.plugin.settings[VAULT_ROOT], DEFAULT_SETTINGS); 24 | Object.assign(this.plugin.extraSettings, DEFAULT_EXTRA_SETTINGS); 25 | this.display(); 26 | }) 27 | }); 28 | } 29 | 30 | display() { 31 | const { containerEl } = this; 32 | containerEl.empty(); 33 | this.component.load(); 34 | 35 | containerEl.createEl("h4", { text: "Global" }); 36 | 37 | const root = this.app.vault.getRoot(); 38 | const globalHelper = new MathContextSettingsHelper( 39 | this.containerEl, 40 | this.plugin.settings[VAULT_ROOT], 41 | DEFAULT_SETTINGS, 42 | this.plugin, 43 | root 44 | ); 45 | this.component.addChild(globalHelper); 46 | 47 | const extraHelper = new ExtraSettingsHelper( 48 | this.containerEl, 49 | this.plugin.extraSettings, 50 | this.plugin.extraSettings, 51 | this.plugin, 52 | false, 53 | false 54 | ); 55 | this.component.addChild(extraHelper); 56 | 57 | const heading = extraHelper.addHeading('Equations - general'); 58 | const numberingHeading = this.containerEl.querySelector('.equation-heading')!; 59 | this.containerEl.insertBefore( 60 | heading.settingEl, 61 | numberingHeading 62 | ); 63 | this.containerEl.insertAfter( 64 | extraHelper.settingRefs.enableProof.settingEl, 65 | this.containerEl.querySelector('.proof-heading')! 66 | ); 67 | this.containerEl.insertAfter( 68 | extraHelper.settingRefs.showTheoremCalloutEditButton.settingEl, 69 | globalHelper.settingRefs.profile.settingEl 70 | ); 71 | this.containerEl.insertAfter( 72 | extraHelper.settingRefs.excludeExampleCallout.settingEl, 73 | globalHelper.settingRefs.profile.settingEl 74 | ); 75 | this.containerEl.insertBefore( 76 | extraHelper.settingRefs.foldDefault.settingEl, 77 | globalHelper.settingRefs.labelPrefix.settingEl 78 | ); 79 | this.containerEl.insertBefore( 80 | extraHelper.settingRefs.setOnlyTheoremAsMain.settingEl, 81 | globalHelper.settingRefs.labelPrefix.settingEl 82 | ); 83 | this.containerEl.insertBefore( 84 | extraHelper.settingRefs.setLabelInModal.settingEl, 85 | globalHelper.settingRefs.labelPrefix.settingEl 86 | ); 87 | this.containerEl.insertAfter( 88 | extraHelper.settingRefs.noteTitleInTheoremLink.settingEl, 89 | globalHelper.settingRefs.refFormat.settingEl 90 | ); 91 | this.containerEl.insertAfter( 92 | extraHelper.settingRefs.noteTitleInEquationLink.settingEl, 93 | globalHelper.settingRefs.eqRefSuffix.settingEl 94 | ); 95 | 96 | this.containerEl.insertBefore( 97 | globalHelper.settingRefs.insertSpace.settingEl, 98 | extraHelper.settingRefs.searchMethod.settingEl, 99 | ); 100 | 101 | // const projectHeading = containerEl.createEl("h3", { text: "Projects (experimental)" }); 102 | // const projectDesc = containerEl.createDiv({ 103 | // text: PROJECT_DESCRIPTION, 104 | // cls: ["setting-item-description", "math-booster-setting-item-description"] 105 | // }); 106 | 107 | // this.containerEl.insertBefore( 108 | // projectHeading, 109 | // extraHelper.settingRefs.projectInfix.settingEl 110 | // ); 111 | // this.containerEl.insertAfter( 112 | // projectDesc, 113 | // projectHeading, 114 | // ); 115 | 116 | this.addRestoreDefaultsButton(); 117 | 118 | containerEl.createEl("h4", { text: "Local" }); 119 | new Setting(containerEl).setName("Local settings") 120 | .setDesc("You can set up local (i.e. file-specific or folder-specific) settings, which have more precedence than the global settings. Local settings can be configured in various ways; here in the plugin settings, right-clicking in the file explorer, the \"Open local settings for the current file\" command, and the \"Open local settings for the current file\" button in the theorem callout settings pop-ups.") 121 | .addButton((btn) => { 122 | btn.setButtonText("Search files & folders") 123 | .onClick(() => { 124 | new LocalContextSettingsSuggestModal(this.app, this.plugin, this).open(); 125 | }); 126 | }); 127 | 128 | new Setting(containerEl) 129 | .setName("Excluded files") 130 | .setDesc("You can make your search results more visible by excluding certain files or folders.") 131 | .addButton((btn) => { 132 | btn.setButtonText("Manage") 133 | .onClick(() => { 134 | new ExcludedFileManageModal(this.app, this.plugin).open(); 135 | }); 136 | }); 137 | } 138 | 139 | async hide() { 140 | super.hide(); 141 | await this.plugin.saveSettings(); 142 | this.plugin.indexManager.trigger('global-settings-updated'); 143 | this.plugin.updateLinkAutocomplete(); 144 | this.component.unload(); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/theorem-callouts/state-field.ts: -------------------------------------------------------------------------------- 1 | import { StateField, EditorState, Transaction, RangeSet, RangeValue, Range, Text } from '@codemirror/state'; 2 | import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; 3 | 4 | import LatexReferencer from 'main'; 5 | import { readTheoremCalloutSettings } from 'utils/parse'; 6 | import { editorInfoField } from 'obsidian'; 7 | 8 | export const CALLOUT = /HyperMD-callout_HyperMD-quote_HyperMD-quote-([1-9][0-9]*)/; 9 | 10 | export class TheoremCalloutInfo extends RangeValue { 11 | constructor(public index: number | null) { 12 | super(); 13 | } 14 | } 15 | 16 | 17 | export const createTheoremCalloutsField = (plugin: LatexReferencer) => StateField.define>({ 18 | create(state: EditorState) { 19 | // Since because canvas files cannot be indexed currently, 20 | // do not number theorems in canvas to make live preview consistent with reading view 21 | if (!state.field(editorInfoField).file) return RangeSet.empty; 22 | 23 | const ranges = getTheoremCalloutInfos(plugin, state, state.doc, 0, 0); 24 | return RangeSet.of(ranges); 25 | }, 26 | update(value: RangeSet, tr: Transaction) { 27 | // Since because canvas files cannot be indexed currently, 28 | // do not number theorems in canvas to make live preview consistent with reading view 29 | if (!tr.state.field(editorInfoField).file) return RangeSet.empty; 30 | 31 | // Because the field can be perfectly determined by the document content, 32 | // we don't need to update it when the document is not changed 33 | if (!tr.docChanged) return value; 34 | 35 | // In order to make the updates efficient, we only update the theorem callout infos that are affected by the changes, 36 | // that is, theorem callouts after the insertion point. 37 | 38 | // Here, we use tr.newDoc instead of tr.state.doc because "Contrary to .state.doc, accessing this won't force the entire new state to be computed right away" (from CM6 docs) 39 | 40 | let minChangedPosition = tr.newDoc.length - 1; 41 | const changeDesc = tr.changes.desc; 42 | changeDesc.iterChangedRanges((fromA, toA, fromB, toB) => { 43 | if (fromB < minChangedPosition) { 44 | minChangedPosition = fromB; 45 | } 46 | }); 47 | 48 | value = value.map(changeDesc); 49 | 50 | let init = 0; 51 | value.between(0, minChangedPosition, (from, to, info) => { 52 | if (to < minChangedPosition && info.index !== null) init = info.index + 1; 53 | }); 54 | 55 | const updatedRanges = getTheoremCalloutInfos(plugin, tr.state, tr.newDoc, minChangedPosition, init); 56 | return value.update({ 57 | add: updatedRanges, 58 | filter: () => false, 59 | filterFrom: minChangedPosition, 60 | }); 61 | } 62 | }); 63 | 64 | 65 | function getTheoremCalloutInfos(plugin: LatexReferencer, state: EditorState, doc: Text, from: number, init: number): Range[] { 66 | const ranges: Range[] = []; 67 | // syntaxTree returns a potentially imcomplete tree (limited by viewport), so we need to ensure it's complete 68 | const tree = ensureSyntaxTree(state, doc.length) ?? syntaxTree(state); 69 | 70 | let theoremIndex = init; // incremented when a auto-numbered theorem is found 71 | 72 | tree.iterate({ 73 | from, to: doc.length, 74 | enter(node) { 75 | if (node.name === 'Document') return; // skip the node for the entire document 76 | 77 | if (node.node.parent?.name !== 'Document') return false; // skip sub-nodes of a line 78 | 79 | const text = doc.sliceString(node.from, node.to); 80 | 81 | const match = node.name.match(CALLOUT); 82 | if (!match) return false; 83 | 84 | const settings = readTheoremCalloutSettings(text, plugin.extraSettings.excludeExampleCallout); 85 | if (!settings) return false; 86 | 87 | const value = new TheoremCalloutInfo(settings.number === 'auto' ? theoremIndex++ : null); 88 | const range = value.range(node.from, node.to); 89 | ranges.push(range); 90 | 91 | return false; 92 | } 93 | }); 94 | 95 | return ranges; 96 | } 97 | -------------------------------------------------------------------------------- /src/theorem-callouts/view-plugin.ts: -------------------------------------------------------------------------------- 1 | import { PluginValue, EditorView, ViewUpdate, ViewPlugin } from '@codemirror/view'; 2 | 3 | import LatexReferencer from 'main'; 4 | import { MathIndex } from 'index/math-index'; 5 | 6 | 7 | export const createTheoremCalloutNumberingViewPlugin = (plugin: LatexReferencer) => ViewPlugin.fromClass( 8 | class implements PluginValue { 9 | index: MathIndex = plugin.indexManager.index; 10 | 11 | constructor(public view: EditorView) { 12 | // Wait until the initial rendering is done so that we can find the callout elements using qeurySelectorAll(). 13 | setTimeout(() => this._update(view)); 14 | } 15 | 16 | update(update: ViewUpdate) { 17 | this._update(update.view); 18 | } 19 | 20 | _update(view: EditorView) { 21 | const infos = view.state.field(plugin.theoremCalloutsField); 22 | 23 | for (const calloutEl of view.contentDOM.querySelectorAll('.callout.theorem-callout')) { 24 | const pos = view.posAtDOM(calloutEl); 25 | const iter = infos.iter(pos); 26 | if (iter.from !== pos) continue; // insertion or deletion occured before this callout, and the posAtDom is out-dated for some reasons: do not update the theorem number 27 | 28 | const index = iter.value?.index; 29 | if (typeof index === 'number') calloutEl.setAttribute('data-theorem-index', String(index)); 30 | else calloutEl.removeAttribute('data-theorem-index'); 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /src/typings/type.d.ts: -------------------------------------------------------------------------------- 1 | import { PaneType, SplitDirection } from "obsidian"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | declare module "obsidian" { 5 | interface App { 6 | plugins: { 7 | enabledPlugins: Set; 8 | plugins: { 9 | [id: string]: any; 10 | }; 11 | getPlugin: (id: string) => Plugin | null; 12 | }; 13 | } 14 | interface Editor { 15 | cm?: EditorView; 16 | } 17 | // Reference: https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/be4a12c3f861c31f2be3c0f81809cfc5ab6bb5fd/src/ui/AutoCompleteSuggest.ts#L595-L619 18 | interface EditorSuggest { 19 | scope: Scope; 20 | suggestions: { 21 | selectedItem: number; 22 | values: T[]; 23 | containerEl: HTMLElement; 24 | }; 25 | suggestEl: HTMLElement; 26 | } 27 | 28 | // Reference: https://github.com/tadashi-aikawa/obsidian-another-quick-switcher/blob/6aa40a46fe817d25c11847a46ec6c765c742d629/src/ui/UnsafeModalInterface.ts#L5 29 | interface SuggestModal { 30 | chooser: { 31 | values: T[] | null; 32 | selectedItem: number; 33 | setSelectedItem( 34 | item: number, 35 | event?: KeyboardEvent, 36 | ): void; 37 | useSelectedItem(ev: Partial): void; 38 | suggestions: Element[]; 39 | }; 40 | } 41 | } 42 | 43 | export type LeafArgs = [newLeaf?: PaneType | boolean] | [newLeaf?: 'split', direction?: SplitDirection]; 44 | -------------------------------------------------------------------------------- /src/typings/workers.d.ts: -------------------------------------------------------------------------------- 1 | declare module "index/web-worker/importer.worker" { 2 | const WorkerFactory: new () => Worker; 3 | export default WorkerFactory; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, ChangeSet, RangeValue, RangeSet, SelectionRange } from '@codemirror/state'; 2 | import { SyntaxNodeRef } from '@lezer/common'; 3 | import { MathInfoSet } from 'render-math-in-callouts'; 4 | import { EditorPosition, Loc, MarkdownView, editorLivePreviewField } from "obsidian"; 5 | 6 | export function locToEditorPosition(loc: Loc): EditorPosition { 7 | return { ch: loc.col, line: loc.line }; 8 | } 9 | 10 | export function isLivePreview(state: EditorState) { 11 | return state.field(editorLivePreviewField); 12 | } 13 | 14 | export function isSourceMode(state: EditorState) { 15 | return !isLivePreview(state); 16 | } 17 | 18 | export function isReadingView(markdownView: MarkdownView) { 19 | return markdownView.getMode() === "preview"; 20 | } 21 | 22 | export function isEditingView(markdownView: MarkdownView) { 23 | return markdownView.getMode() === "source"; 24 | } 25 | 26 | /** CodeMirror/Lezer utilities */ 27 | 28 | export function nodeText(node: SyntaxNodeRef, state: EditorState): string { 29 | return state.sliceDoc(node.from, node.to); 30 | } 31 | 32 | export function printNode(node: SyntaxNodeRef, state: EditorState) { 33 | // Debugging utility 34 | console.log( 35 | `${node.from}-${node.to}: "${nodeText(node, state)}" (${node.name})` 36 | ); 37 | } 38 | 39 | export function printMathInfoSet(set: MathInfoSet, state: EditorState) { 40 | // Debugging utility 41 | console.log("MathInfoSet:"); 42 | set.between(0, state.doc.length, (from, to, value) => { 43 | console.log(` ${from}-${to}: ${value.mathText} ${value.display ? "(display)" : ""} ${value.insideCallout ? "(in callout)" : ""} ${value.overlap === undefined ? "(overlap not checked yet)" : value.overlap ? "(overlapping)" : "(non-overlapping)"}`); 44 | }); 45 | } 46 | 47 | export function nodeTextQuoteSymbolTrimmed(node: SyntaxNodeRef, state: EditorState, quoteLevel: number): string | undefined { 48 | const quoteSymbolPattern = new RegExp(`((>\\s*){${quoteLevel}})(.*)`); 49 | const quoteSymbolMatch = nodeText(node, state).match(quoteSymbolPattern); 50 | if (quoteSymbolMatch) { 51 | return quoteSymbolMatch.slice(-1)[0]; 52 | } 53 | } 54 | 55 | export function printChangeSet(changes: ChangeSet) { 56 | changes.iterChanges( 57 | (fromA, toA, fromB, toB, inserted) => { 58 | console.log(`${fromA}-${toA}: "${inserted.toString()}" inserted (${fromB}-${toB} in new state)`); 59 | } 60 | ); 61 | } 62 | 63 | export function rangeSetSome(set: RangeSet, predicate: (value: T, index: number, set: RangeSet) => unknown) { 64 | const cursor = set.iter(); 65 | let index = 0; 66 | while (cursor.value) { 67 | if (predicate(cursor.value, index, set)) { 68 | return true; 69 | } 70 | cursor.next(); 71 | index++; 72 | } 73 | return false; 74 | } 75 | 76 | export function hasOverlap(range1: { from: number, to: number }, range2: { from: number, to: number }): boolean { 77 | return range1.from <= range2.to && range2.from <= range1.to; 78 | } 79 | 80 | export function rangesHaveOverlap(ranges: readonly SelectionRange[], from: number, to: number) { 81 | for (const range of ranges) { 82 | if (range.from <= to && range.to >= from) 83 | return true; 84 | } 85 | return false; 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | 3 | import LatexReferencer from "main"; 4 | import { getPropertyOrLinkTextInProperty } from "utils/obsidian"; 5 | import { DEFAULT_SETTINGS, MathContextSettings, NumberStyle, ResolvedMathSettings } from "settings/settings"; 6 | import { THEOREM_LIKE_ENVs, TheoremLikeEnvID } from "env"; 7 | 8 | 9 | const ROMAN = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM", 10 | "", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC", 11 | "", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; 12 | 13 | export function toRomanUpper(num: number): string { 14 | // https://stackoverflow.com/a/9083076/13613783 15 | const digits = String(num).split(""); 16 | let roman = ""; 17 | let i = 3; 18 | while (i--) { 19 | // @ts-ignore 20 | roman = (ROMAN[+digits.pop() + (i * 10)] ?? "") + roman; 21 | } 22 | return Array(+digits.join("") + 1).join("M") + roman; 23 | } 24 | 25 | export function toRomanLower(num: number): string { 26 | return toRomanUpper(num).toLowerCase(); 27 | } 28 | 29 | export const ALPH = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 30 | 31 | export function toAlphUpper(num: number): string { 32 | return (num - 1).toString(26).split("").map(str => ALPH[parseInt(str, 26)]).join(""); 33 | } 34 | 35 | export function toAlphLower(num: number): string { 36 | return toAlphUpper(num).toLowerCase(); 37 | } 38 | 39 | export const CONVERTER = { 40 | "arabic": String, 41 | "alph": toAlphLower, 42 | "Alph": toAlphUpper, 43 | "roman": toRomanLower, 44 | "Roman": toRomanUpper, 45 | } 46 | 47 | export function formatTheoremCalloutType(plugin: LatexReferencer, settings: { type: string, profile: string }): string { 48 | const profile = plugin.extraSettings.profiles[settings.profile]; 49 | return profile.body.theorem[settings.type as TheoremLikeEnvID]; 50 | } 51 | 52 | export function formatTitleWithoutSubtitle(plugin: LatexReferencer, file: TFile, settings: ResolvedMathSettings): string { 53 | let title = formatTheoremCalloutType(plugin, settings); 54 | 55 | if (settings.number) { 56 | if (settings.number == 'auto') { 57 | if (settings._index !== undefined) { 58 | settings.numberInit = settings.numberInit ?? 1; 59 | const num = +settings._index + +settings.numberInit; 60 | const style = settings.numberStyle ?? DEFAULT_SETTINGS.numberStyle as NumberStyle; 61 | title += ` ${getNumberPrefix(plugin.app, file, settings)}${CONVERTER[style](num)}${settings.numberSuffix}`; 62 | } 63 | } else { 64 | title += ` ${settings.number}`; 65 | } 66 | } 67 | return title; 68 | } 69 | 70 | export function formatTitle(plugin: LatexReferencer, file: TFile, settings: ResolvedMathSettings, noTitleSuffix: boolean = false): string { 71 | let title = formatTitleWithoutSubtitle(plugin, file, settings); 72 | return addSubTitle(title, settings, noTitleSuffix); 73 | } 74 | 75 | export function addSubTitle(mainTitle: string, settings: ResolvedMathSettings, noTitleSuffix: boolean = false) { 76 | let title = mainTitle; 77 | if (settings.title) { 78 | title += ` (${settings.title})`; 79 | } 80 | if (!noTitleSuffix && settings.titleSuffix) { 81 | title += settings.titleSuffix; 82 | } 83 | return title; 84 | } 85 | 86 | export function inferNumberPrefix(source: string, regExp: string): string | undefined { 87 | const pattern = new RegExp(regExp); 88 | const match = source.match(pattern); 89 | if (match) { 90 | let prefix = match[0].trim(); 91 | if (!prefix.endsWith('.')) prefix += '.'; 92 | return prefix; 93 | } 94 | } 95 | 96 | // /** 97 | // * "A note about calculus" => The "A" at the head shouldn't be used as a prefix (indefinite article) 98 | // * "A. note about calculus" => The "A" at the head IS a prefix 99 | // */ 100 | // export function areValidLabels(labels: string[]): boolean { 101 | // function isValidLabel(label: string): boolean { // true if every label is an arabic or roman numeral 102 | // if (label.match(/^[0-9]+$/)) { 103 | // // Arabic numerals 104 | // return true; 105 | // } 106 | // if (label.match(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i)) { 107 | // // Roman numerals 108 | // // Reference: https://stackoverflow.com/a/267405/13613783 109 | // return true; 110 | // } 111 | // if (label.match(/^[a-z]$/i)) { 112 | // return true; 113 | // } 114 | // return false; 115 | // } 116 | // const blankRemoved = labels.filter((label) => label); 117 | // if (blankRemoved.length >= 2) { 118 | // return blankRemoved.every((label) => isValidLabel(label)); 119 | // } 120 | // if (blankRemoved.length == 1) { 121 | // return labels.length == 2 && (isValidLabel(labels[0])); 122 | // } 123 | // return false; 124 | // } 125 | 126 | /** 127 | * Get an appropriate prefix for theorem callout numbering. 128 | * @param file 129 | * @param settings 130 | * @returns 131 | */ 132 | export function getNumberPrefix(app: App, file: TFile, settings: Required): string { 133 | if (settings.numberPrefix) { 134 | return settings.numberPrefix; 135 | } 136 | const source = settings.inferNumberPrefixFromProperty ? getPropertyOrLinkTextInProperty(app, file, settings.inferNumberPrefixFromProperty) : file.basename; 137 | if (settings.inferNumberPrefix && source) { 138 | return inferNumberPrefix( 139 | source, 140 | settings.inferNumberPrefixRegExp 141 | ) ?? ""; 142 | } 143 | return ""; 144 | } 145 | 146 | /** 147 | * Get an appropriate prefix for equation numbering. 148 | * @param file 149 | * @param settings 150 | * @returns 151 | */ 152 | export function getEqNumberPrefix(app: App, file: TFile, settings: Required): string { 153 | if (settings.eqNumberPrefix) { 154 | return settings.eqNumberPrefix; 155 | } 156 | const source = settings.inferEqNumberPrefixFromProperty ? getPropertyOrLinkTextInProperty(app, file, settings.inferEqNumberPrefixFromProperty) : file.basename; 157 | if (settings.inferEqNumberPrefix && source) { 158 | const prefix = inferNumberPrefix( 159 | source, 160 | settings.inferEqNumberPrefixRegExp 161 | ) ?? ""; 162 | return prefix; 163 | } 164 | return ""; 165 | } 166 | 167 | export function formatLabel(settings: ResolvedMathSettings): string | undefined { 168 | if (settings.label) { 169 | return settings.labelPrefix + THEOREM_LIKE_ENVs[settings.type as TheoremLikeEnvID].prefix + ":" + settings.label; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/utils/general.ts: -------------------------------------------------------------------------------- 1 | export function splitIntoLines(text: string): string[] { 2 | // https://stackoverflow.com/a/5035005/13613783 3 | return text.split(/\r?\n/); 4 | } 5 | 6 | export function capitalize(text: string): string { 7 | return text.charAt(0).toUpperCase() + text.slice(1); 8 | } 9 | 10 | export function removeFrom(item: Type, array: Array) { 11 | return array.splice(array.indexOf(item), 1); 12 | } 13 | 14 | export function insertAt(array: Array, item: Type, index: number) { 15 | array.splice(index, 0, item); 16 | } 17 | 18 | export function pathToName(path: string): string { 19 | return path.slice(path.lastIndexOf('/') + 1); 20 | } 21 | 22 | export function pathToBaseName(path: string): string { 23 | const name = pathToName(path); 24 | const index = name.lastIndexOf('.'); 25 | if (index >= 0) { 26 | return name.slice(0, index); 27 | } 28 | return name; 29 | } 30 | 31 | // https://stackoverflow.com/a/50851710/13613783 32 | export type BooleanKeys = { [k in keyof T]: T[k] extends boolean ? k : never }[keyof T]; 33 | export type NumberKeys = { [k in keyof T]: T[k] extends number ? k : never }[keyof T]; 34 | -------------------------------------------------------------------------------- /src/utils/obsidian.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { BlockSubpathResult, CachedMetadata, Component, HeadingSubpathResult, MarkdownPostProcessorContext, MarkdownView, Modifier, Platform, Plugin, Pos, SectionCache, parseLinktext, resolveSubpath } from "obsidian"; 3 | import { App, TAbstractFile, TFile, TFolder } from "obsidian"; 4 | import { locToEditorPosition } from 'utils/editor'; 5 | import { LeafArgs } from 'typings/type'; 6 | 7 | //////////////////// 8 | // File utilities // 9 | //////////////////// 10 | 11 | /** 12 | * Similar to Vault.recurseChildren, but this function can be also called for TFile, not just TFolder. 13 | * Also, the callback is only called for TFile. 14 | */ 15 | export function iterDescendantFiles(file: TAbstractFile, callback: (descendantFile: TFile) => any) { 16 | if (file instanceof TFile) { 17 | callback(file); 18 | } else if (file instanceof TFolder) { 19 | for (const child of file.children) { 20 | iterDescendantFiles(child, callback); 21 | } 22 | } 23 | } 24 | 25 | export function getAncestors(file: TAbstractFile): TAbstractFile[] { 26 | const ancestors: TAbstractFile[] = []; 27 | let ancestor: TAbstractFile | null = file; 28 | while (ancestor) { 29 | ancestors.push(ancestor); 30 | if (file instanceof TFolder && file.isRoot()) { 31 | break; 32 | } 33 | ancestor = ancestor.parent; 34 | } 35 | ancestors.reverse(); 36 | return ancestors; 37 | } 38 | 39 | export function isEqualToOrChildOf(file1: TAbstractFile, file2: TAbstractFile): boolean { 40 | if (file1 == file2) { 41 | return true; 42 | } 43 | if (file2 instanceof TFolder && file2.isRoot()) { 44 | return true; 45 | } 46 | let ancestor = file1.parent; 47 | while (true) { 48 | if (ancestor == file2) { 49 | return true; 50 | } 51 | if (ancestor) { 52 | if (ancestor.isRoot()) { 53 | return false; 54 | } 55 | ancestor = ancestor.parent 56 | } 57 | } 58 | } 59 | 60 | export function getFile(app: App): TAbstractFile { 61 | return app.workspace.getActiveFile() ?? app.vault.getRoot(); 62 | } 63 | 64 | ////////////////////// 65 | // Cache & metadata // 66 | ////////////////////// 67 | 68 | export function getSectionCacheFromPos(cache: CachedMetadata, pos: number, type: string): SectionCache | undefined { 69 | // pos: CodeMirror offset units 70 | if (cache.sections) { 71 | const sectionCache = Object.values(cache.sections).find((sectionCache) => 72 | sectionCache.type == type 73 | && (sectionCache.position.start.offset == pos || sectionCache.position.end.offset == pos) 74 | ); 75 | return sectionCache; 76 | } 77 | } 78 | 79 | export function getSectionCacheOfDOM(el: HTMLElement, type: string, view: EditorView, cache: CachedMetadata) { 80 | const pos = view.posAtDOM(el); 81 | return getSectionCacheFromPos(cache, pos, type); 82 | } 83 | 84 | export function getSectionCacheFromMouseEvent(event: MouseEvent, type: string, view: EditorView, cache: CachedMetadata) { 85 | const pos = view.posAtCoords(event) ?? view.posAtCoords(event, false); 86 | return getSectionCacheFromPos(cache, pos, type); 87 | } 88 | 89 | export function getProperty(app: App, file: TFile, name: string) { 90 | return app.metadataCache.getFileCache(file)?.frontmatter?.[name]; 91 | } 92 | 93 | export function getPropertyLink(app: App, file: TFile, name: string) { 94 | const cache = app.metadataCache.getFileCache(file); 95 | if (cache?.frontmatterLinks) { 96 | for (const link of cache.frontmatterLinks) { 97 | if (link.key == name) { 98 | return link; 99 | } 100 | } 101 | } 102 | } 103 | 104 | export function getPropertyOrLinkTextInProperty(app: App, file: TFile, name: string) { 105 | return getPropertyLink(app, file, name)?.link ?? getProperty(app, file, name); 106 | } 107 | 108 | export function generateBlockID(cache: CachedMetadata, length: number = 6): string { 109 | let id = ''; 110 | 111 | while (true) { 112 | // Reference: https://stackoverflow.com/a/58326357/13613783 113 | id = [...Array(length)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); 114 | if (cache?.blocks && id in cache.blocks) { 115 | continue; 116 | } else { 117 | break; 118 | } 119 | } 120 | return id; 121 | } 122 | 123 | export function resolveLinktext(app: App, linktext: string, sourcePath: string): { file: TFile, subpathResult: HeadingSubpathResult | BlockSubpathResult | null } | null { 124 | const { path, subpath } = parseLinktext(linktext); 125 | const targetFile = app.metadataCache.getFirstLinkpathDest(path, sourcePath); 126 | if (!targetFile) return null; 127 | const targetCache = app.metadataCache.getFileCache(targetFile); 128 | if (!targetCache) return null; 129 | const result = resolveSubpath(targetCache, subpath); 130 | return { file: targetFile, subpathResult: result }; 131 | } 132 | 133 | 134 | /////////////////// 135 | // Markdown view // 136 | /////////////////// 137 | 138 | export function getMarkdownPreviewViewEl(view: MarkdownView) { 139 | return Array.from(view.previewMode.containerEl.children).find((child) => child.matches(".markdown-preview-view")); 140 | } 141 | 142 | export function getMarkdownSourceViewEl(view: MarkdownView) { 143 | const firstCandidate = view.editor.cm?.dom.parentElement; 144 | if (firstCandidate) return firstCandidate; 145 | const secondCandidate = view.previewMode.containerEl.previousSibling; 146 | if (secondCandidate instanceof HTMLElement && secondCandidate.matches(".markdown-source-view")) { 147 | return secondCandidate; 148 | } 149 | } 150 | 151 | export async function openFileAndSelectPosition(app: App, file: TFile, position: Pos, ...leafArgs: LeafArgs) { 152 | // @ts-ignore 153 | const leaf = app.workspace.getLeaf(...leafArgs); 154 | await leaf.openFile(file); 155 | if (leaf.view instanceof MarkdownView) { 156 | // Editing view 157 | const editor = leaf.view.editor; 158 | const from = locToEditorPosition(position.start); 159 | const to = locToEditorPosition(position.end); 160 | 161 | editor.setSelection(from, to); 162 | editor.scrollIntoView({ from, to }, true); 163 | 164 | // Reading view: thank you NothingIsLost (https://discord.com/channels/686053708261228577/840286264964022302/952218718711189554) 165 | leaf.view.setEphemeralState({ line: position.start.line }); 166 | } 167 | } 168 | 169 | export function findBlockFromReadingViewDom(sizerEl: HTMLElement, cb: (div: HTMLElement, index: number) => boolean): HTMLElement | undefined { 170 | let index = 0; 171 | for (const div of sizerEl.querySelectorAll(':scope > div')) { 172 | if (div.classList.contains('markdown-preview-pusher')) continue; 173 | if (div.classList.contains('mod-header')) continue; 174 | if (div.classList.contains('mod-footer')) continue; 175 | 176 | if (cb(div, index++)) return div; 177 | } 178 | } 179 | 180 | /** 181 | * Given a HTMLElement passed to a MarkdownPostProcessor, check if the current context is PDF export of not. 182 | */ 183 | export function isPdfExport(el: HTMLElement): boolean { 184 | // el.classList.contains('markdown-rendered') is true not only for PDf export 185 | // but also CM6 decorations in Live Preview whose widgets are rendered by MarkdownRenderer. 186 | // So we need to check '.print', too. 187 | // el.closest('[src]') === null is necessary to exclude embeds inside a note exported to PDF. 188 | // return el.closest('.print') !== null && el.closest('[src]') === null && el.classList.contains('markdown-rendered'); 189 | 190 | // Come to think about it, just the following would suffice: 191 | return (el.parentElement?.classList.contains('print') ?? false) && el.matches('.markdown-preview-view.markdown-rendered'); 192 | } 193 | 194 | //////////// 195 | // Others // 196 | //////////// 197 | 198 | // compare the version of given plugin and the required version 199 | export function isPluginOlderThan(plugin: Plugin, version: string): boolean { 200 | return plugin.manifest.version.localeCompare(version, undefined, { numeric: true }) < 0; 201 | } 202 | 203 | export function getModifierNameInPlatform(mod: Modifier): string { 204 | if (mod == "Mod") { 205 | return Platform.isMacOS || Platform.isIosApp ? "⌘" : "ctrl"; 206 | } 207 | if (mod == "Shift") { 208 | return "shift"; 209 | } 210 | if (mod == "Alt") { 211 | return Platform.isMacOS || Platform.isIosApp ? "⌥" : "alt"; 212 | } 213 | if (mod == "Meta") { 214 | return Platform.isMacOS || Platform.isIosApp ? "⌘" : Platform.isWin ? "win" : "meta"; 215 | } 216 | return "ctrl"; 217 | } 218 | 219 | 220 | export class MutationObservingChild extends Component { 221 | observer: MutationObserver; 222 | 223 | constructor(public targetEl: HTMLElement, public callback: MutationCallback, public options: MutationObserverInit) { 224 | super(); 225 | this.observer = new MutationObserver(callback); 226 | } 227 | 228 | onload() { 229 | this.observer.observe(this.targetEl, this.options); 230 | } 231 | 232 | onunload() { 233 | this.observer.disconnect(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/utils/parse.ts: -------------------------------------------------------------------------------- 1 | import { THEOREM_LIKE_ENV_IDs, THEOREM_LIKE_ENV_PREFIXES, THEOREM_LIKE_ENV_PREFIX_ID_MAP, TheoremLikeEnvID, TheoremLikeEnvPrefix } from "env"; 2 | import { FoldOption, MinimalTheoremCalloutSettings } from "settings/settings"; 3 | 4 | export const THEOREM_CALLOUT_PATTERN = new RegExp( 5 | `> *\\[\\! *(?${THEOREM_LIKE_ENV_IDs.join('|')}|${THEOREM_LIKE_ENV_PREFIXES.join('|')}|math) *(\\|(?.*?))?\\](?[+-])?(? .*)?`, 6 | 'i' 7 | ); 8 | 9 | export function matchTheoremCallout(line: string): RegExpExecArray | null { 10 | return THEOREM_CALLOUT_PATTERN.exec(line) 11 | } 12 | 13 | /** > [!type|HERE IS METADATA] 14 | * a.k.a the "data-callout-metadata" attribute 15 | */ 16 | export function parseTheoremCalloutMetadata(metadata: string) { 17 | let number = metadata.trim(); 18 | if (!number) number = 'auto'; 19 | else if (number === '*') number = ''; 20 | return number; 21 | } 22 | 23 | /** 24 | * 25 | * @param line 26 | * @param excludeExample if true, then ""> [!example]" will not be treated as a theorem callout but as the built-in example callout. 27 | * @returns 28 | */ 29 | export function readTheoremCalloutSettings(line: string, excludeExample: boolean = false): MinimalTheoremCalloutSettings & { legacy: boolean } | undefined { 30 | const rawSettings = line.match(THEOREM_CALLOUT_PATTERN)?.groups as { type: string, number?: string, title?: string, fold?: string } | undefined; 31 | if (!rawSettings) return; 32 | 33 | let type = rawSettings.type.trim().toLowerCase(); 34 | 35 | // TODO: rewrite using _readTheoremCalloutSettings 36 | 37 | if (type === 'math' && rawSettings.number) { 38 | // legacy format 39 | const settings = JSON.parse(rawSettings.number) as MinimalTheoremCalloutSettings & { legacy: boolean }; 40 | settings.legacy = true; 41 | return settings; 42 | } 43 | 44 | // new format 45 | if (excludeExample && type === 'example') return undefined; 46 | 47 | if (type.length <= 4) { // use length to avoid iterating over all the prefixes 48 | // convert a prefix to an ID (e.g. "thm" -> "theorem") 49 | type = THEOREM_LIKE_ENV_PREFIX_ID_MAP[type as TheoremLikeEnvPrefix]; 50 | } 51 | const number = parseTheoremCalloutMetadata(rawSettings.number ?? ''); 52 | 53 | let title: string | undefined = rawSettings.title?.trim(); 54 | if (title === '') title = undefined; 55 | 56 | const fold = (rawSettings.fold?.trim() ?? '') as FoldOption; 57 | 58 | return { type, number, title, fold, legacy: false }; 59 | } 60 | 61 | export function _readTheoremCalloutSettings(callout: {type: string, metadata: string}, excludeExample: boolean = false): { type: string, number: string, legacy: boolean } | undefined { 62 | let type = callout.type; 63 | 64 | if (callout.type === 'math' && callout.metadata) { 65 | // legacy format 66 | const settings = JSON.parse(callout.metadata) as MinimalTheoremCalloutSettings & { legacy: boolean }; 67 | settings.legacy = true; 68 | return settings; 69 | } 70 | 71 | // new format 72 | if (excludeExample && callout.type === 'example') return undefined; 73 | 74 | if (callout.type.length <= 4) { // use length to avoid iterating over all the prefixes 75 | // convert a prefix to an ID (e.g. "thm" -> "theorem") 76 | type = THEOREM_LIKE_ENV_PREFIX_ID_MAP[callout.type as TheoremLikeEnvPrefix]; 77 | } 78 | const number = parseTheoremCalloutMetadata(callout.metadata); 79 | 80 | return { type, number, legacy: false }; 81 | } 82 | 83 | 84 | export function trimMathText(text: string) { 85 | return text.match(/\$\$([\s\S]*)\$\$/)?.[1].trim() ?? text; 86 | } 87 | 88 | export function parseLatexComment(line: string): { nonComment: string, comment: string } { 89 | const match = line.match(/(?<!\\)%/); 90 | if (match?.index !== undefined) { 91 | return { nonComment: line.substring(0, match.index), comment: line.substring(match.index + 1) } 92 | } 93 | return { nonComment: line, comment: '' }; 94 | } 95 | 96 | /** Parse the given markdown text and returns all comments in it as an array of lines. */ 97 | export function parseMarkdownComment(markdown: string): string[] { 98 | const comments: string[] = []; 99 | const pattern = /%%([\s\S]*?)%%/g; 100 | let result; 101 | while (result = pattern.exec(markdown)) { 102 | for (let line of result[1].split('\n')) { 103 | line = line.trim(); 104 | if (line) comments.push(line); 105 | } 106 | } 107 | return comments; 108 | } 109 | 110 | /** Parse an one-line YAML-like string into a key-value pair. */ 111 | export function parseYamlLike(line: string): Record<string, string | undefined> | null { 112 | const result = line.match(/^(?<key>.*?):(?<value>.*)$/)?.groups; 113 | if (!result) return null; 114 | return { [result.key.trim()]: result.value.trim() }; 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/plugin.ts: -------------------------------------------------------------------------------- 1 | import LatexReferencer from "main"; 2 | import { CachedMetadata, Editor, MarkdownFileInfo, MarkdownView, Notice, TAbstractFile, TFile } from "obsidian"; 3 | import { DEFAULT_SETTINGS, MathContextSettings, MinimalTheoremCalloutSettings, ResolvedMathSettings, TheoremCalloutSettings } from "settings/settings"; 4 | import { generateBlockID, getAncestors, getFile } from "./obsidian"; 5 | import { EquationBlock, MarkdownBlock, MarkdownPage, TheoremCalloutBlock } from "index/typings/markdown"; 6 | import { getIO } from "file-io"; 7 | import { splitIntoLines } from "./general"; 8 | import { THEOREM_LIKE_ENV_IDs, THEOREM_LIKE_ENV_PREFIXES } from "env"; 9 | 10 | 11 | export function resolveSettings(settings: MinimalTheoremCalloutSettings, plugin: LatexReferencer, currentFile: TAbstractFile): ResolvedMathSettings; 12 | export function resolveSettings(settings: undefined, plugin: LatexReferencer, currentFile: TAbstractFile): Required<MathContextSettings>; 13 | export function resolveSettings(settings: MinimalTheoremCalloutSettings | undefined, plugin: LatexReferencer, currentFile: TAbstractFile): Required<MathContextSettings> { 14 | /** Resolves settings. Does not overwride, but returns a new settings object. 15 | * Returned settings can be either 16 | * - ResolvedMathContextSettings or 17 | * - Required<MathContextSettings> & Partial<TheoremCalloutSettings>. 18 | * */ 19 | const resolvedSettings = Object.assign({}, DEFAULT_SETTINGS); 20 | const ancestors = getAncestors(currentFile); 21 | for (const ancestor of ancestors) { 22 | Object.assign(resolvedSettings, plugin.settings[ancestor.path]); 23 | } 24 | Object.assign(resolvedSettings, settings); 25 | return resolvedSettings; 26 | } 27 | 28 | export function getProfile(plugin: LatexReferencer, file: TFile) { 29 | const settings = resolveSettings(undefined, plugin, file); 30 | const profile = plugin.extraSettings.profiles[settings.profile]; 31 | return profile; 32 | } 33 | 34 | export function getProfileByID(plugin: LatexReferencer, profileID: string) { 35 | const profile = plugin.extraSettings.profiles[profileID]; 36 | return profile; 37 | } 38 | 39 | export function staticifyEqNumber(plugin: LatexReferencer, file: TFile) { 40 | const page = plugin.indexManager.index.load(file.path); 41 | if (!MarkdownPage.isMarkdownPage(page)) { 42 | new Notice(`Failed to fetch the metadata of file ${file.path}.`); 43 | return; 44 | } 45 | const io = getIO(plugin, file); 46 | for (const block of page.$blocks.values()) { 47 | if (block instanceof EquationBlock && block.$printName !== null) { 48 | io.setRange( 49 | block.$pos, 50 | `$$\n${block.$mathText} \\tag{${block.$printName.slice(1, -1)}}\n$$` 51 | ); 52 | } 53 | } 54 | } 55 | 56 | export async function insertBlockIdIfNotExist(plugin: LatexReferencer, targetFile: TFile, cache: CachedMetadata, block: MarkdownBlock, length: number = 6): Promise<{ id: string, lineAdded: number } | undefined> { 57 | // Make sure the section cache is fresh enough! 58 | if (!(cache?.sections)) return; 59 | 60 | if (block.$blockId) return { id: block.$blockId, lineAdded: 0 }; 61 | 62 | // The section has no block ID, so let's create a new one 63 | const id = generateBlockID(cache, length); 64 | // and insert it 65 | const io = getIO(plugin, targetFile); 66 | await io.insertLine(block.$position.end + 1, "^" + id); 67 | await io.insertLine(block.$position.end + 1, "") 68 | return { id, lineAdded: 2 }; 69 | } 70 | 71 | export function increaseQuoteLevel(content: string): string { 72 | let lines = content.split("\n"); 73 | lines = lines.map((line) => "> " + line); 74 | return lines.join("\n"); 75 | } 76 | 77 | /** 78 | * Correctly insert a display math even inside callouts or quotes. 79 | */ 80 | export function insertDisplayMath(editor: Editor) { 81 | const cursorPos = editor.getCursor(); 82 | const line = editor.getLine(cursorPos.line).trimStart(); 83 | const nonQuoteMatch = line.match(/[^>\s]/); 84 | 85 | const head = nonQuoteMatch?.index ?? line.length; 86 | const quoteLevel = line.slice(0, head).match(/>\s*/g)?.length ?? 0; 87 | let insert = "$$\n" + "> ".repeat(quoteLevel) + "\n" + "> ".repeat(quoteLevel) + "$$"; 88 | 89 | editor.replaceRange(insert, cursorPos); 90 | cursorPos.line += 1; 91 | cursorPos.ch = quoteLevel * 2; 92 | editor.setCursor(cursorPos); 93 | } 94 | 95 | export async function rewriteTheoremCalloutFromV1ToV2(plugin: LatexReferencer, file: TFile) { 96 | const { app, indexManager } = plugin; 97 | 98 | const page = await indexManager.reload(file); 99 | await app.vault.process(file, (data) => convertTheoremCalloutFromV1ToV2(data, page)); 100 | } 101 | 102 | 103 | export const convertTheoremCalloutFromV1ToV2 = (data: string, page: MarkdownPage) => { 104 | const lines = data.split('\n'); 105 | const newLines = [...lines]; 106 | let lineAdded = 0; 107 | 108 | for (const section of page.$sections) { 109 | for (const block of section.$blocks) { 110 | if (!TheoremCalloutBlock.isTheoremCalloutBlock(block)) continue; 111 | if (!block.$v1) continue 112 | 113 | const newHeadLines = [generateTheoremCalloutFirstLine({ 114 | type: block.$settings.type, 115 | number: block.$settings.number, 116 | title: block.$settings.title 117 | })]; 118 | const legacySettings = block.$settings as any; 119 | if (legacySettings.label) newHeadLines.push(`> %% label: ${legacySettings.label} %%`); 120 | if (legacySettings.setAsNoteMathLink) newHeadLines.push(`> %% main %%`); 121 | 122 | newLines.splice(block.$position.start + lineAdded, 1, ...newHeadLines); 123 | 124 | lineAdded += newHeadLines.length - 1; 125 | } 126 | } 127 | 128 | return newLines.join('\n'); 129 | } 130 | 131 | export function generateTheoremCalloutFirstLine(config: TheoremCalloutSettings): string { 132 | const metadata = config.number === 'auto' ? '' : config.number === '' ? '|*' : `|${config.number}`; 133 | let firstLine = `> [!${config.type}${metadata}]${config.fold ?? ''}${config.title ? ' ' + config.title : ''}` 134 | if (config.label) firstLine += `\n> %% label: ${config.label} %%`; 135 | return firstLine; 136 | } 137 | 138 | export function insertTheoremCallout(editor: Editor, config: TheoremCalloutSettings): void { 139 | const selection = editor.getSelection(); 140 | const cursorPos = editor.getCursor(); 141 | 142 | const firstLine = generateTheoremCalloutFirstLine(config); 143 | 144 | if (selection) { 145 | const nLines = splitIntoLines(selection).length; 146 | editor.replaceSelection(firstLine + '\n' + increaseQuoteLevel(selection)); 147 | cursorPos.line += nLines; 148 | } else { 149 | editor.replaceRange(firstLine + '\n> ', cursorPos) 150 | cursorPos.line += 1; 151 | } 152 | 153 | if (config.label) cursorPos.line += 1; 154 | cursorPos.ch = 2; 155 | editor.setCursor(cursorPos); 156 | } 157 | 158 | export function isTheoremCallout(plugin: LatexReferencer, type: string) { 159 | if (plugin.extraSettings.excludeExampleCallout && type === 'example') return false; 160 | return (THEOREM_LIKE_ENV_IDs as unknown as string[]).includes(type) || (THEOREM_LIKE_ENV_PREFIXES as unknown as string[]).includes(type) || type === 'math' 161 | } 162 | 163 | export function insertProof(plugin: LatexReferencer, editor: Editor, context: MarkdownView | MarkdownFileInfo) { 164 | const settings = resolveSettings(undefined, plugin, context.file ?? getFile(plugin.app)); 165 | const cursor = editor.getCursor(); 166 | editor.replaceRange(`\`${settings.beginProof}\`\n\n\`${settings.endProof}\``, cursor); 167 | editor.setCursor({ line: cursor.line + 1, ch: 0 }); 168 | } 169 | -------------------------------------------------------------------------------- /src/utils/render.ts: -------------------------------------------------------------------------------- 1 | import { Component, MarkdownRenderer, renderMath } from "obsidian"; 2 | 3 | export function renderTextWithMath(source: string): (HTMLElement | string)[] { 4 | // Obsidian API's renderMath only can render math itself, but not a text with math in it. 5 | // e.g., it can handle "\\sqrt{x}", but cannot "$\\sqrt{x}$ is a square root" 6 | 7 | const elements: (HTMLElement | string)[] = []; 8 | 9 | const mathPattern = /\$(.*?[^\s])\$/g; 10 | let result; 11 | let textFrom = 0; 12 | let textTo = 0; 13 | while ((result = mathPattern.exec(source)) !== null) { 14 | const mathString = result[1]; 15 | textTo = result.index; 16 | if (textTo > textFrom) { 17 | elements.push(source.slice(textFrom, textTo)); 18 | } 19 | textFrom = mathPattern.lastIndex; 20 | 21 | const mathJaxEl = renderMath(mathString, false); 22 | 23 | const mathSpan = createSpan({ cls: ["math", "math-inline", "is-loaded"] }); 24 | mathSpan.replaceChildren(mathJaxEl); 25 | elements.push(mathSpan); 26 | } 27 | 28 | if (textFrom < source.length) { 29 | elements.push(source.slice(textFrom)); 30 | } 31 | 32 | return elements; 33 | } 34 | 35 | /** 36 | * Easy-to-use version of MarkdownRenderer.renderMarkdown. 37 | * @param markdown 38 | * @param sourcePath 39 | * @param component - Typically you can just pass the plugin instance. 40 | * @returns 41 | */ 42 | export async function renderMarkdown(markdown: string, sourcePath: string, component: Component): Promise<NodeList | undefined> { 43 | const el = createSpan(); 44 | await MarkdownRenderer.renderMarkdown(markdown, el, sourcePath, component); 45 | for (const child of el.children) { 46 | if (child.tagName == "P") { 47 | return child.childNodes; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /styles.scss: -------------------------------------------------------------------------------- 1 | @use 'styles/main'; 2 | @use 'styles/framed'; 3 | @use 'styles/plain'; 4 | @use 'styles/mathwiki'; 5 | @use 'styles/vivid'; 6 | 7 | :has(> .theorem-callout-framed) { 8 | @include framed.framed(); 9 | } 10 | 11 | :has(> .theorem-callout-plain) { 12 | @include plain.plain(); 13 | } 14 | 15 | :has(> .theorem-callout-mathwiki) { 16 | @include mathwiki.mathwiki(); 17 | } 18 | 19 | :has(> .theorem-callout-vivid) { 20 | @include vivid.vivid(); 21 | } 22 | -------------------------------------------------------------------------------- /styles/framed.scss: -------------------------------------------------------------------------------- 1 | @mixin framed { 2 | /* 3 | If you're going to use this file as a CSS snippet, only include the code between 4 | the START and END comments to your snippet file. 5 | */ 6 | /* START */ 7 | .theorem-callout { 8 | --callout-color: var(--text-normal); 9 | background-color: rgb(0, 0, 0, 0); 10 | border: solid var(--border-width); 11 | border-radius: var(--size-2-3); 12 | font-family: CMU Serif, Times, Noto Serif JP; 13 | } 14 | 15 | .theorem-callout .callout-icon { 16 | display: none; 17 | } 18 | 19 | .theorem-callout-main-title { 20 | font-family: CMU Serif, Times, Noto Sans JP; 21 | font-weight: bolder; 22 | } 23 | 24 | .theorem-callout-subtitle { 25 | font-weight: normal; 26 | } 27 | 28 | :not(.theorem-callout-axiom):not(.theorem-callout-definition):not(.theorem-callout-remark).theorem-callout-en .callout-content { 29 | font-style: italic; 30 | } 31 | /* END */ 32 | } -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | .math-booster-new-feature > p { 2 | color: var(--text-warning); 3 | margin: 0; 4 | } 5 | 6 | .math-booster-version-2-release-note-modal { 7 | --table-border-width: 2px; 8 | } 9 | 10 | .editor-suggest-setting-indented-heading { 11 | margin-left: var(--size-4-1); 12 | } 13 | .editor-suggest-setting-indented-heading + .setting-item, 14 | .editor-suggest-setting-indented-heading + .setting-item + .setting-item { 15 | margin-left: var(--size-4-3); 16 | } 17 | 18 | .math-booster-modal-top { 19 | padding: var(--size-4-3); 20 | } 21 | 22 | .math-booster-modal-top .setting-item { 23 | border-top: none; 24 | border-bottom: 1px solid var(--background-modifier-border); 25 | } 26 | 27 | .math-booster-setting-item-description { 28 | padding-bottom: 0.75em; 29 | } 30 | 31 | .math-booster-search-item-description { 32 | color: var(--text-faint); 33 | } 34 | 35 | .math-booster-backlink-modal { 36 | width: var(--file-line-width); 37 | } 38 | 39 | .math-booster-backlink-preview { 40 | border: var(--border-width) solid var(--background-modifier-border); 41 | border-radius: var(--radius-s); 42 | padding: var(--size-4-6); 43 | } 44 | 45 | .math-booster-begin-proof { 46 | padding-right: 10px; 47 | font-family: CMU Serif, Times, Noto Serif JP; 48 | font-weight: bold; 49 | } 50 | 51 | .math-booster-begin-proof-en { 52 | font-style: italic; 53 | } 54 | 55 | 56 | .math-booster-end-proof { 57 | float: right; 58 | } 59 | 60 | .math-booster-add-profile { 61 | display: flex; 62 | flex-direction: row; 63 | justify-content: space-between; 64 | } 65 | 66 | .math-booster-add-profile > input { 67 | width: 200px; 68 | } 69 | 70 | .math-booster-button-container { 71 | display: flex; 72 | flex-direction: row; 73 | justify-content: flex-end; 74 | gap: var(--size-4-2); 75 | padding: var(--size-4-2); 76 | } 77 | 78 | .theorem-callout { 79 | position: relative; 80 | } 81 | 82 | .theorem-callout-setting-button { 83 | padding-bottom: var(--size-2-2); 84 | padding-right: var(--size-2-3); 85 | position: absolute; 86 | right: var(--size-2-2); 87 | bottom: var(--size-2-2); 88 | opacity: 0; 89 | } 90 | 91 | .theorem-callout:hover .theorem-callout-setting-button { 92 | transition: 0s; 93 | opacity: 1 94 | } 95 | 96 | .theorem-callout-font-family-inherit { 97 | font-family: inherit !important; 98 | } 99 | 100 | .math-booster-title-form, 101 | .math-booster-label-form { 102 | width: 300px; 103 | } 104 | 105 | 106 | /* The code below was taken from the Latex Suite plugin (https://github.com/artisticat1/obsidian-latex-suite/blob/a5914c70c16d5763a182ec51d9716110b40965cf/styles.css) and adapted. 107 | 108 | MIT License 109 | 110 | Copyright (c) 2022 artisticat1 111 | 112 | Permission is hereby granted, free of charge, to any person obtaining a copy 113 | of this software and associated documentation files (the "Software"), to deal 114 | in the Software without restriction, including without limitation the rights 115 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 116 | copies of the Software, and to permit persons to whom the Software is 117 | furnished to do so, subject to the following conditions: 118 | 119 | The above copyright notice and this permission notice shall be included in all 120 | copies or substantial portions of the Software. 121 | 122 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 123 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 124 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 125 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 126 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 127 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 128 | SOFTWARE. 129 | */ 130 | .math-booster-dependency-validation { 131 | color: white; 132 | display: inline-block; 133 | border-radius: 1em; 134 | margin-right: var(--size-3); 135 | cursor: default; 136 | pointer-events: none; 137 | } 138 | 139 | .math-booster-dependency-validation svg { 140 | width: 16px !important; 141 | height: 16px !important; 142 | } 143 | 144 | .math-booster-dependency-validation.valid { 145 | background-color: #7dc535; 146 | visibility: visible; 147 | } 148 | 149 | .theme-dark .math-booster-dependency-validation.valid { 150 | background-color: #588b24; 151 | } 152 | 153 | .math-booster-dependency-validation.invalid { 154 | background-color: #ea5555; 155 | visibility: visible; 156 | } 157 | -------------------------------------------------------------------------------- /styles/mathwiki.scss: -------------------------------------------------------------------------------- 1 | @mixin mathwiki { 2 | /* 3 | If you're going to use this file as a CSS snippet, only include the code between 4 | the START and END comments to your snippet file. 5 | */ 6 | /* START */ 7 | .theorem-callout { 8 | --callout-color: 248, 248, 255; 9 | font-family: CMU Serif, Times, Noto Serif JP; 10 | } 11 | 12 | .theorem-callout .callout-title-inner { 13 | padding-left: 5px; 14 | } 15 | 16 | .theorem-callout-subtitle { 17 | font-weight: normal; 18 | } 19 | 20 | .theorem-callout-en .callout-content { 21 | font-style: italic; 22 | } 23 | 24 | .theorem-callout-axiom { 25 | /* Font Awesome: lock */ 26 | --callout-icon: '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="lock" class="svg-inline--fa fa-lock fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z"></path></svg>'; 27 | } 28 | 29 | .theorem-callout-definition { 30 | /* Font Awesome: book */ 31 | --callout-icon: '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="book" class="svg-inline--fa fa-book fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M448 360V24c0-13.3-10.7-24-24-24H96C43 0 0 43 0 96v320c0 53 43 96 96 96h328c13.3 0 24-10.7 24-24v-16c0-7.5-3.5-14.3-8.9-18.7-4.2-15.4-4.2-59.3 0-74.7 5.4-4.3 8.9-11.1 8.9-18.6zM128 134c0-3.3 2.7-6 6-6h212c3.3 0 6 2.7 6 6v20c0 3.3-2.7 6-6 6H134c-3.3 0-6-2.7-6-6v-20zm0 64c0-3.3 2.7-6 6-6h212c3.3 0 6 2.7 6 6v20c0 3.3-2.7 6-6 6H134c-3.3 0-6-2.7-6-6v-20zm253.4 250H96c-17.7 0-32-14.3-32-32 0-17.6 14.4-32 32-32h285.4c-1.9 17.1-1.9 46.9 0 64z"></path></svg>'; 32 | } 33 | 34 | .theorem-callout-theorem { 35 | /* Font Awesome: magic */ 36 | --callout-icon: '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="magic" class="svg-inline--fa fa-magic fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M224 96l16-32 32-16-32-16-16-32-16 32-32 16 32 16 16 32zM80 160l26.66-53.33L160 80l-53.34-26.67L80 0 53.34 53.33 0 80l53.34 26.67L80 160zm352 128l-26.66 53.33L352 368l53.34 26.67L432 448l26.66-53.33L512 368l-53.34-26.67L432 288zm70.62-193.77L417.77 9.38C411.53 3.12 403.34 0 395.15 0c-8.19 0-16.38 3.12-22.63 9.38L9.38 372.52c-12.5 12.5-12.5 32.76 0 45.25l84.85 84.85c6.25 6.25 14.44 9.37 22.62 9.37 8.19 0 16.38-3.12 22.63-9.37l363.14-363.15c12.5-12.48 12.5-32.75 0-45.24zM359.45 203.46l-50.91-50.91 86.6-86.6 50.91 50.91-86.6 86.6z"></path></svg>'; 37 | } 38 | 39 | .theorem-callout-proposition { 40 | /* Font Awesome: calculator */ 41 | --callout-icon: '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="calculator" class="svg-inline--fa fa-calculator fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 0H48C22.4 0 0 22.4 0 48v416c0 25.6 22.4 48 48 48h352c25.6 0 48-22.4 48-48V48c0-25.6-22.4-48-48-48zM128 435.2c0 6.4-6.4 12.8-12.8 12.8H76.8c-6.4 0-12.8-6.4-12.8-12.8v-38.4c0-6.4 6.4-12.8 12.8-12.8h38.4c6.4 0 12.8 6.4 12.8 12.8v38.4zm0-128c0 6.4-6.4 12.8-12.8 12.8H76.8c-6.4 0-12.8-6.4-12.8-12.8v-38.4c0-6.4 6.4-12.8 12.8-12.8h38.4c6.4 0 12.8 6.4 12.8 12.8v38.4zm128 128c0 6.4-6.4 12.8-12.8 12.8h-38.4c-6.4 0-12.8-6.4-12.8-12.8v-38.4c0-6.4 6.4-12.8 12.8-12.8h38.4c6.4 0 12.8 6.4 12.8 12.8v38.4zm0-128c0 6.4-6.4 12.8-12.8 12.8h-38.4c-6.4 0-12.8-6.4-12.8-12.8v-38.4c0-6.4 6.4-12.8 12.8-12.8h38.4c6.4 0 12.8 6.4 12.8 12.8v38.4zm128 128c0 6.4-6.4 12.8-12.8 12.8h-38.4c-6.4 0-12.8-6.4-12.8-12.8V268.8c0-6.4 6.4-12.8 12.8-12.8h38.4c6.4 0 12.8 6.4 12.8 12.8v166.4zm0-256c0 6.4-6.4 12.8-12.8 12.8H76.8c-6.4 0-12.8-6.4-12.8-12.8V76.8C64 70.4 70.4 64 76.8 64h294.4c6.4 0 12.8 6.4 12.8 12.8v102.4z"></path></svg>'; 42 | } 43 | 44 | .theorem-callout-example { 45 | /* Font Awesome: anchor */ 46 | --callout-icon: '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="anchor" class="svg-inline--fa fa-anchor fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M12.971 352h32.394C67.172 454.735 181.944 512 288 512c106.229 0 220.853-57.38 242.635-160h32.394c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0l-67.029 67.029c-7.56 7.56-2.206 20.485 8.485 20.485h35.146c-20.29 54.317-84.963 86.588-144.117 94.015V256h52c6.627 0 12-5.373 12-12v-40c0-6.627-5.373-12-12-12h-52v-5.47c37.281-13.178 63.995-48.725 64-90.518C384.005 43.772 341.605.738 289.37.01 235.723-.739 192 42.525 192 96c0 41.798 26.716 77.35 64 90.53V192h-52c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h52v190.015c-58.936-7.399-123.82-39.679-144.117-94.015h35.146c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0L4.485 331.515C-3.074 339.074 2.28 352 12.971 352zM288 64c17.645 0 32 14.355 32 32s-14.355 32-32 32-32-14.355-32-32 14.355-32 32-32z"></path></svg>'; 47 | } 48 | /* END */ 49 | } -------------------------------------------------------------------------------- /styles/plain.scss: -------------------------------------------------------------------------------- 1 | @mixin plain { 2 | /* 3 | If you're going to use this file as a CSS snippet, only include the code between 4 | the START and END comments to your snippet file. 5 | */ 6 | /* START */ 7 | .theorem-callout { 8 | --callout-color: var(--text-normal); 9 | background-color: rgb(0, 0, 0, 0); 10 | padding-left: 0; 11 | padding-right: 0; 12 | border: none; 13 | box-shadow: none; 14 | font-family: CMU Serif, Times, Noto Serif JP; 15 | } 16 | 17 | .theorem-callout .callout-icon { 18 | display: none; 19 | } 20 | 21 | .theorem-callout-main-title { 22 | font-family: CMU Serif, Times, Noto Sans JP; 23 | font-weight: bolder; 24 | } 25 | 26 | .theorem-callout-subtitle { 27 | font-weight: normal; 28 | } 29 | 30 | :not(.theorem-callout-axiom):not(.theorem-callout-definition):not(.theorem-callout-remark).theorem-callout-en .callout-content { 31 | font-style: italic; 32 | } 33 | /* END */ 34 | } -------------------------------------------------------------------------------- /styles/vivid.scss: -------------------------------------------------------------------------------- 1 | @mixin vivid { 2 | 3 | /* 4 | If you're going to use this file as a CSS snippet, only include the code between 5 | the START and END comments to your snippet file. 6 | */ 7 | /* START */ 8 | .theorem-callout { 9 | --callout-color: 238, 15, 149; 10 | border-top: none; 11 | border-bottom: none; 12 | border-left: var(--size-2-2) solid rgb(var(--callout-color)); 13 | border-right: none; 14 | border-radius: 0px; 15 | box-shadow: none; 16 | padding: 0px; 17 | font-family: CMU Serif, Times, Noto Serif JP; 18 | } 19 | 20 | .theorem-callout .callout-title { 21 | padding: var(--size-2-3); 22 | padding-left: var(--size-4-3); 23 | } 24 | 25 | .theorem-callout .callout-icon { 26 | display: none; 27 | } 28 | 29 | .theorem-callout .callout-title-inner { 30 | font-family: Inter; 31 | font-weight: normal; 32 | color: rgb(var(--callout-color)); 33 | } 34 | 35 | .theorem-callout-subtitle { 36 | font-weight: lighter; 37 | } 38 | 39 | .theorem-callout .callout-content { 40 | background-color: var(--background-primary); 41 | padding: 1px 20px 2px 20px; 42 | } 43 | /* END */ 44 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 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 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7", 19 | "ES2021.String", 20 | "DOM.Iterable", 21 | "es2022" 22 | ] 23 | }, 24 | "include": [ 25 | "**/*.ts" 26 | ], 27 | "exclude": [ 28 | "src/legacy/*.ts", 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.3.0": "1.3.5", 3 | "0.3.1": "1.3.5", 4 | "0.3.2": "1.3.5" 5 | } --------------------------------------------------------------------------------