initializing

This commit is contained in:
JK 2025-10-30 20:16:32 +01:00
commit 7e75089cb2
118 changed files with 11611 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

2
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,2 @@
github: RyotaUshio
custom: ['https://www.buymeacoffee.com/ryotaushio']

35
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install -g sass
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# This is auto-generated by the css files in the styles directory.
styles.css
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Docs
docs/_site/
# Unused
src/callout_decoration.ts
exported_latest/*
.export-to-txt.py

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Ryota Ushio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

98
README.md Normal file
View file

@ -0,0 +1,98 @@
# LaTeX-like Theorem & Equation Referencer for Obsidian
> [!important]
> 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).
**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.
![Screenshot](https://raw.githubusercontent.com/RyotaUshio/obsidian-math-booster/1c7b106fcfbddccdcda8451de1c21a094994b686/docs/fig/screenshot.png)
(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))
## Docs
https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/
## Features
- [Theorem environments](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/theorem-callouts.html)
- [Automatic equation numbering](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/equations.html)
- [Clever referencing](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/clever-referencing.html)
- [Search & link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/search-&-link-autocomplete.html)
- [Custom link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/custom-link-autocomplete.html)
- Easily find & insert link to theorems & equations.
- Filter theorems & equations based on their locations (*entire vault/recent notes/active note*)
- [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*
- [Proof environment (experimental)](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/proof-environment.html)
> [!note]
> 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:
>
> #### Transitioning to [**Better Math in Callouts & Blockquotes**](https://github.com/RyotaUshio/obsidian-math-in-callout)
>
> - Rendering equations inside callouts
> - Multi-line equation support inside blockquotes
>
> #### Transitioning to [**Rendered Block Link Suggestions**](https://github.com/RyotaUshio/obsidian-rendered-block-link-suggestions)
>
> - [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)
Theorems & equations can be **dynamically/automatically numbered**, while you can statically/manually number them if you want.
The number prefix can be either explicitly specified or automatically inferred from the note title.
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!)
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).
## Companion plugins
Here's a list of other math-related plugins I've developed:
- [No More Flickering Inline Math](https://github.com/RyotaUshio/obsidian-inline-math)
- [Better Math in Callouts & Blockquotes](https://github.com/RyotaUshio/obsidian-math-in-callout)
- [MathJax Preamble Manager](https://github.com/RyotaUshio/obsidian-mathjax-preamble-manager)
- [Auto-\\displaystyle Inline Math](https://github.com/RyotaUshio/obsidian-auto-displaystyle-inline-math)
## Installation
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).
Also, you can test the latest beta release using [BRAT](https://github.com/TfTHacker/obsidian42-brat):
1. Install BRAT and enable it.
2. Go to **Options**. In the **Beta Plugin List** section, click on the **Add Beta plugin** button.
3. Copy and paste `RyotaUshio/obsidian-latex-theorem-equation-referencer` in the pop-up prompt and click on **Add Plugin**.
4. _(Optional)_ Turn on **Auto-update plugins at startup** at the top of the page.
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.
Since version 2 is still beta, it's not on the community plugin browser yet.
## Dependencies
### Obsidian plugins
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)).
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.
### Fonts
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.
Additionally, [Noto Sans JP](https://fonts.google.com/noto/specimen/Noto+Sans+JP) is required for render the preset styles properly in Japanese.
## Contributing
- Feel free to create a new issue if something is not working well. Questions are also welcomed.
- Please send a pull request if you have any ideas to improve this plugin and our experience!
- Contribution to the docs is also highly appreciated: see [here](https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer-docs).
## Roadmaps
- Import from LaTeX: ArXiv papers, research/literature notes written in LaTeX, ...
- Export to LaTeX: Write research notes in Obsidian, and then export them into LaTeX.
## Support development
If you find this plugin useful, please support my work by buying me a coffee!
<a href="https://www.buymeacoffee.com/ryotaushio" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>

File diff suppressed because one or more lines are too long

1
docs/commands.html Normal file

File diff suppressed because one or more lines are too long

1
docs/credits.html Normal file

File diff suppressed because one or more lines are too long

79
docs/equations.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
docs/index.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

51
esbuild.config.mjs Normal file
View file

@ -0,0 +1,51 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
plugins: [inlineWorkerPlugin()],
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
minify: prod,
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "../plugin-full-calendar/obsidian-dev-vault/.obsidian/plugins/Latex-like-equations/main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

12
manifest-beta.json Normal file
View file

@ -0,0 +1,12 @@
{
"id": "math-booster",
"name": "LaTeX-like Equation Referencer",
"version": "2.2.1-YouFoundJK",
"minAppVersion": "1.3.5",
"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.",
"author": "Ryota Ushio",
"authorUrl": "https://github.com/RyotaUshio",
"fundingUrl": "https://www.buymeacoffee.com/ryotaushio",
"helpUrl": "https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/",
"isDesktopOnly": false
}

12
manifest.json Normal file
View file

@ -0,0 +1,12 @@
{
"id": "math-booster",
"name": "LaTeX-like Equation Referencer",
"version": "2.2.1-YouFoundJK",
"minAppVersion": "1.3.5",
"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.",
"author": "Ryota Ushio",
"authorUrl": "https://github.com/RyotaUshio",
"fundingUrl": "https://www.buymeacoffee.com/ryotaushio",
"helpUrl": "https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/",
"isDesktopOnly": false
}

2388
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "obsidian-math-booster",
"version": "2.2.0",
"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.",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && sass styles.scss styles.css",
"prod": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"dev-style": "sass --watch styles.scss styles.css",
"build-style": "sass --watch styles.scss styles.css",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "Ryota Ushio",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.19.4",
"obsidian": "latest",
"obsidian-mathlinks": "^0.5.1",
"obsidian-quick-preview": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.0.3",
"esbuild-plugin-inline-worker": "^0.1.1",
"flatqueue": "^2.0.3",
"monkey-around": "^2.3.0",
"sorted-btree": "^1.8.1"
}
}

67
src/cleveref.ts Normal file
View file

@ -0,0 +1,67 @@
import { EquationBlock } from 'index/typings/markdown';
import { TFile, HeadingSubpathResult, BlockSubpathResult, App } from 'obsidian';
import * as MathLinks from 'obsidian-mathlinks';
import LatexReferencer from 'main';
import { MathIndex } from 'index/math-index';
import { MarkdownPage, MathBlock, TheoremCalloutBlock } from 'index/typings/markdown';
export class CleverefProvider extends MathLinks.Provider {
app: App;
index: MathIndex;
constructor(mathLinks: any, public plugin: LatexReferencer) {
super(mathLinks);
this.app = plugin.app;
this.index = plugin.indexManager.index;
}
provide(
parsedLinktext: { path: string; subpath: string; },
targetFile: TFile | null,
targetSubpathResult: HeadingSubpathResult | BlockSubpathResult | null,
): string | null {
const { path, subpath } = parsedLinktext;
if (targetFile === null) return null;
const page = this.index.load(targetFile.path);
if (!MarkdownPage.isMarkdownPage(page)) return null
// only path, no subpath: return page.$refName if it exists, otherwise there's nothing to do
if (!subpath) return page.$refName ?? null;
const processedPath = path ? page.$refName ?? path : '';
// subpath resolution failed, do nothing
if (targetSubpathResult === null) return null;
// subpath resolution succeeded
if (targetSubpathResult.type === 'block') {
// handle block links
// get the target block
const block = page.$blocks.get(targetSubpathResult.block.id);
if (MathBlock.isMathBlock(block)) {
// display text set manually: higher priority
if (block.$display) return path && this.shouldShowNoteTitle(block) ? processedPath + ' > ' + block.$display : block.$display;
// display text computed automatically: lower priority
if (block.$refName) return path && this.shouldShowNoteTitle(block) ? processedPath + ' > ' + block.$refName : block.$refName;
}
} else {
// handle heading links
// just ignore (return null) if we don't need to perform any particular processing
if (path && page.$refName) {
return processedPath + ' > ' + subpath;
}
}
return null;
}
shouldShowNoteTitle(block: MathBlock): boolean {
if (TheoremCalloutBlock.isTheoremCalloutBlock(block)) return this.plugin.extraSettings.noteTitleInTheoremLink;
if (EquationBlock.isEquationBlock(block)) return this.plugin.extraSettings.noteTitleInEquationLink;
return true;
}
}

63
src/env.ts Normal file
View file

@ -0,0 +1,63 @@
// length must be >= 5
export const THEOREM_LIKE_ENV_IDs = [
"axiom",
"definition",
"lemma",
"proposition",
"theorem",
"corollary",
"claim",
"assumption",
"example",
"exercise",
"conjecture",
"hypothesis",
"remark",
] as const;
export const PROOF_LIKE_ENV_IDs = [
"proof",
"solution",
] as const;
export const ENV_IDs = [...THEOREM_LIKE_ENV_IDs, ...PROOF_LIKE_ENV_IDs,] as const;
// length must be <= 4
export const THEOREM_LIKE_ENV_PREFIXES = [
"axm",
"def",
"lem",
"prp",
"thm",
"cor",
"clm",
"asm",
"exm",
"exr",
"cnj",
"hyp",
"rmk",
] as const;
export type TheoremLikeEnvID = typeof THEOREM_LIKE_ENV_IDs[number];
export type TheoremLikeEnvPrefix = typeof THEOREM_LIKE_ENV_PREFIXES[number];
export interface TheoremLikeEnv {
id: TheoremLikeEnvID,
prefix: TheoremLikeEnvPrefix,
}
export const THEOREM_LIKE_ENVs = {} as Record<TheoremLikeEnvID, TheoremLikeEnv>;
THEOREM_LIKE_ENV_IDs.forEach((id, index) => {
THEOREM_LIKE_ENVs[id] = {id, prefix: THEOREM_LIKE_ENV_PREFIXES[index]};
});
export const THEOREM_LIKE_ENV_PREFIX_ID_MAP = {} as Record<TheoremLikeEnvPrefix, TheoremLikeEnvID>;
THEOREM_LIKE_ENV_PREFIXES.forEach((prefix, index) => {
THEOREM_LIKE_ENV_PREFIX_ID_MAP[prefix] = THEOREM_LIKE_ENV_IDs[index];
});
export const THEOREM_LIKE_ENV_ID_PREFIX_MAP = {} as Record<TheoremLikeEnvID, TheoremLikeEnvPrefix>;
THEOREM_LIKE_ENV_IDs.forEach((id, index) => {
THEOREM_LIKE_ENV_ID_PREFIX_MAP[id] = THEOREM_LIKE_ENV_PREFIXES[index];
});

68
src/equations/common.ts Normal file
View file

@ -0,0 +1,68 @@
import { EquationBlock } from "index/typings/markdown";
import { finishRenderMath, renderMath } from "obsidian";
import { MathContextSettings } from "settings/settings";
import { parseLatexComment } from "utils/parse";
export function replaceMathTag(displayMathEl: HTMLElement, equation: EquationBlock, settings: Required<MathContextSettings>) {
if (equation.$manualTag) return; // respect a tag (\tag{...}) manually set by the user
const taggedText = getMathTextWithTag(equation, settings.lineByLine);
if (taggedText) {
const mjxContainerEl = renderMath(taggedText, true);
if (equation.$printName !== null) {
displayMathEl.setAttribute('width', 'full');
displayMathEl.style.cssText = mjxContainerEl.style.cssText;
} else {
displayMathEl.removeAttribute('width');
displayMathEl.removeAttribute('style');
}
displayMathEl.replaceChildren(...mjxContainerEl.childNodes);
}
}
export function getMathTextWithTag(equation: EquationBlock, lineByLine?: boolean): string | undefined {
if (equation.$printName !== null) {
const tagResult = equation.$printName.match(/^\((.*)\)$/);
if (tagResult) {
const tagContent = tagResult[1];
return insertTagInMathText(equation.$mathText, tagContent, lineByLine);
}
}
return equation.$mathText;
}
export function insertTagInMathText(text: string, tagContent: string, lineByLine?: boolean): string {
if (!lineByLine) return text + `\\tag{${tagContent}}`;
const alignResult = text.match(/^\s*\\begin\{align\}([\s\S]*)\\end\{align\}\s*$/);
if (!alignResult) return text + `\\tag{${tagContent}}`;
const envStack: string[] = [];
// remove comments
let alignContent = alignResult[1]
.split('\n')
.map(line => parseLatexComment(line).nonComment)
.join('\n');
// add tags
let index = 1;
alignContent = alignContent
.split("\\\\")
.map((alignLine) => {
const pattern = /\\(?<which>begin|end)\{(?<env>.*?)\}/g;
let result;
while (result = pattern.exec(alignLine)) {
const { which, env } = result.groups!;
if (which === 'begin') envStack.push(env);
else if (envStack.last() === env) envStack.pop();
}
if (envStack.length || !alignLine.trim() || alignLine.contains("\\nonumber")) return alignLine;
return alignLine + `\\tag{${tagContent}-${index++}}`;
}).join("\\\\");
if (index <= 2) return text + `\\tag{${tagContent}}`;
return "\\begin{align}" + alignContent + "\\end{align}";
}

View file

@ -0,0 +1,119 @@
/**
* Display equation numbers in Live Preview.
*/
import { EditorState, StateEffect } from '@codemirror/state';
import { PluginValue, ViewPlugin, EditorView, ViewUpdate } from '@codemirror/view';
import { EquationBlock, MarkdownBlock, MarkdownPage } from 'index/typings/markdown';
import LatexReferencer from 'main';
import { MarkdownView, TFile, editorInfoField, finishRenderMath } from 'obsidian';
import { resolveSettings } from 'utils/plugin';
import { replaceMathTag } from './common';
import { DEFAULT_SETTINGS, MathContextSettings } from 'settings/settings';
export function createEquationNumberPlugin(plugin: LatexReferencer) {
const { app, indexManager: { index } } = plugin;
const forceUpdateEffect = StateEffect.define<null>();
plugin.registerEvent(plugin.indexManager.on('index-updated', (file) => {
app.workspace.iterateAllLeaves((leaf) => {
if (
leaf.view instanceof MarkdownView
&& leaf.view.file?.path === file.path
&& leaf.view.getMode() === 'source'
) {
leaf.view.editor.cm?.dispatch({ effects: forceUpdateEffect.of(null) });
}
});
}));
return ViewPlugin.fromClass(class implements PluginValue {
file: TFile | null;
page: MarkdownPage | null;
settings: Required<MathContextSettings>;
constructor(view: EditorView) {
this.file = view.state.field(editorInfoField).file;
this.page = null;
this.settings = DEFAULT_SETTINGS;
if (this.file) {
this.settings = resolveSettings(undefined, plugin, this.file);
const page = index.load(this.file.path);
if (MarkdownPage.isMarkdownPage(page)) {
this.page = page;
this.updateEquationNumber(view, this.page);
}
}
}
updateFile(state: EditorState) {
this.file = state.field(editorInfoField).file;
if (this.file) this.settings = resolveSettings(undefined, plugin, this.file);
}
async updatePage(file: TFile): Promise<MarkdownPage> {
const page = index.load(file.path);
if (MarkdownPage.isMarkdownPage(page)) this.page = page;
if (!this.page) {
this.page = await plugin.indexManager.reload(file);
}
return this.page;
}
update(update: ViewUpdate) {
if (!this.file) this.updateFile(update.state);
if (!this.file) return;
if (update.transactions.some(tr => tr.effects.some(effect => effect.is(forceUpdateEffect)))) {
// index updated
this.settings = resolveSettings(undefined, plugin, this.file);
this.updatePage(this.file).then((updatedPage) => this.updateEquationNumber(update.view, updatedPage))
} else if (update.geometryChanged) {
if (this.page) this.updateEquationNumber(update.view, this.page);
else this.updatePage(this.file).then((updatedPage) => this.updateEquationNumber(update.view, updatedPage));
}
}
async updateEquationNumber(view: EditorView, page: MarkdownPage) {
const mjxContainerElements = view.contentDOM.querySelectorAll<HTMLElement>(':scope > .cm-embed-block.math > mjx-container.MathJax[display="true"]');
for (const mjxContainerEl of mjxContainerElements) {
// skip if the equation is being edited to avoid the delay of preview
const mightBeClosingDollars = mjxContainerEl.parentElement?.previousElementSibling?.lastElementChild;
const isBeingEdited = mightBeClosingDollars?.matches('span.cm-formatting-math-end');
if (isBeingEdited) continue;
const pos = view.posAtDOM(mjxContainerEl);
let block: MarkdownBlock | undefined;
try {
const line = view.state.doc.lineAt(pos).number - 1; // sometimes throws an error for reasons that I don't understand
block = page.getBlockByLineNumber(line);
} catch (err) {
block = page.getBlockByOffset(pos);
}
if (!(block instanceof EquationBlock)) continue;
// only update if necessary
if (mjxContainerEl.getAttribute('data-equation-print-name') !== block.$printName) {
replaceMathTag(mjxContainerEl, block, this.settings);
}
if (block.$printName !== null) mjxContainerEl.setAttribute('data-equation-print-name', block.$printName);
else mjxContainerEl.removeAttribute('data-equation-print-name');
}
// DON'T FOREGET THIS CALL!!
// https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/203
// https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/200
finishRenderMath();
}
destroy() {
// I don't know if this is really necessary, but just in case...
finishRenderMath();
}
});
}

View file

@ -0,0 +1,162 @@
/**
* Display equation numbers in reading view, embeds, hover page preview, and PDF export.
*/
import { App, MarkdownRenderChild, finishRenderMath, MarkdownPostProcessorContext, TFile, Notice } from "obsidian";
import LatexReferencer from 'main';
import { resolveSettings } from 'utils/plugin';
import { EquationBlock, MarkdownPage } from "index/typings/markdown";
import { MathIndex } from "index/math-index";
import { isPdfExport, resolveLinktext } from "utils/obsidian";
import { replaceMathTag } from "./common";
export const createEquationNumberProcessor = (plugin: LatexReferencer) => async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
if (isPdfExport(el)) preprocessForPdfExport(plugin, el, ctx);
const sourceFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(sourceFile instanceof TFile)) return;
const mjxContainerElements = el.querySelectorAll<HTMLElement>('mjx-container.MathJax[display="true"]');
for (const mjxContainerEl of mjxContainerElements) {
ctx.addChild(
new EquationNumberRenderer(mjxContainerEl, plugin, sourceFile, ctx)
);
}
finishRenderMath();
}
/**
* As a preprocessing for displaying equation numbers in the exported PDF,
* add an attribute representing a block ID to each numbered equation element
* so that EquationNumberRenderer can find the corresponding block from the index
* without relying on the line number.
*/
function preprocessForPdfExport(plugin: LatexReferencer, el: HTMLElement, ctx: MarkdownPostProcessorContext) {
try {
const topLevelMathDivs = el.querySelectorAll<HTMLElement>(':scope > div.math.math-block > mjx-container.MathJax[display="true"]');
const page = plugin.indexManager.index.getMarkdownPage(ctx.sourcePath);
if (!page) {
new Notice(`${plugin.manifest.name}: Failed to fetch the metadata for PDF export; equation numbers will not be displayed in the exported PDF.`);
return;
}
let equationIndex = 0;
for (const section of page.$sections) {
for (const block of section.$blocks) {
if (!EquationBlock.isEquationBlock(block)) continue;
const div = topLevelMathDivs[equationIndex++];
if (block.$printName) div.setAttribute('data-equation-id', block.$id);
}
}
if (topLevelMathDivs.length != equationIndex) {
new Notice(`${plugin.manifest.name}: Something unexpected occured while preprocessing for PDF export. Equation numbers might not be displayed properly in the exported PDF.`);
}
} catch (err) {
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.`);
console.error(err);
}
}
export class EquationNumberRenderer extends MarkdownRenderChild {
app: App
index: MathIndex;
constructor(containerEl: HTMLElement, public plugin: LatexReferencer, public file: TFile, public context: MarkdownPostProcessorContext) {
// containerEl, currentEL are mjx-container.MathJax elements
super(containerEl);
this.app = plugin.app;
this.index = this.plugin.indexManager.index;
this.registerEvent(this.plugin.indexManager.on("index-initialized", () => {
setTimeout(() => this.update());
}));
this.registerEvent(this.plugin.indexManager.on("index-updated", (file) => {
setTimeout(() => {
if (file.path === this.file.path) this.update();
});
}));
}
getEquationCache(lineOffset: number = 0): EquationBlock | null {
const info = this.context.getSectionInfo(this.containerEl);
const page = this.index.getMarkdownPage(this.file.path);
if (!info || !page) return null;
// get block ID
const block = page.getBlockByLineNumber(info.lineStart + lineOffset) ?? page.getBlockByLineNumber(info.lineEnd + lineOffset);
if (EquationBlock.isEquationBlock(block)) return block;
return null;
}
async onload() {
setTimeout(() => this.update());
}
onunload() {
// I don't know if this is really necessary, but just in case...
finishRenderMath();
}
update() {
// for PDF export
const id = this.containerEl.getAttribute('data-equation-id');
const equation = id ? this.index.getEquationBlock(id) : this.getEquationCacheCaringHoverAndEmbed();
if (!equation) return;
const settings = resolveSettings(undefined, this.plugin, this.file);
replaceMathTag(this.containerEl, equation, settings);
}
getEquationCacheCaringHoverAndEmbed(): EquationBlock | null {
/**
* https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/issues/179
*
* In the case of embeds or hover popovers, the line numbers contained
* in the result of MarkdownPostProcessorContext.getSectionInfo() is
* relative to the content included in the embed.
* In other words, they does not always represent the offset from the beginning of the file.
* So they require special handling.
*/
const equation = this.getEquationCache();
let linktext = this.containerEl.closest('[src]')?.getAttribute('src'); // in the case of embeds
if (!linktext) {
const hoverEl = this.containerEl.closest<HTMLElement>('.hover-popover:not(.hover-editor)');
if (hoverEl) {
// The current context is hover page preview; read the linktext saved in the plugin instance.
linktext = this.plugin.lastHoverLinktext;
}
}
if (linktext) { // linktext was found
const { file, subpathResult } = resolveLinktext(this.app, linktext, this.context.sourcePath) ?? {};
if (!file || !subpathResult) return null;
const page = this.index.load(file.path);
if (!MarkdownPage.isMarkdownPage(page)) return null;
if (subpathResult.type === "block") {
const block = page.$blocks.get(subpathResult.block.id);
if (!EquationBlock.isEquationBlock(block)) return null;
return block;
} else {
return this.getEquationCache(subpathResult.start.line);
}
}
return equation;
}
}

113
src/file-io.ts Normal file
View file

@ -0,0 +1,113 @@
import { CachedMetadata, Editor, MarkdownView, Pos, TFile } from "obsidian";
import LatexReferencer from "./main";
import { isEditingView, locToEditorPosition } from "utils/editor";
import { insertAt, splitIntoLines } from "utils/general";
export abstract class FileIO {
constructor(public plugin: LatexReferencer, public file: TFile) { }
abstract setLine(lineNumber: number, text: string): Promise<void>;
abstract setRange(position: Pos, text: string): Promise<void>;
abstract insertLine(lineNumber: number, text: string): Promise<void>;
abstract getLine(lineNumber: number): Promise<string>;
abstract getRange(position: Pos): Promise<string>;
}
export class ActiveNoteIO extends FileIO {
/**
* File IO for the currently active markdown view.
* Uses the Editor interface instead of Vault.
* (See https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines#Prefer+the+Editor+API+instead+of+%60Vault.modify%60)
* @param editor
*/
constructor(plugin: LatexReferencer, file: TFile, public editor: Editor) {
super(plugin, file);
}
async setLine(lineNumber: number, text: string): Promise<void> {
this.editor.setLine(lineNumber, text);
}
async setRange(position: Pos, text: string): Promise<void> {
const from = locToEditorPosition(position.start);
const to = locToEditorPosition(position.end);
this.editor.replaceRange(text, from, to);
}
async insertLine(lineNumber: number, text: string): Promise<void> {
this.editor.replaceRange(text + "\n", { line: lineNumber, ch: 0 });
}
async getLine(lineNumber: number): Promise<string> {
return this.editor.getLine(lineNumber);
}
async getRange(position: Pos): Promise<string> {
const from = locToEditorPosition(position.start);
const to = locToEditorPosition(position.end);
const text = this.editor.getRange(from, to);
return text;
}
}
export class NonActiveNoteIO extends FileIO {
_data: string | null = null;
/**
* File IO for non-active (= currently not opened / currently opened but not focused) notes.
* Uses the Vault interface instead of Editor.
*/
constructor(plugin: LatexReferencer, file: TFile) {
super(plugin, file);
}
async setLine(lineNumber: number, text: string): Promise<void> {
this.plugin.app.vault.process(this.file, (data: string): string => {
const lines = splitIntoLines(data);
lines[lineNumber] = text;
return lines.join('\n');
})
}
async setRange(position: Pos, text: string): Promise<void> {
this.plugin.app.vault.process(this.file, (data: string): string => {
return data.slice(0, position.start.offset) + text + data.slice(position.end.offset + 1, data.length);
})
}
async insertLine(lineNumber: number, text: string): Promise<void> {
this.plugin.app.vault.process(this.file, (data: string): string => {
const lines = splitIntoLines(data);
insertAt(lines, text, lineNumber);
return lines.join('\n');
})
}
async getLine(lineNumber: number): Promise<string> {
const data = await this.plugin.app.vault.cachedRead(this.file);
const lines = splitIntoLines(data);
return lines[lineNumber];
}
async getRange(position: Pos): Promise<string> {
const content = await this.plugin.app.vault.cachedRead(this.file);
return content.slice(position.start.offset, position.end.offset);
}
}
/**
* Automatically judges which of ActiveNoteIO or NonActiveNoteIO
* should be used for the given file.
*/
export function getIO(plugin: LatexReferencer, file: TFile, activeMarkdownView?: MarkdownView | null) {
activeMarkdownView = activeMarkdownView ?? plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (activeMarkdownView && activeMarkdownView.file == file && isEditingView(activeMarkdownView)) {
return new ActiveNoteIO(plugin, file, activeMarkdownView.editor);
} else {
return new NonActiveNoteIO(plugin, file);
}
}

23
src/index/README.md Normal file
View file

@ -0,0 +1,23 @@
The source code contained in this directory was taken from the Datacore plugin (https://github.com/blacksmithgu/datacore) and adapted.
MIT License
Copyright (c) 2023 Michael Brenan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,184 @@
import { getFileTitle } from "../utils/normalizers";
/** The Obsidian 'link', used for uniquely describing a file, header, or block. */
export class Link {
/** The file path this link points to. */
public path: string;
/** The display name associated with the link. */
public display?: string;
/** The block ID or header this link points to within a file, if relevant. */
public subpath?: string;
/** Is this link an embedded link (of form '![[hello]]')? */
public embed: boolean;
/** The type of this link, which determines what 'subpath' refers to, if anything. */
public type: "file" | "header" | "block";
/** Create a link to a specific file. */
public static file(path: string, embed: boolean = false, display?: string): Link {
return new Link({
path,
embed,
display,
subpath: undefined,
type: "file",
});
}
/** Infer the type of the link from the full internal link path. */
public static infer(linkpath: string, embed: boolean = false, display?: string): Link {
if (linkpath.includes("#^")) {
let split = linkpath.split("#^");
return Link.block(split[0], split[1], embed, display);
} else if (linkpath.includes("#")) {
let split = linkpath.split("#");
return Link.header(split[0], split[1], embed, display);
} else return Link.file(linkpath, embed, display);
}
/** Create a link to a specific file and header in that file. */
public static header(path: string, header: string, embed?: boolean, display?: string): Link {
// Headers need to be normalized to alpha-numeric & with extra spacing removed.
return new Link({
path,
embed,
display,
subpath: header, //normalizeHeaderForLink(header),
type: "header",
});
}
/** Create a link to a specific file and block in that file. */
public static block(path: string, blockId: string, embed?: boolean, display?: string): Link {
return new Link({
path,
embed,
display,
subpath: blockId,
type: "block",
});
}
/** Load a link from it's raw JSON representation. */
public static fromObject(object: Record<string, any>): Link {
return new Link(object);
}
/** Create a link by parsing it's interior part (inside of the '[[]]'). */
public static parseInner(rawlink: string): Link {
let [link, display] = splitOnUnescapedPipe(rawlink);
return Link.infer(link, false, display);
}
private constructor(fields: Partial<Link>) {
Object.assign(this, fields);
}
/** Update this link with a new path. */
public withPath(path: string): Link {
return new Link(Object.assign({}, this, { path }));
}
/** Return a new link which points to the same location but with a new display value. */
public withDisplay(display?: string): Link {
return new Link(Object.assign({}, this, { display }));
}
/** Return a new link which has the given embedded status. */
public withEmbed(embed: boolean): Link {
if (this.embed == embed) return this;
return new Link(Object.assign({}, this, { embed }));
}
/** Convert a file link into a link to a specific header. */
public withHeader(header: string): Link {
return Link.header(this.path, header, this.embed, this.display);
}
/** Convert a file link into a link to a specificb lock. */
public withBlock(block: string): Link {
return Link.block(this.path, block, this.embed, this.display);
}
/** Checks for link equality (i.e., that the links are pointing to the same exact location). */
public equals(other: Link): boolean {
if (other == undefined || other == null) return false;
return this.path == other.path && this.type == other.type && this.subpath == other.subpath;
}
/** Convert this link to it's markdown representation. */
public toString(): string {
return this.markdown();
}
/** Convert this link to a raw object which is serialization-friendly. */
public toObject(): Record<string, any> {
return {
path: this.path,
type: this.type,
subpath: this.subpath,
display: this.display,
embed: this.embed,
};
}
/** Convert any link into a link to its file. */
public toFile() {
return Link.file(this.path, this.embed, this.display);
}
/** Convert this link into an embedded link. */
public toEmbed(): Link {
return this.withEmbed(true);
}
/** Convert this link into a non-embedded link. */
public fromEmbed(): Link {
return this.withEmbed(false);
}
/** Convert this link to markdown so it can be rendered. */
public markdown(): string {
let result = (this.embed ? "!" : "") + "[[" + this.obsidianLink();
result += this.displayOrDefault();
result += "]]";
return result;
}
/** Obtain the display for this link, or return a simple default display. */
public displayOrDefault() {
if (this.display) {
return this.display;
} else {
let result = getFileTitle(this.path);
if (this.type == "header" || this.type == "block") result += " > " + this.subpath;
return result;
}
}
/** Convert the inner part of the link to something that Obsidian can open / understand. */
public obsidianLink(): string {
const escaped = this.path.replace("|", "\\|");
if (this.type == "header") return escaped + "#" + this.subpath?.replace("|", "\\|");
if (this.type == "block") return escaped + "#^" + this.subpath?.replace("|", "\\|");
else return escaped;
}
/** The stripped name of the file this link points to. */
public fileName(): string {
return getFileTitle(this.path);
}
}
/** Split on unescaped pipes in an inner link. */
export function splitOnUnescapedPipe(link: string): [string, string | undefined] {
let pipe = -1;
while ((pipe = link.indexOf("|", pipe + 1)) >= 0) {
if (pipe > 0 && link[pipe - 1] == "\\") continue;
return [link.substring(0, pipe).replace(/\\\|/g, "|"), link.substring(pipe + 1)];
}
return [link.replace(/\\\|/g, "|"), undefined];
}

View file

@ -0,0 +1,405 @@
// import { DateTime, Duration } from "luxon";
import { Link } from "./link";
// import { renderMinimalDate, renderMinimalDuration } from "utils/normalizers";
// Re-exports of useful generic types.
export { Link };
/** Shorthand for a mapping from keys to values. */
export type DataObject = Record<string, any>;
/** The literal types supported by the query engine. */
export type LiteralType =
| "boolean"
| "number"
| "string"
// | "date"
// | "duration"
| "link"
| "array"
| "object"
| "function"
| "null";
/** The raw values that a literal can take on. */
export type Literal =
| boolean
| number
| string
// | DateTime
// | Duration
| Link
| Array<Literal>
| DataObject
| Function
| null;
/** Maps the string type to it's external, API-facing representation. */
export type LiteralRepr<T extends LiteralType> = T extends "boolean"
? boolean
: T extends "number"
? number
: T extends "string"
? string
// : T extends "duration"
// ? Duration
// : T extends "date"
// ? DateTime
: T extends "null"
? null
: T extends "link"
? Link
: T extends "array"
? Array<Literal>
: T extends "object"
? DataObject
: T extends "function"
? Function
: any;
/** A wrapped literal value which can be switched on. */
export type WrappedLiteral =
| LiteralWrapper<"string">
| LiteralWrapper<"number">
| LiteralWrapper<"boolean">
// | LiteralWrapper<"date">
// | LiteralWrapper<"duration">
| LiteralWrapper<"link">
| LiteralWrapper<"array">
| LiteralWrapper<"object">
| LiteralWrapper<"function">
| LiteralWrapper<"null">;
/** Combines a textual type and value; primarily useful for switching on. */
export interface LiteralWrapper<T extends LiteralType> {
type: T;
value: LiteralRepr<T>;
}
/** Utility library for handling literal values. */
export namespace Literals {
/** Settings used when formatting literal values to text. */
export interface ToStringSettings {
/** What a null will render as. */
nullRepresentation: string;
/** Date format. */
dateFormat: string;
/** Date-time format. */
dateTimeFormat: string;
}
/** Sane, English-based defaults for date formats. */
export const DEFAULT_TO_STRING: ToStringSettings = {
nullRepresentation: "-",
dateFormat: "MMMM dd, yyyy",
dateTimeFormat: "h:mm a - MMMM dd, yyyy",
};
/** Convert an arbitrary value into a reasonable, Markdown-friendly string if possible. */
export function toString(
field: any,
setting: ToStringSettings = DEFAULT_TO_STRING,
recursive: boolean = false
): string {
let wrapped = wrapValue(field);
if (!wrapped) return setting.nullRepresentation;
switch (wrapped.type) {
case "null":
return setting.nullRepresentation;
case "string":
return wrapped.value;
case "number":
case "boolean":
return "" + wrapped.value;
case "link":
return wrapped.value.markdown();
case "function":
return "<function>";
case "array":
let result = "";
if (recursive) result += "[";
result += wrapped.value.map((f) => toString(f, setting, true)).join(", ");
if (recursive) result += "]";
return result;
case "object":
return (
"{ " +
Object.entries(wrapped.value)
.map((e) => e[0] + ": " + toString(e[1], setting, true))
.join(", ") +
" }"
);
// case "date":
// return renderMinimalDate(wrapped.value, setting.dateFormat, setting.dateTimeFormat);
// case "duration":
// return renderMinimalDuration(wrapped.value);
}
}
/** Wrap a literal value so you can switch on it easily. */
export function wrapValue(val: Literal): WrappedLiteral | undefined {
if (isNull(val)) return { type: "null", value: val };
else if (isNumber(val)) return { type: "number", value: val };
else if (isString(val)) return { type: "string", value: val };
else if (isBoolean(val)) return { type: "boolean", value: val };
// else if (isDuration(val)) return { type: "duration", value: val };
// else if (isDate(val)) return { type: "date", value: val };
else if (isArray(val)) return { type: "array", value: val };
else if (isLink(val)) return { type: "link", value: val };
else if (isFunction(val)) return { type: "function", value: val };
else if (isObject(val)) return { type: "object", value: val };
else return undefined;
}
/** Recursively map complex objects at the leaves. */
export function mapLeaves(val: Literal, func: (t: Literal) => Literal): Literal {
if (isObject(val)) {
let result: DataObject = {};
for (let [key, value] of Object.entries(val)) result[key] = mapLeaves(value, func);
return result;
} else if (isArray(val)) {
let result: Literal[] = [];
for (let value of val) result.push(mapLeaves(value, func));
return result;
} else {
return func(val);
}
}
/** Compare two arbitrary JavaScript values. Produces a total ordering over ANY possible datacore value. */
export function compare(
val1: Literal | undefined,
val2: Literal | undefined,
linkNormalizer?: (link: string) => string
): number {
// Handle undefined/nulls first.
if (val1 === undefined) val1 = null;
if (val2 === undefined) val2 = null;
if (val1 === null && val2 === null) return 0;
else if (val1 === null) return -1;
else if (val2 === null) return 1;
// A non-null value now which we can wrap & compare on.
let wrap1 = wrapValue(val1);
let wrap2 = wrapValue(val2);
if (wrap1 === undefined && wrap2 === undefined) return 0;
else if (wrap1 === undefined) return -1;
else if (wrap2 === undefined) return 1;
// Short-circuit on different types or on reference equality.
if (wrap1.type != wrap2.type) return wrap1.type.localeCompare(wrap2.type);
if (wrap1.value === wrap2.value) return 0;
switch (wrap1.type) {
case "string":
return wrap1.value.localeCompare(wrap2.value as string);
case "number":
if (wrap1.value < (wrap2.value as number)) return -1;
else if (wrap1.value == (wrap2.value as number)) return 0;
return 1;
case "null":
return 0;
case "boolean":
if (wrap1.value == wrap2.value) return 0;
else return wrap1.value ? 1 : -1;
case "link":
let link1 = wrap1.value;
let link2 = wrap2.value as Link;
let normalize = linkNormalizer ?? ((x: string) => x);
// We can't compare by file name or display, since that would break link equality. Compare by path.
let pathCompare = normalize(link1.path).localeCompare(normalize(link2.path));
if (pathCompare != 0) return pathCompare;
// Then compare by type.
let typeCompare = link1.type.localeCompare(link2.type);
if (typeCompare != 0) return typeCompare;
// Then compare by subpath existence.
if (link1.subpath && !link2.subpath) return 1;
if (!link1.subpath && link2.subpath) return -1;
if (!link1.subpath && !link2.subpath) return 0;
// Since both have a subpath, compare by subpath.
return (link1.subpath ?? "").localeCompare(link2.subpath ?? "");
// case "date":
// return wrap1.value < (wrap2.value as DateTime)
// ? -1
// : wrap1.value.equals(wrap2.value as DateTime)
// ? 0
// : 1;
// case "duration":
// return wrap1.value < (wrap2.value as Duration)
// ? -1
// : wrap1.value.equals(wrap2.value as Duration)
// ? 0
// : 1;
case "array":
let f1 = wrap1.value;
let f2 = wrap2.value as any[];
for (let index = 0; index < Math.min(f1.length, f2.length); index++) {
let comp = compare(f1[index], f2[index]);
if (comp != 0) return comp;
}
return f1.length - f2.length;
case "object":
let o1 = wrap1.value;
let o2 = wrap2.value as Record<string, any>;
let k1 = Array.from(Object.keys(o1));
let k2 = Array.from(Object.keys(o2));
k1.sort();
k2.sort();
let keyCompare = compare(k1, k2);
if (keyCompare != 0) return keyCompare;
for (let key of k1) {
let comp = compare(o1[key], o2[key]);
if (comp != 0) return comp;
}
return 0;
case "function":
return 0;
}
}
/** Find the corresponding datacore type for an arbitrary value. */
export function typeOf(val: any): LiteralType | undefined {
return wrapValue(val)?.type;
}
/** Determine if the given value is "truthy" (i.e., is non-null and has data in it). */
export function isTruthy(field: Literal): boolean {
let wrapped = wrapValue(field);
if (!wrapped) return false;
switch (wrapped.type) {
case "number":
return wrapped.value != 0;
case "string":
return wrapped.value.length > 0;
case "boolean":
return wrapped.value;
case "link":
return !!wrapped.value.path;
// case "date":
// return wrapped.value.toMillis() != 0;
// case "duration":
// return wrapped.value.as("seconds") != 0;
case "object":
return Object.keys(wrapped.value).length > 0;
case "array":
return wrapped.value.length > 0;
case "null":
return false;
case "function":
return true;
}
}
/** Deep copy a field. */
export function deepCopy<T extends Literal>(field: T): T {
if (field === null || field === undefined) return field;
if (Literals.isArray(field)) {
return ([] as Literal[]).concat(field.map((v) => deepCopy(v))) as T;
} else if (Literals.isObject(field)) {
let result: Record<string, Literal> = {};
for (let [key, value] of Object.entries(field)) result[key] = deepCopy(value);
return result as T;
} else {
return field;
}
}
/** Determine if the value is a string. */
export function isString(val: any): val is string {
return typeof val == "string";
}
/** Determine if the value is a number. */
export function isNumber(val: any): val is number {
return typeof val == "number";
}
// /** Determine if the value is a date. */
// export function isDate(val: any): val is DateTime {
// return val instanceof DateTime;
// }
// /** Determine if the value is a duration. */
// export function isDuration(val: any): val is Duration {
// return val instanceof Duration;
// }
/** Determine if the value is null or undefined. */
export function isNull(val: any): val is null | undefined {
return val === null || val === undefined;
}
/** Determine if the value is an array. */
export function isArray(val: any): val is any[] {
return Array.isArray(val);
}
/** Determine if the value is a boolean. */
export function isBoolean(val: any): val is boolean {
return typeof val === "boolean";
}
/** Determine if the value is a link. */
export function isLink(val: any): val is Link {
return val instanceof Link;
}
/** Checks if the given value is an object (and not any other datacore-recognized object-like type). */
export function isObject(val: any): val is Record<string, any> {
return (
val !== undefined &&
typeof val == "object" &&
!isArray(val) &&
// !isDuration(val) &&
// !isDate(val) &&
!isLink(val) &&
!isNull(val)
);
}
/** Determines if the given value is a javascript function. */
export function isFunction(val: any): val is Function {
return typeof val == "function";
}
}
/** A grouping on a type which supports recursively-nested groups. */
export type GroupElement<T> = { key: Literal; rows: Grouping<T> };
export type Grouping<T> = T[] | GroupElement<T>[];
export namespace Groupings {
/** Determines if the given group entry is a standalone value, or a grouping of sub-entries. */
export function isElementGroup<T>(entry: T | GroupElement<T>): entry is GroupElement<T> {
return Literals.isObject(entry) && Object.keys(entry).length == 2 && "key" in entry && "rows" in entry;
}
/** Determines if the given array is a grouping array. */
export function isGrouping<T>(entry: Grouping<T>): entry is GroupElement<T>[] {
for (let element of entry) if (!isElementGroup(element)) return false;
return true;
}
/** Count the total number of elements in a recursive grouping. */
export function count<T>(elements: Grouping<T>): number {
if (isGrouping(elements)) {
let result = 0;
for (let subgroup of elements) result += count(subgroup.rows);
return result;
} else {
return elements.length;
}
}
}

View file

@ -0,0 +1,227 @@
import { Link } from "index/expression/link";
import { getFileTitle } from "index/utils/normalizers";
import { CachedMetadata, SectionCache } from "obsidian";
import BTree from "sorted-btree";
import {
JsonMarkdownBlock,
JsonMarkdownPage,
JsonMarkdownSection,
JsonTheoremCalloutBlock,
JsonEquationBlock,
} from "index/typings/json";
import { MinimalTheoremCalloutSettings } from "settings/settings";
import { parseMarkdownComment, parseYamlLike, readTheoremCalloutSettings, trimMathText } from "utils/parse";
import { parseLatexComment } from "utils/parse";
/**
* Given the raw source and Obsidian metadata for a given markdown file,
* return full markdown file metadata.
*/
export function markdownImport(
path: string,
markdown: string,
metadata: CachedMetadata,
excludeExample: boolean
): JsonMarkdownPage {
// Total length of the file.
const lines = markdown.split("\n");
const empty = !lines.some((line) => line.trim() !== "");
//////////////
// Sections //
//////////////
const metaheadings = metadata.headings ?? [];
metaheadings.sort((a, b) => a.position.start.line - b.position.start.line);
const sections = new BTree<number, JsonMarkdownSection>(undefined, (a, b) => a - b);
for (let index = 0; index < metaheadings.length; index++) {
const section = metaheadings[index];
const start = section.position.start.line;
const end =
index == metaheadings.length - 1 ? lines.length - 1 : metaheadings[index + 1].position.start.line - 1;
sections.set(start, {
$ordinal: index + 1,
$title: section.heading,
$level: section.level,
$position: { start, end },
$blocks: [],
$links: [],
});
}
// Add an implicit section for the "heading" section of the page if there is not an immediate header but there is
// some content in the file. If there are other sections, then go up to that, otherwise, go for the entire file.
const firstSection: [number, JsonMarkdownSection] | undefined = sections.getPairOrNextHigher(0);
if ((!firstSection && !empty) || (firstSection && !emptylines(lines, 0, firstSection[1].$position.start))) {
const end = firstSection ? firstSection[1].$position.start - 1 : lines.length;
sections.set(0, {
$ordinal: 0,
$title: getFileTitle(path),
$level: 1,
$position: { start: 0, end },
$blocks: [],
$links: [],
});
}
////////////
// Blocks //
////////////
// All blocks; we will assign tags and other metadata to blocks as we encounter them. At the end, only blocks that
// have actual metadata will be stored to save on memory pressure.
const blocks = new BTree<number, JsonMarkdownBlock>(undefined, (a, b) => a - b);
let blockOrdinal = 1;
for (const block of metadata.sections || []) {
// Skip headings blocks, we handle them specially as sections.
if (block.type === "heading") continue;
const start = block.position.start.line;
const end = block.position.end.line;
let theoremCalloutSettings: MinimalTheoremCalloutSettings | null = null;
let v1 = false;
if (block.type === "callout") {
const settings = readTheoremCalloutSettings(lines[start], excludeExample);
theoremCalloutSettings = settings ?? null;
v1 = !!(settings?.legacy);
}
if (block.type === "math") {
// Read the LaTeX source
const mathText = trimMathText(getBlockText(markdown, block));
// If manually tagged (`\tag{...}`), extract the tag
const tagMatch = mathText.match(/\\tag\{(.*)\}/);
// Parse additional metadata from LaTeX comments
const metadata: Record<string, string | undefined> = {};
for (const line of mathText.split('\n')) {
const { comment } = parseLatexComment(line);
if (!comment) continue;
Object.assign(metadata, parseYamlLike(comment));
}
blocks.set(start, {
$ordinal: blockOrdinal++,
$position: { start, end },
$pos: block.position,
$links: [],
$blockId: block.id,
$manualTag: tagMatch?.[1] ?? null,
$mathText: mathText,
$type: "equation",
$label: metadata.label,
$display: metadata.display,
} as JsonEquationBlock);
} else if (theoremCalloutSettings) {
// Parse additional metadata from Markdown comments
const contentText = lines.slice(start + 1, end + 1).join('\n');
const commentLines = parseMarkdownComment(contentText);
const metadata: Record<string, string | undefined> = {};
for (let line of commentLines) {
if (line.startsWith('>')) line = line.slice(1).trim();
if (!line) continue;
if (line === 'main') metadata.main = 'true'; // %% main %% is the same as %% main: true %%
else Object.assign(metadata, parseYamlLike(line));
}
blocks.set(start, {
$ordinal: blockOrdinal++,
$position: { start, end },
$pos: block.position,
$links: [],
$blockId: block.id,
$settings: theoremCalloutSettings,
$type: "theorem",
$label: metadata.label,
$display: metadata.display,
$main: metadata.main === 'true',
$v1: v1,
} as JsonTheoremCalloutBlock);
} else {
blocks.set(start, {
$ordinal: blockOrdinal++,
$position: { start, end },
$pos: block.position,
$links: [],
$blockId: block.id,
$type: block.type,
});
}
}
// Add blocks to sections.
for (const block of blocks.values() as Iterable<JsonMarkdownBlock>) {
const section = sections.getPairOrNextLower(block.$position.start);
if (section && section[1].$position.end >= block.$position.end) {
section[1].$blocks.push(block);
}
}
///////////
// Links //
///////////
const links: Link[] = [];
for (let linkdef of metadata.links ?? []) {
const link = Link.infer(linkdef.link);
const line = linkdef.position.start.line;
addLink(links, link);
const section = sections.getPairOrNextLower(line);
if (section && section[1].$position.end >= line) addLink(section[1].$links, link);
const block = blocks.getPairOrNextLower(line);
if (block && block[1].$position.end >= line) addLink(block[1].$links, link);
const listItem = blocks.getPairOrNextHigher(line);
if (listItem && listItem[1].$position.end >= line) addLink(listItem[1].$links, link);
}
///////////////////////
// Frontmatter Links //
///////////////////////
// Frontmatter links are only assigned to the page.
for (const linkdef of metadata.frontmatterLinks ?? []) {
const link = Link.infer(linkdef.link, false, linkdef.displayText);
addLink(links, link);
}
return {
$path: path,
$links: links,
$sections: sections.valuesArray(),
$extension: "md",
$position: { start: 0, end: lines.length },
};
}
/** Check if the given line range is all empty. Start is inclusive, end exclusive. */
function emptylines(lines: string[], start: number, end: number): boolean {
for (let index = start; index < end; index++) {
if (lines[index].trim() !== "") return false;
}
return true;
}
/**
* Mutably add the given link to the list only if it is not already present.
* This is O(n) but should be fine for most files; we could eliminate the O(n) by instead
* using intermediate sets but not worth the complexity.
*/
function addLink(target: Link[], incoming: Link) {
if (target.find((v) => v.equals(incoming))) return;
target.push(incoming);
}
function getBlockText(data: string, block: SectionCache) {
return data.slice(block.position.start.offset, block.position.end.offset);
}

429
src/index/manager.ts Normal file
View file

@ -0,0 +1,429 @@
import { App, Component, EventRef, Events, MetadataCache, TAbstractFile, TFile, Vault } from "obsidian";
import { Deferred, deferred } from "./utils/deferred";
import { ImporterSettings } from "../settings/settings";
import { MathImporter } from "./web-worker/importer";
import { MathIndex } from "./math-index";
import { ImportResult } from "./web-worker/message";
import { MarkdownPage } from "./typings/markdown";
import LatexReferencer from "../main";
import { iterDescendantFiles } from "utils/obsidian";
import * as MathLinks from "obsidian-mathlinks";
export class MathIndexManager extends Component {
app: App;
/** Access to the obsidian vault. */
vault: Vault;
/** Provides access to per-(markdown)-file metadata. */
metadataCache: MetadataCache;
/** Datacore events, mainly used to update downstream views. This object is shadowed by the Datacore object itself. */
events: Events;
/** In-memory index over all stored metadata. */
index: MathIndex;
/** Asynchronous multi-threaded file importer with throttling. */
importer: MathImporter;
// /** Local-storage backed cache of metadata objects. */
// persister: LocalStorageCache;
/** Only set when the index is in the midst of initialization; tracks current progress. */
initializer?: MathIndexInitializer;
/** If true, the index is fully hydrated and all files have been indexed. */
initialized: boolean;
constructor(
public plugin: LatexReferencer,
// public version: string,
public settings: ImporterSettings
) {
super();
const { app } = plugin;
this.app = app;
this.vault = app.vault;
this.metadataCache = app.metadataCache;
// this.persister = new LocalStorageCache("primary", version);
this.events = new Events();
this.index = new MathIndex(plugin, app.vault, app.metadataCache);
this.initialized = false;
this.addChild(
(this.importer = new MathImporter(this.plugin, app.vault, app.metadataCache, () => {
return {
workers: settings.importerNumThreads,
utilization: Math.max(0.1, Math.min(1.0, settings.importerUtilization)),
};
}))
);
}
/** Obtain the current index revision, for determining if anything has changed. */
get revision() {
return this.index.revision;
}
/** Initialize datacore by scanning persisted caches and all available files, and queueing parses as needed. */
initialize() {
// The metadata cache is updated on initial file index and file loads.
this.registerEvent(this.metadataCache.on("resolve", (file) => this.updateLinked(file)));
// Renames do not set off the metadata cache; catch these explicitly.
this.registerEvent(this.vault.on("rename", this.rename, this));
// File creation does cause a metadata change, but deletes do not. Clear the caches for this.
this.registerEvent(
this.vault.on("delete", async (file) => {
if (file instanceof TFile) {
await this.updateLinkedOnDeltion(file);
}
if (file.path in this.plugin.settings) {
delete this.plugin.settings[file.path];
}
this.plugin.excludedFiles.remove(file.path);
})
);
this.registerEvent(
this.on("local-settings-updated", async (file) => {
iterDescendantFiles(file, (descendantFile) => {
if (descendantFile.extension === "md") {
this.index.updateNames(descendantFile);
MathLinks.update(this.app, descendantFile);
};
});
})
);
this.registerEvent(
this.on("global-settings-updated", () => {
// re-index the whole vault
const init = new MathIndexInitializer(this);
init.finished().then(() => {
this.removeChild(init);
this.index.touch();
this.trigger("update", this.revision);
});
this.addChild(init);
})
);
// Asynchronously initialize actual content in the background using a lifecycle-respecting object.
const init = (this.initializer = new MathIndexInitializer(this));
init.finished().then((stats: InitializationStats) => {
this.initialized = true;
this.initializer = undefined;
this.removeChild(init);
const durationSecs = (stats.durationMs / 1000.0).toFixed(3);
console.log(
`${this.plugin.manifest.name}: Imported all theorems and equations in the vault in ${durationSecs}s ` +
`(${stats.imported} notes imported, ${stats.skipped} notes skipped).`
);
this.index.touch();
this.trigger("update", this.revision);
this.trigger("index-initialized");
MathLinks.update(this.app);
});
this.addChild(init);
}
private async rename(file: TAbstractFile, oldPath: string) {
if (!(file instanceof TFile)) return;
this.plugin.settings[file.path] = structuredClone(this.plugin.settings[oldPath]);
delete this.plugin.settings[oldPath];
this.plugin.excludedFiles.remove(oldPath);
this.plugin.excludedFiles.push(file.path);
// Delete the file at the old path, then request a reload at the new path.
// This is less optimal than what can probably be done, but paths are used in a bunch of places
// (for sections, tasks, etc to refer to their parent file) and it requires some finesse to fix.
this.index.delete(oldPath);
await this.reload(file);
this.index.updateNames(file);
MathLinks.update(this.app);
}
/** Queue a file for reloading; this is done asynchronously in the background and may take a few seconds. */
public async reload(file: TFile): Promise<MarkdownPage> {
const result = await this.importer.import<ImportResult>(file);
if (result.type === "error") {
throw new Error(`Failed to import file '${file.name}: ${result.$error}`);
} else if (result.type === "markdown") {
const parsed = MarkdownPage.from(result.result, (link) => {
const rpath = this.metadataCache.getFirstLinkpathDest(link.path, result.result.$path!);
if (rpath) return link.withPath(rpath.path);
else return link;
});
this.index.store(parsed, (object, store) => {
store(object.$sections, (section, store) => {
store(section.$blocks);
});
});
this.trigger("update", this.revision);
this.trigger('index-updated', file);
return parsed;
}
throw new Error("Encountered unrecognized import result type: " + (result as any).type);
}
/** Given an array of TFiles, this function does two things:
* 1. It reloads (re-imports) each file in the array.
* 2. It re-computes the theorem/equation numbers for all the files containing blocks
* that each file in the array previously linked to.
*   EDIT: Wow, I forgot to update the files that each file in the array newly links to.
*
* This should be named like updateOldAndNewLinkDestinations.
*/
public async updateLinked(file: TFile) {
// Since only linked/referenced equations are numbered, we need to recompute
// the equation numbers for all the files such that
// 1. contained blocks that this file previously linked to, or
// 2. is now containing blocks that this file newly links to.
const toBeUpdated = new Set<TFile>([file]); // Use Set, not Array, to avoid updating the same file multiple times.
// get the old outgoing block links (each of which can be potentially a link to some equation)
// before reloading the given file
const oldPage = this.index.load(file.path);
if (MarkdownPage.isMarkdownPage(oldPage)) {
for (const link of oldPage.$links) {
if (link.type === "block") {
const linkedFile = this.vault.getAbstractFileByPath(link.path);
if (linkedFile instanceof TFile) {
toBeUpdated.add(linkedFile);
}
}
}
}
// re-import the file changed
const newPage = await this.reload(file);
// get the new outgoing block links (each of which can be potentially a link to some equation)
for (const link of newPage.$links) {
if (link.type === "block") {
const linkedFile = this.vault.getAbstractFileByPath(link.path);
if (linkedFile instanceof TFile) {
toBeUpdated.add(linkedFile);
}
}
}
// recompute theorem/equation numbers for the previously or currently linked files
toBeUpdated.forEach((fileToBeUpdated) => {
this.index.updateNames(fileToBeUpdated);
MathLinks.update(this.app, fileToBeUpdated);
});
this.trigger("update", this.revision);
}
public async updateLinkedOnDeltion(file: TFile) {
// Since only linked/referenced equations are numbered, we need to recompute
// the equation numbers for all the files that contained blocks that this file previously linked to
const toBeUpdated = new Set<TFile>(); // Use Set, not Array, to avoid updating the same file multiple times.
// get the old outgoing block links (each of which can be potentially a link to some equation)
// before deleting the given file
const oldPage = this.index.load(file.path);
if (MarkdownPage.isMarkdownPage(oldPage)) {
for (const link of oldPage.$links) {
if (link.type === "block") {
const linkedFile = this.vault.getAbstractFileByPath(link.path);
if (linkedFile instanceof TFile) {
toBeUpdated.add(linkedFile);
}
}
}
}
// execute deletion
this.index.delete(file.path);
// recompute theorem/equation numbers for the previously linked files
toBeUpdated.forEach((fileToBeUpdated) => {
this.index.updateNames(fileToBeUpdated);
});
this.trigger("update", this.revision);
MathLinks.update(this.app);
}
// Event propogation.
/** From Datacore: Called whenever the index updates to a new revision. This is the broadest possible datacore event. */
public on(evt: "update", callback: (revision: number) => any, context?: any): EventRef;
/** This plugin's custom events */
// triggered when the index is updated, which means the datacore-level metadata or latex-referencer-level metadata (such as $printName) are updated
public on(evt: "index-updated", callback: (file: TFile) => any): EventRef;
public on(evt: "local-settings-updated", callback: (file: TAbstractFile) => any): EventRef;
public on(evt: "global-settings-updated", callback: () => any): EventRef;
public on(evt: "index-initialized", callback: () => any): EventRef;
on(evt: string, callback: (...data: any) => any, context?: any): EventRef {
return this.events.on(evt, callback, context);
}
/** Unsubscribe from an event using the event and original callback. */
off(evt: string, callback: (...data: any) => any) {
this.events.off(evt, callback);
}
/** Unsubscribe from an event using the event reference. */
offref(ref: EventRef) {
this.events.offref(ref);
}
/** From Datacore: Trigger an update event. */
public trigger(evt: "update", revision: number): void;
/** This plugin's custom events */
public trigger(evt: "index-updated", file: TFile): void;
public trigger(evt: "local-settings-updated", file: TAbstractFile): void;
public trigger(evt: "global-settings-updated"): void;
public trigger(evt: "index-initialized"): void;
/** Trigger an event. */
trigger(evt: string, ...args: any[]): void {
this.events.trigger(evt, ...args);
}
}
/** Lifecycle-respecting file queue which will import files, reading them from the file cache if needed. */
export class MathIndexInitializer extends Component {
/** Number of concurrent operations the initializer will perform. */
static BATCH_SIZE: number = 8;
/** Whether the initializer should continue to run. */
active: boolean;
/** Queue of files to still import. */
queue: TFile[];
/** The files actively being imported. */
current: TFile[];
/** Deferred promise which resolves when importing is done. */
done: Deferred<InitializationStats>;
/** The time that init started in milliseconds. */
start: number;
/** Total number of files to import. */
files: number;
/** Total number of imported files so far. */
initialized: number;
/** Total number of imported files. */
imported: number;
/** Total number of skipped files. */
skipped: number;
constructor(public manager: MathIndexManager) {
super();
this.active = false;
this.queue = this.manager.vault.getMarkdownFiles();
this.files = this.queue.length;
this.start = Date.now();
this.current = [];
this.done = deferred();
this.initialized = this.imported = this.skipped = 0;
}
async onload() {
// Queue BATCH_SIZE elements from the queue to import.
this.active = true;
this.runNext();
}
/** Promise which resolves when the initialization completes. */
finished(): Promise<InitializationStats> {
return this.done;
}
/** Cancel initialization. */
onunload() {
if (this.active) {
this.active = false;
this.done.reject("Initialization was cancelled before completing.");
}
}
/** Poll for another task to execute from the queue. */
private runNext() {
// Do nothing if max number of concurrent operations already running.
if (!this.active || this.current.length >= MathIndexInitializer.BATCH_SIZE) {
return;
}
// There is space available to execute another.
const next = this.queue.pop();
if (next) {
this.current.push(next);
this.init(next)
.then((result) => this.handleResult(next, result))
.catch((result) => this.handleResult(next, result));
this.runNext();
} else if (!next && this.current.length == 0) {
this.active = false;
this.manager.vault.getMarkdownFiles().forEach((file) => this.manager.index.updateNames(file));
MathLinks.update(this.manager.app);
// All work is done, resolve.
this.done.resolve({
durationMs: Date.now() - this.start,
files: this.files,
imported: this.imported,
skipped: this.skipped,
});
}
}
/** Process the result of an initialization and queue more runs. */
private handleResult(file: TFile, result: InitializationResult) {
this.current.remove(file);
this.initialized++;
if (result.status === "skipped") this.skipped++;
else if (result.status === "imported") this.imported++;
// Queue more jobs for processing.
this.runNext();
}
/** Initialize a specific file. */
private async init(file: TFile): Promise<InitializationResult> {
try {
const metadata = this.manager.metadataCache.getFileCache(file);
if (!metadata) return { status: "skipped" };
await this.manager.reload(file);
return { status: "imported" };
} catch (ex) {
console.log(`${this.manager.plugin.manifest.name}: Failed to import file: `, ex);
return { status: "skipped" };
}
}
}
/** Statistics about a successful vault initialization. */
export interface InitializationStats {
/** How long initializaton took in miliseconds. */
durationMs: number;
/** Total number of files that were imported */
files: number;
/** The number of files that were loaded and imported via background workers. */
imported: number;
/** The number of files that were skipped due to no longer existing or not being ready. */
skipped: number;
}
/** The result of initializing a file. */
interface InitializationResult {
status: "skipped" | "imported";
}

382
src/index/math-index.ts Normal file
View file

@ -0,0 +1,382 @@
import { MetadataCache, TFile, Vault } from 'obsidian';
import { InvertedIndex } from './storage/inverted';
import { Indexable, LINKBEARING_TYPE, Linkable } from './typings/indexable';
import { Link } from 'index/expression/literal';
import { EquationBlock, MarkdownPage, TheoremCalloutBlock } from './typings/markdown';
import LatexReferencer from 'main';
import { CONVERTER, formatTitle, formatTitleWithoutSubtitle, getEqNumberPrefix } from 'utils/format';
import { resolveSettings } from 'utils/plugin';
import { ResolvedMathSettings, TheoremRefFormat } from 'settings/settings';
export class MathIndex {
/** The current store revision. */
public revision: number;
/**
* Master collection of all object IDs. This is technically redundant with objects.keys() but this is a fast set
* compared to an iterator.
*/
private ids: Set<string>;
/** The master collection of ALL indexed objects, mapping ID -> the object. */
private objects: Map<string, Indexable>;
/** Map parent object to it's direct child objects. */
private children: Map<string, Set<string>>;
// Indices for the various accepted query types. These will probably be moved to a different type later.
/** Global map of object type -> list of all objects of that type. */
private types: InvertedIndex<string>;
// /** Tracks exact tag occurence in objects. */
// private etags: InvertedIndex<string>;
// /** Tracks tag occurence in objects. */
// private tags: InvertedIndex<string>;
/** Maps link strings to the object IDs that link to those links. */
private links: InvertedIndex<string>;
/** Tracks the existence of fields (indexed by normalized key name). */
// private fields: Map<string, FieldIndex>; // irrelevant because we are not going to search/query
/**
* Quick searches for objects in folders. This index only tracks top-level objects - it is expanded recursively to
* find child objects.
*/
// private folder: FolderIndex; // irrelevant because we are not going to search/query
public constructor(public plugin: LatexReferencer, public vault: Vault, public metadataCache: MetadataCache,
// public settings: Settings // irrelevant because we are not going to search/query
) {
this.revision = 0;
this.ids = new Set();
this.objects = new Map();
this.children = new Map();
this.types = new InvertedIndex();
// this.etags = new InvertedIndex();
// this.tags = new InvertedIndex();
this.links = new InvertedIndex();
// this.fields = new Map();
// this.folder = new FolderIndex(vault);
}
/** Update the revision of the datastore due to an external update. */
public touch() {
this.revision += 1;
}
/** Load an object by ID. */
public load(id: string): Indexable | undefined;
/** Load a list of objects by ID. */
public load(ids: string[]): Indexable[];
/** Load an object by ID or list of IDs. */
load(id: string | string[]): Indexable | Indexable[] | undefined {
if (Array.isArray(id)) {
return id.map((a) => this.load(a)).filter((obj): obj is Indexable => obj !== undefined);
}
return this.objects.get(id);
}
/**
* Store the given object, making it immediately queryable. Storing an object
* takes ownership over it, and index-specific variables (prefixed via '$') may be
* added to the object.
*/
public store<T extends Indexable>(object: T | T[], substorer?: Substorer<T>) {
this._recursiveStore(object, this.revision++, substorer, undefined);
}
/** Recursively store objects using a potential subindexer. */
private _recursiveStore<T extends Indexable>(
object: T | T[],
revision: number,
substorer?: Substorer<T>,
parent?: Indexable
) {
// Handle array inputs.
if (Array.isArray(object)) {
for (let element of object) {
this._recursiveStore(element, revision, substorer, parent);
}
return;
}
// Delete the previous instance of this object if present.
// TODO: Probably only actually need to delete the root objects.
this._deleteRecursive(object.$id);
// Assign the next revision to this object; indexed objects are implied to be root objects.
object.$revision = revision;
object.$parent = parent;
// Add the object to the appropriate object maps.
this.ids.add(object.$id);
this.objects.set(object.$id, object);
// Add the object to the parent children map.
if (parent) {
if (!this.children.has(parent.$id)) this.children.set(parent.$id, new Set());
this.children.get(parent.$id)!.add(object.$id);
}
this._index(object);
// Index any subordinate objects in this object.
substorer?.(object, (incoming, subindex) => this._recursiveStore(incoming, revision, subindex, object));
}
/** Delete an object by ID from the index, recursively deleting any child objects as well. */
public delete(id: string): boolean {
if (this._deleteRecursive(id)) {
this.revision++;
return true;
}
return false;
}
/** Internal method that does not bump the revision. */
private _deleteRecursive(id: string): boolean {
const object = this.objects.get(id);
if (!object) {
return false;
}
// Recursively delete all child objects.
const children = this.children.get(id);
if (children) {
for (let child of children) {
this._deleteRecursive(child);
}
this.children.delete(id);
}
// Drop this object from the appropriate maps.
this._unindex(object);
this.ids.delete(id);
this.objects.delete(id);
return true;
}
/** Add the given indexable to the appropriate indices. */
private _index(object: Indexable) {
this.types.set(object.$id, object.$types);
// // Exact and derived tags.
// if (object.$types.contains(TAGGABLE_TYPE) && iterableExists(object, "$tags")) {
// const tags = object.$tags as Set<string>;
// this.etags.set(object.$id, tags);
// this.tags.set(object.$id, extractSubtags(tags));
// }
// Exact and derived links.
if (object.$types.contains(LINKBEARING_TYPE) && iterableExists(object, "$links")) {
this.links.set(
object.$id,
(object.$links as Link[]).map((link) => link.obsidianLink())
);
}
// // All fields on an object.
// if (object.$types.contains(FIELDBEARING_TYPE) && "fields" in object) {
// for (const field of object.fields as Iterable<Field>) {
// // Skip any index fields.
// if (INDEX_FIELDS.has(field.key)) continue;
// const norm = field.key.toLowerCase();
// if (!this.fields.has(norm)) this.fields.set(norm, new FieldIndex(false));
// this.fields.get(norm)!.add(object.$id, field.value);
// }
// }
}
/** Remove the given indexable from all indices. */
private _unindex(object: Indexable) {
this.types.delete(object.$id, object.$types);
// if (object.$types.contains(TAGGABLE_TYPE) && iterableExists(object, "$tags")) {
// const tags = object.$tags as Set<string>;
// this.etags.delete(object.$id, tags);
// this.tags.delete(object.$id, extractSubtags(tags));
// }
if (object.$types.contains(LINKBEARING_TYPE) && iterableExists(object, "$links")) {
// Assume links are normalized when deleting them. Could be broken but I hope not. We can always use a 2-way index to
// fix this if we encounter non-normalized links.
this.links.delete(
object.$id,
(object.$links as Link[]).map((link) => link.obsidianLink())
);
}
// if (object.$types.contains(FIELDBEARING_TYPE) && "fields" in object) {
// for (const field of object.fields as Iterable<Field>) {
// // Skip any index fields.
// if (INDEX_FIELDS.has(field.key)) continue;
// const norm = field.key.toLowerCase();
// if (!this.fields.has(norm)) continue;
// this.fields.get(norm)!.delete(object.$id, field.value);
// }
// }
}
/** Completely clear the datastore of all values. */
public clear() {
this.ids.clear();
this.objects.clear();
this.children.clear();
this.types.clear();
// this.tags.clear();
// this.etags.clear();
this.links.clear();
// this.fields.clear();
this.revision++;
}
/** Get all the backlinks to the given linkable. */
public getBacklinks(object: Linkable) {
const normalizedLink = object.$link.obsidianLink();
return this.links.get(normalizedLink);
}
/** Check if the given linkable object has any backlinks. */
public isLinked(object: Linkable): boolean {
return this.getBacklinks(object).size > 0;
}
/**
* Update $printName and $refName of theorems and equations.
* Additionally, set $main of a theorem callout to true if it is the only one in the file, if configured as such.
* Fiinally, set $refName of the page to the refName of the main theorem, if it exists.
*
* Warning: This function doesn't trigger MathLinks.update(), so you have to call it by yourself!
*/
public updateNames(file: TFile) {
const settings = resolveSettings(undefined, this.plugin, file);
let blockOrdinal = 1;
let block: Indexable | undefined;
let theorems: TheoremCalloutBlock[] = []
let autoNumberedTheoremCount = 0;
let mainTheorem: TheoremCalloutBlock | null = null;
const equationNumberInit = +(settings.eqNumberInit);
let equationCount = 0;
const eqPrefix = getEqNumberPrefix(this.plugin.app, file, settings);
const eqSuffix = settings.eqNumberSuffix;
while (block = this.load(`${file.path}/block${blockOrdinal++}`)) {
if (TheoremCalloutBlock.isTheoremCalloutBlock(block)) {
theorems.push(block);
if (block.$main) mainTheorem = block;
// Theorem numbers start at 1, and are incremented by 1
// for each theorem callout.
// They may be additionally formatted according to the settings.
const resolvedSettings = Object.assign({}, settings, block.$settings);
if (block.$settings.number == 'auto') {
block.$index = autoNumberedTheoremCount;
(resolvedSettings as ResolvedMathSettings)._index = autoNumberedTheoremCount++;
}
// const printName = formatTitle(this.plugin, file, resolvedSettings);
const mainTitle = formatTitleWithoutSubtitle(this.plugin, file, resolvedSettings);
const refName = this.formatMathLink(file, resolvedSettings, "refFormat");
block.$theoremMainTitle = mainTitle;
block.$refName = refName;
block.$titleSuffix = settings.titleSuffix;
} else if (EquationBlock.isEquationBlock(block)) {
// Equation numbers start at settings.eqNumberInit, and are incremented by 1
// for eqch equation block that doesn't have a manual tag (i.e. \tag{...}) but
// has any backlinks.
// If an equation block has a manual tag, it is used as printNames & refNames.
let printName: string | null = null;
let refName: string | null = null;
if (block.$manualTag) {
printName = `(${block.$manualTag})`;
} else if (!settings.numberOnlyReferencedEquations || block.$link && this.isLinked(block as Linkable)) {
block.$index = equationCount;
printName = "(" + eqPrefix + CONVERTER[settings.eqNumberStyle](equationNumberInit + equationCount) + eqSuffix + ")";
equationCount++;
}
if (printName !== null) refName = settings.eqRefPrefix + printName + settings.eqRefSuffix;
block.$printName = printName;
block.$refName = refName;
}
}
if (this.plugin.extraSettings.setOnlyTheoremAsMain && theorems.length == 1) {
theorems[0].$main = true;
mainTheorem = theorems[0];
}
const page = this.load(file.path);
if (MarkdownPage.isMarkdownPage(page)) {
if (mainTheorem) {
const resolvedSettings = Object.assign({}, settings, mainTheorem.$settings);
(resolvedSettings as ResolvedMathSettings)._index = mainTheorem.$index;
if (!resolvedSettings.ignoreMainTheoremCalloutWithoutTitle || mainTheorem.$theoremSubtitle)
page.$refName = this.formatMathLink(file, resolvedSettings, "noteMathLinkFormat");
}
}
this.plugin.indexManager.trigger("index-updated", file);
}
formatMathLink(file: TFile, resolvedSettings: ResolvedMathSettings, key: "refFormat" | "noteMathLinkFormat"): string {
const refFormat: TheoremRefFormat = resolvedSettings[key];
if (refFormat == "[type] [number] ([title])") {
return formatTitle(this.plugin, file, resolvedSettings, true);
}
if (refFormat == "[type] [number]") {
return formatTitleWithoutSubtitle(this.plugin, file, resolvedSettings);
}
if (refFormat == "[title] if title exists, [type] [number] otherwise") {
return resolvedSettings.title ? resolvedSettings.title : formatTitleWithoutSubtitle(this.plugin, file, resolvedSettings);
}
// if (refFormat == "[title] ([type] [number]) if title exists, [type] [number] otherwise")
const typePlusNumber = formatTitleWithoutSubtitle(this.plugin, file, resolvedSettings);
return resolvedSettings.title ? `${resolvedSettings.title} (${typePlusNumber})` : typePlusNumber;
}
getByType(type: string) {
return this.types.get(type);
}
getMarkdownPage(path: string): MarkdownPage | null {
const page = this.load(path);
return MarkdownPage.isMarkdownPage(page) ? page : null;
}
getTheoremCalloutBlock(id: string): TheoremCalloutBlock | null {
const block = this.load(id);
return TheoremCalloutBlock.isTheoremCalloutBlock(block) ? block : null;
}
getEquationBlock(id: string): EquationBlock | null {
const block = this.load(id);
return EquationBlock.isEquationBlock(block) ? block : null;
}
}
/** A general function for storing sub-objects in a given object. */
export type Substorer<T extends Indexable> = (
object: T,
add: <U extends Indexable>(object: U | U[], subindex?: Substorer<U>) => void
) => void;
/** Type guard which checks if object[key] exists and is an iterable. */
function iterableExists<T extends Record<string, any>, K extends string>(
object: T,
key: K
): object is T & Record<K, Iterable<any>> {
return key in object && object[key] !== undefined && Symbol.iterator in object[key];
}

View file

@ -0,0 +1,41 @@
/** Tracks an inverted index of value -> set<ids>. */
export class InvertedIndex<V> {
private inverted: Map<V, Set<string>>;
public constructor() {
this.inverted = new Map();
}
/** Set the key to the given values. */
public set(key: string, values: Iterable<V>) {
for (let value of values) {
if (!this.inverted.has(value)) this.inverted.set(value, new Set());
this.inverted.get(value)!.add(key);
}
}
/** Get all keys that map to the given value. */
public get(value: V): Set<string> {
return this.inverted.get(value) ?? InvertedIndex.EMPTY_SET;
}
/** Delete a key from the set of associated values. */
public delete(key: string, values: Iterable<V>) {
for (let value of values) {
const set = this.inverted.get(value);
if (set) {
set.delete(key);
}
if (set && set.size == 0) {
this.inverted.delete(value);
}
}
}
public clear() {
this.inverted.clear();
}
private static EMPTY_SET: Set<string> = new Set();
}

View file

@ -0,0 +1,64 @@
import { Link } from "../expression/link";
// import { DateTime } from "luxon";
/** The names of all index fields that are present on ALL indexed types. */
export const INDEX_FIELDS = new Set([
"$types",
// "$typename",
"$id",
"$revision"
]);
/** Any indexable field, which must have a few index-relevant properties. */
export interface Indexable {
/** The object types that this indexable is. */
$types: string[];
// /** Textual description of the object, such as `Page` or `Section`. Used in visualizations. */
// $typename: string;
/** The unique index ID for this object. */
$id: string;
/**
* The indexable object that is the parent of this object. Only set after the object is actually indexed.
*/
$parent?: Indexable;
/** If present, the revision in the index of this object. */
$revision?: number;
/** The file that this indexable was derived from, if file-backed. */
$file?: string;
}
/** Metadata for objects which support linking. */
export const LINKABLE_TYPE = "linkable";
export interface Linkable {
/** A link to this linkable object. */
$link: Link;
}
/** General metadata for any file. */
export const FILE_TYPE = "file";
export interface File extends Linkable {
/** The path this file exists at. */
$path: string;
// /** Obsidian-provided date this page was created. */
// $ctime: DateTime;
// /** Obsidian-provided date this page was modified. */
// $mtime: DateTime;
// /** Obsidian-provided size of this page in bytes. */
// $size: number;
/** The extension of the file. */
$extension: string;
}
// /** Metadata for taggable objects. */
// export const TAGGABLE_TYPE = "taggable";
// export interface Taggable {
// /** The exact tags on this object. (#a/b/c or #foo/bar). */
// $tags: string[];
// }
/** Metadata for objects which can link to other things. */
export const LINKBEARING_TYPE = "links";
export interface Linkbearing {
/** The links in this file. */
$links: Link[];
}

82
src/index/typings/json.ts Normal file
View file

@ -0,0 +1,82 @@
//! Note: These are "serialization" types for metadata, which contain
// the absolute minimum information needed to save and load data.
import { Link } from "index/expression/literal";
import { Pos } from "obsidian";
import { MinimalTheoremCalloutSettings, TheoremCalloutSettings } from "settings/settings";
/** A span of contiguous lines. */
export interface LineSpan {
/** The inclusive start line. */
start: number;
/** The inclusive end line. */
end: number;
}
/** Stores just the minimal information needed to create a markdown file; used for saving and loading these files. */
export interface JsonMarkdownPage {
/** The path this file exists at. */
$path: string;
/** The extension; for markdown files, almost always '.md'. */
$extension: string;
/** The full extent of the file (start 0, end the number of lines in the file.) */
$position: LineSpan;
/** All links in the file. */
$links: Link[];
/**
* All child markdown sections of this markdown file. The initial section before any content is special and is
* named with the title of the file.
*/
$sections: JsonMarkdownSection[];
}
export interface JsonMarkdownSection {
/** The index of this section in the file. */
$ordinal: number;
/** The title of the section; the root (implicit) section will have the title of the page. */
$title: string;
/** The indentation level of the section (1 - 6). */
$level: number;
/** The span of lines indicating the position of the section. */
$position: LineSpan;
// /** All tags on the file. */
// $tags: string[];
/** All links in the file. */
/** -> All links in the SECTION? */
$links: Link[];
/** All of the markdown blocks in this section. */
$blocks: JsonMarkdownBlock[];
}
export interface JsonMarkdownBlock {
/** The index of this block in the file. */
$ordinal: number;
/** The position/extent of the block. */
$position: LineSpan;
$pos: Pos;
/** All links in the file. */
$links: Link[];
/** If present, the distinct block ID for this block. */
$blockId?: string;
/** The type of block - paragraph, list, and so on. */
$type: string;
}
export interface JsonMathBlock extends JsonMarkdownBlock {
$type: "theorem" | "equation";
$label?: string;
$display?: string;
}
export interface JsonTheoremCalloutBlock extends JsonMathBlock {
$type: "theorem";
$settings: MinimalTheoremCalloutSettings;
$main: boolean;
$v1: boolean;
}
export interface JsonEquationBlock extends JsonMathBlock {
$type: "equation";
$manualTag: string | null;
$mathText: string;
}

View file

@ -0,0 +1,445 @@
import { Link } from "index/expression/literal";
import { getFileTitle } from "index/utils/normalizers";
import {
FILE_TYPE,
File,
Indexable,
LINKABLE_TYPE,
LINKBEARING_TYPE,
Linkable,
Linkbearing,
} from "index/typings/indexable";
import {
LineSpan,
JsonMarkdownPage,
JsonMarkdownSection,
JsonMarkdownBlock,
JsonTheoremCalloutBlock,
JsonEquationBlock,
} from "./json";
import { MinimalTheoremCalloutSettings } from "settings/settings";
import { Pos } from "obsidian";
/** A link normalizer which takes in a raw link and produces a normalized link. */
export type LinkNormalizer = (link: Link) => Link;
export const NOOP_NORMALIZER: LinkNormalizer = (x) => x;
/** A markdown file in the vault; the source of most metadata. */
export class MarkdownPage implements File, Linkbearing, Indexable {
/** All of the types that a markdown file is. */
static TYPES = [FILE_TYPE, "markdown", "page", LINKABLE_TYPE, LINKBEARING_TYPE];
// Use static types for all markdown files.
$types: string[] = MarkdownPage.TYPES;
$typename: string = "Page";
// Markdown file IDs are always just the full path.
get $id() {
return this.$path;
}
// The file of a file is... it's file.
get $file() {
return this.$path;
}
/** The path this file exists at. */
$path: string;
/** The extension; for markdown files, almost always '.md'. */
$extension: string;
/** The full extent of the file (start 0, end the number of lines in the file.) */
$position: LineSpan;
/** All links in the file. */
$links: Link[];
/**
* All child markdown sections of this markdown file. The initial section before any content is special and is
* named with the title of the file.
*/
$sections: MarkdownSection[] = [];
/**
* Maps a block ID to the corresponding markdown block in this page.
*/
$blocks: Map<string, MarkdownBlock>;
/** $refName of the main theorem callout, if any. */
$refName?: string
/** Create a markdown file from the given raw values. */
static from(raw: JsonMarkdownPage, normalizer: LinkNormalizer = NOOP_NORMALIZER): MarkdownPage {
const sections = raw.$sections.map((sect) => MarkdownSection.from(sect, raw.$path, normalizer));
const blocks = new Map<string, MarkdownBlock>();
for (const section of sections) {
for (const block of section.$blocks) {
if (block.$blockId) blocks.set(block.$blockId, block);
}
}
return new MarkdownPage({
$path: raw.$path,
$extension: raw.$extension,
$position: raw.$position,
$links: raw.$links.map(normalizer),
$sections: sections,
$blocks: blocks,
});
}
private constructor(init: Partial<MarkdownPage>) {
Object.assign(this, init);
}
/** Return the number of lines in the document. */
get $lineCount() {
return this.$position.end;
}
/** The name of the file. */
get $name() {
return getFileTitle(this.$path);
}
/** A link to this file. */
get $link() {
return Link.file(this.$path);
}
/** Convert this page into it's partial representation for saving. */
public partial(): JsonMarkdownPage {
return {
$path: this.$path,
$extension: this.$extension,
$position: this.$position,
$links: this.$links,
$sections: this.$sections.map((sect) => sect.partial()),
};
}
public getBlockByLineNumber(line: number) {
const section = this.$sections.find((section) => section.$position.start <= line && line <= section.$position.end);
const block = section?.$blocks.find((block) => block.$position.start <= line && line <= block.$position.end);
return block;
}
public getBlockByOffset(offset: number) {
for (const section of this.$sections) {
for (const block of section.$blocks) {
if (block.$pos.start.offset <= offset && offset <= block.$pos.end.offset) return block;
}
}
}
static isMarkdownPage(object: Indexable | undefined): object is MarkdownPage {
return object !== undefined && '$typename' in object && (object as any).$typename === 'Page';
}
}
export class MarkdownSection implements Indexable, Linkable, Linkbearing {
/** All of the types that a markdown section is. */
static TYPES = ["markdown", "section", LINKABLE_TYPE, LINKBEARING_TYPE];
/** Path of the file that this section is in. */
$types: string[] = MarkdownSection.TYPES;
$typename: string = "Section";
$id: string;
$file: string;
/** The index of this section in the file. */
$ordinal: number;
/** The title of the section; the root (implicit) section will have the title of the page. */
$title: string;
/** The indentation level of the section (1 - 6). */
$level: number;
/** The span of lines indicating the position of the section. */
$position: LineSpan;
/** All tags on the file. */
$tags: string[];
/** All links in the file. */
$links: Link[];
/** All of the markdown blocks in this section. */
$blocks: MarkdownBlock[];
/** Convert raw markdown section data to the appropriate class. */
static from(raw: JsonMarkdownSection, file: string, normalizer: LinkNormalizer = NOOP_NORMALIZER): MarkdownSection {
const blocks = raw.$blocks.map((block) => MarkdownBlock.from(block, file, normalizer));
return new MarkdownSection({
$file: file,
$id: MarkdownSection.readableId(file, raw.$title, raw.$ordinal),
$ordinal: raw.$ordinal,
$title: raw.$title,
$level: raw.$level,
$position: raw.$position,
$links: raw.$links.map(normalizer),
$blocks: blocks,
});
}
private constructor(init: Partial<MarkdownSection>) {
Object.assign(this, init);
}
/** Obtain the number of lines in the section. */
get $lineCount(): number {
return this.$position.end - this.$position.start;
}
/** Alias for title which allows searching over pages and sections by 'name'. */
get $name(): string {
return this.$title;
}
/** Return a link to this section. */
get $link(): Link {
return Link.header(this.$file, this.$title);
}
public partial(): JsonMarkdownSection {
return {
$ordinal: this.$ordinal,
$title: this.$title,
$level: this.$level,
$position: this.$position,
$links: this.$links,
$blocks: this.$blocks.map((block) => block.partial()),
};
}
/** Generate a readable ID for this section using the first 8 characters of the string and the ordinal. */
static readableId(file: string, title: string, ordinal: number): string {
const first8 = title.substring(0, Math.min(title.length, 8)).replace(/[^A-Za-z0-9-_]+/gi, "-");
return `${file}/section${ordinal}/${first8}`;
}
static isMarkdownSection(object: Indexable | undefined): object is MarkdownSection {
return object !== undefined && '$typename' in object && (object as any).$typename === 'Section';
}
}
/** Base class for all markdown blocks. */
export class MarkdownBlock implements Indexable, Linkbearing {
static TYPES = ["markdown", "block", LINKBEARING_TYPE];
$types: string[] = MarkdownBlock.TYPES;
$typename: string = "Block";
$id: string;
$file: string;
/** The index of this block in the file. */
$ordinal: number;
/** The position/extent of the block. */
$position: LineSpan;
$pos: Pos;
/** All links in the file. */
$links: Link[];
/** If present, the distinct block ID for this block. */
$blockId?: string;
/** The type of block - paragraph, list, and so on. */
$type: string;
static from(object: JsonMarkdownBlock, file: string, normalizer: LinkNormalizer = NOOP_NORMALIZER): MarkdownBlock {
if (object.$type === "theorem") {
return TheoremCalloutBlock.from(object as JsonTheoremCalloutBlock, file, normalizer);
} else if (object.$type === "equation") {
return EquationBlock.from(object as JsonEquationBlock, file, normalizer);
}
return new MarkdownBlock({
$file: file,
$id: MarkdownBlock.readableId(file, object.$ordinal),
$ordinal: object.$ordinal,
$position: object.$position,
$pos: object.$pos,
$links: object.$links.map(normalizer),
$blockId: object.$blockId,
$type: object.$type,
});
}
protected constructor(init: Partial<MarkdownBlock>) {
Object.assign(this, init);
}
/** If this block has a block ID, the link to this block. */
get $link(): Link | undefined {
if (this.$blockId) return Link.block(this.$file, this.$blockId);
else return undefined;
}
public partial(): JsonMarkdownBlock {
return {
$ordinal: this.$ordinal,
$position: this.$position,
$pos: this.$pos,
$links: this.$links,
$blockId: this.$blockId,
$type: this.$type,
};
}
/** Generate a readable ID for this block using the ordinal of the block. */
static readableId(file: string, ordinal: number): string {
return `${file}/block${ordinal}`;
}
static isMarkdownBlock(object: Indexable | undefined): object is MarkdownSection {
return object !== undefined && object.$types.includes('block');
}
}
export abstract class MathBlock extends MarkdownBlock {
// only set after backlinks are ready
abstract $printName: string | null;
// declaring as abstract to treat like an interface
$refName: string | null;
/** Additional metadata specified via comments */
$label?: string;
$display?: string;
// set if this block is auto-numbered. This is the index of this block among all auto-numbered blocks in the file
$index?: number;
static isMathBlock(object: Indexable | undefined): object is MathBlock {
return object !== undefined && object.$types.includes('block-math-booster');
}
}
export class TheoremCalloutBlock extends MathBlock implements Linkbearing {
static TYPES = ["markdown", "block", "block-math-booster", "block-theorem", LINKBEARING_TYPE];
$types: string[] = TheoremCalloutBlock.TYPES;
$typename: string = "Theorem Callout Block";
$type: string = "theorem";
/** True if written in the version-1 format of '> [!math|{"type":"theorem",...}]"' */
$v1: boolean;
/** The settings for this theorem callout. */
$settings: MinimalTheoremCalloutSettings;
$titleSuffix: string;
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "Theorem 1.1" */
$theoremMainTitle: string;
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "theorem" */
get $theoremType(): string {
return this.$settings.type;
}
get $numberSpec(): string {
return this.$settings.number;
}
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "Cauchy-Schwarz" */
get $theoremSubtitle(): string | undefined {
return this.$settings.title;
}
/** e.g. "Theorem 1.1 (Cauchy-Schwarz)" */
get $printName(): string {
return this.$theoremSubtitle ? `${this.$theoremMainTitle} (${this.$theoremSubtitle})` : this.$theoremMainTitle;
}
/** Additional metadata specified via comments */
$main: boolean;
static from(
object: JsonTheoremCalloutBlock,
file: string,
normalizer: LinkNormalizer = NOOP_NORMALIZER
): TheoremCalloutBlock {
/**
* `printName` and `refName` can be computed only after the backlinks get ready
* because only linked equations are numbered. We have to wait until then.
*/
return new TheoremCalloutBlock({
$file: file,
$id: MarkdownBlock.readableId(file, object.$ordinal),
$ordinal: object.$ordinal,
$position: object.$position,
$pos: object.$pos,
$links: object.$links.map(normalizer),
$blockId: object.$blockId,
$type: object.$type,
$settings: object.$settings,
$label: object.$label,
$display: object.$display,
$main: object.$main,
$v1: object.$v1,
});
}
public partial(): JsonMarkdownBlock {
return Object.assign(super.partial(), {
$settings: this.$settings,
$label: this.$label,
$display: this.$display,
$main: this.$main,
$v1: this.$v1,
});
}
public constructor(init: Partial<TheoremCalloutBlock>) {
super(init);
}
static isTheoremCalloutBlock(object: Indexable | undefined): object is TheoremCalloutBlock {
return object !== undefined && object.$types.includes('block-theorem');
}
}
export class EquationBlock extends MathBlock {
static TYPES = ["markdown", "block", "block-math-booster", "block-equation"];
$types: string[] = EquationBlock.TYPES;
$typename: string = "Equation Block";
$type: string = "equation";
$printName: string | null = null;
/** The math text of this equation. */
$mathText: string;
$manualTag: string | null;
static from(
object: JsonEquationBlock,
file: string,
normalizer: LinkNormalizer = NOOP_NORMALIZER
): EquationBlock {
/**
* `printName` and `refName` can be computed only after the backlinks get ready
* because only linked equations are numbered. We have to wait until then.
*/
return new EquationBlock({
$file: file,
$id: MarkdownBlock.readableId(file, object.$ordinal),
$ordinal: object.$ordinal,
$position: object.$position,
$pos: object.$pos,
$links: object.$links.map(normalizer),
$blockId: object.$blockId,
$type: object.$type,
$mathText: object.$mathText,
$manualTag: object.$manualTag,
$label: object.$label,
$display: object.$display,
});
}
public partial(): JsonMarkdownBlock {
return Object.assign(super.partial(), {
$mathText: this.$mathText,
$manualTag: this.$manualTag,
$label: this.$label,
$display: this.$display,
});
}
public constructor(init: Partial<EquationBlock>) {
super(init);
}
static isEquationBlock(object: Indexable | undefined): object is EquationBlock {
return object !== undefined && object.$types.includes('block-equation');
}
}

View file

@ -0,0 +1,22 @@
/** A promise that can be resolved directly. */
export type Deferred<T> = Promise<T> & {
resolve: (value: T) => void;
reject: (error: any) => void;
};
/** Create a new deferred object, which is a resolvable promise. */
export function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void;
let reject: (error: any) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
const deferred = promise as any as Deferred<T>;
deferred.resolve = resolve!;
deferred.reject = reject!;
return deferred;
}

View file

@ -0,0 +1,6 @@
/** Get the "title" for a file, by stripping other parts of the path as well as the extension. */
export function getFileTitle(path: string): string {
if (path.includes("/")) path = path.substring(path.lastIndexOf("/") + 1);
if (path.endsWith(".md")) path = path.substring(0, path.length - 3);
return path;
}

View file

@ -0,0 +1,199 @@
/** Controls and creates Dataview file importers, allowing for asynchronous loading and parsing of files. */
import { Component, MetadataCache, TFile, Vault } from "obsidian";
import LatexReferencer from 'main';
import { Transferable } from "./transferable";
import ImportWorker from "index/web-worker/importer.worker";
import { ImportCommand } from "./message";
/** Settings for throttling import. */
export interface ImportThrottle {
/** The number of workers to use for imports. */
workers: number;
/** A number between 0.1 and 1 which indicates total cpu utilization target; 0.1 means spend 10% of time */
utilization: number;
}
/** Default throttle configuration. */
export const DEFAULT_THROTTLE: ImportThrottle = {
workers: 2,
utilization: 0.75,
};
/** Multi-threaded file parser which debounces rapid file requests automatically. */
export class MathImporter extends Component {
/* Background workers which do the actual file parsing. */
workers: Map<number, PoolWorker>;
/** The next worker ID to hand out. */
nextWorkerId: number;
/** Is the importer active? */
shutdown: boolean;
/** List of files which have been queued for a reload. */
queue: [TFile, (success: any) => void, (failure: any) => void][];
/** Outstanding loads indexed by path. */
outstanding: Map<string, Promise<any>>;
/** Throttle settings. */
throttle: () => ImportThrottle;
public constructor(public plugin: LatexReferencer, public vault: Vault, public metadataCache: MetadataCache, throttle?: () => ImportThrottle) {
super();
this.workers = new Map();
this.shutdown = false;
this.nextWorkerId = 0;
this.throttle = throttle ?? (() => DEFAULT_THROTTLE);
this.queue = [];
this.outstanding = new Map();
}
/**
* Queue the given file for importing. Multiple import requests for the same file in a short time period will be de-bounced
* and all be resolved by a single actual file reload.
*/
public import<T>(file: TFile): Promise<T> {
// De-bounce repeated requests for the same file.
let existing = this.outstanding.get(file.path);
if (existing) return existing;
let promise: Promise<T> = new Promise((resolve, reject) => {
this.queue.push([file, resolve, reject]);
});
this.outstanding.set(file.path, promise);
this.schedule();
return promise;
}
/** Reset any active throttles on the importer (such as if the utilization changes). */
public unthrottle() {
for (let worker of this.workers.values()) {
worker.availableAt = Date.now();
}
}
/** Poll from the queue and execute if there is an available worker. */
private schedule() {
if (this.queue.length == 0 || this.shutdown) return;
const worker = this.availableWorker();
if (!worker) return;
const [file, resolve, reject] = this.queue.shift()!;
worker.active = [file, resolve, reject, Date.now()];
this.vault.cachedRead(file).then((c) =>
worker!.worker.postMessage(
Transferable.transferable({
type: "markdown",
path: file.path,
contents: c,
metadata: this.metadataCache.getFileCache(file),
excludeExampleCallout: this.plugin.extraSettings.excludeExampleCallout,
} as ImportCommand)
)
);
}
/** Finish the parsing of a file, potentially queueing a new file. */
private finish(worker: PoolWorker, data: any) {
let [file, resolve, reject] = worker.active!;
// Resolve promises to let users know this file has finished.
if ("$error" in data) reject(data["$error"]);
else resolve(data);
// Remove file from outstanding.
this.outstanding.delete(file.path);
// Remove this worker if we are over capacity.
// Otherwise, notify the queue this file is available for new work.
if (this.workers.size > this.throttle().workers) {
this.workers.delete(worker.id);
terminate(worker);
} else {
const now = Date.now();
const start = worker.active![3];
const throttle = Math.max(0.1, this.throttle().utilization) - 1.0;
const delay = (now - start) * throttle;
worker.active = undefined;
if (delay <= 1e-10) {
worker.availableAt = now;
this.schedule();
} else {
worker.availableAt = now + delay;
// Note: I'm pretty sure this will garauntee that this executes AFTER delay milliseconds,
// so this should be fine; if it's not, we'll have to swap to an external timeout loop
// which infinitely reschedules itself to the next available execution time.
setTimeout(this.schedule.bind(this), delay);
}
}
}
/** Obtain an available worker, returning undefined if one does not exist. */
private availableWorker(): PoolWorker | undefined {
const now = Date.now();
for (let worker of this.workers.values()) {
if (!worker.active && worker.availableAt <= now) {
return worker;
}
}
// Make a new worker if we can.
if (this.workers.size < this.throttle().workers) {
let worker = this.newWorker();
this.workers.set(worker.id, worker);
return worker;
}
return undefined;
}
/** Create a new worker bound to this importer. */
private newWorker(): PoolWorker {
let worker: PoolWorker = {
id: this.nextWorkerId++,
availableAt: Date.now(),
worker: new ImportWorker(),
};
worker.worker.onmessage = (evt) => this.finish(worker, Transferable.value(evt.data));
return worker;
}
/** Reject all outstanding promises and close all workers on close. */
public onunload(): void {
for (let worker of this.workers.values()) {
terminate(worker);
}
for (let [_file, _success, reject] of this.queue) {
reject("Terminated");
}
this.shutdown = true;
}
}
/** A worker in the pool of executing workers. */
interface PoolWorker {
/** The id of this worker. */
id: number;
/** The raw underlying worker. */
worker: Worker;
/** UNIX time indicating the next time this worker is available for execution according to target utilization. */
availableAt: number;
/** The active promise this worker is working on, if any. */
active?: [TFile, (success: any) => void, (failure: any) => void, number];
}
/** Terminate a pool worker. */
function terminate(worker: PoolWorker) {
worker.worker.terminate();
if (worker.active) worker.active[2]("Terminated");
worker.active = undefined;
}

View file

@ -0,0 +1,25 @@
import { markdownImport } from "index/import/markdown";
import { ImportCommand, MarkdownImportResult } from "index/web-worker/message";
import { Transferable } from "index/web-worker/transferable";
/** Web worker entry point for importing. */
onmessage = (event) => {
try {
const message = Transferable.value(event.data) as ImportCommand;
if (message.type === "markdown") {
const markdown = markdownImport(message.path, message.contents, message.metadata, message.excludeExampleCallout);
postMessage(
Transferable.transferable({
type: "markdown",
result: markdown,
} as MarkdownImportResult)
);
} else {
postMessage({ $error: "Unsupported import method." });
}
} catch (error) {
postMessage({ $error: error.message });
}
};

View file

@ -0,0 +1,39 @@
import { JsonMarkdownPage } from "index/typings/json";
import { CachedMetadata, FileStats } from "obsidian";
/** A command to import a markdown file. */
export interface MarkdownImport {
type: "markdown";
/** The path we are importing. */
path: string;
/** The file contents to import. */
contents: string;
// /** The stats for the file. */
// stat: FileStats;
/** Metadata for the file. */
metadata: CachedMetadata;
excludeExampleCallout: boolean;
}
/** Available import commands to be sent to an import web worker. */
export type ImportCommand = MarkdownImport; //| CanvasImport;
/** The result of importing a file of some variety. */
export interface MarkdownImportResult {
/** The type of import. */
type: "markdown";
/** The result of importing. */
result: JsonMarkdownPage;
}
export interface ImportFailure {
/** Failed to import. */
type: "error";
/** The error that the worker indicated on failure. */
$error: string;
}
export type ImportResult = MarkdownImportResult | ImportFailure;

View file

@ -0,0 +1,98 @@
import { Link, Literals } from "index/expression/literal";
// import { DateTime, Duration, SystemZone } from "luxon";
/** Simplifies passing complex values across the JS web worker barrier. */
export namespace Transferable {
/** Convert a literal value to a serializer-friendly transferable value. */
export function transferable(value: any): any {
// Handle simple universal types first.
if (value instanceof Map) {
let copied = new Map();
for (let [key, val] of value.entries()) copied.set(transferable(key), transferable(val));
return copied;
} else if (value instanceof Set) {
let copied = new Set();
for (let val of value) copied.add(transferable(val));
return copied;
}
let wrapped = Literals.wrapValue(value);
if (wrapped === undefined) throw Error("Unrecognized transferable value: " + value);
switch (wrapped.type) {
case "null":
case "number":
case "string":
case "boolean":
return wrapped.value;
// case "date":
// return {
// "$transfer-type": "date",
// value: transferable(wrapped.value.toObject()),
// options: {
// zone: wrapped.value.zone.equals(SystemZone.instance) ? undefined : wrapped.value.zoneName,
// },
// };
// case "duration":
// return {
// "$transfer-type": "duration",
// value: transferable(wrapped.value.toObject()),
// };
case "array":
return wrapped.value.map((v) => transferable(v));
case "link":
return {
"$transfer-type": "link",
value: transferable(wrapped.value.toObject()),
};
case "object":
let result: Record<string, any> = {};
// Only copy owned properties, and not derived/readonly properties like getters/computed fields.
for (let key of Object.getOwnPropertyNames(wrapped.value))
result[key] = transferable(wrapped.value[key]);
return result;
}
}
/** Convert a transferable value back to a literal value we can work with. */
export function value(transferable: any): any {
if (transferable === null) {
return null;
} else if (transferable === undefined) {
return undefined;
} else if (transferable instanceof Map) {
let real = new Map();
for (let [key, val] of transferable.entries()) real.set(value(key), value(val));
return real;
} else if (transferable instanceof Set) {
let real = new Set();
for (let val of transferable) real.add(value(val));
return real;
} else if (Array.isArray(transferable)) {
return transferable.map((v) => value(v));
} else if (typeof transferable === "object") {
if ("$transfer-type" in transferable) {
switch (transferable["$transfer-type"]) {
// case "date":
// let dateOpts = value(transferable.options);
// let dateData = value(transferable.value) as any;
// return DateTime.fromObject(dateData, { zone: dateOpts.zone });
// case "duration":
// return Duration.fromObject(value(transferable.value));
case "link":
return Link.fromObject(value(transferable.value));
default:
throw Error(`Unrecognized transfer type '${transferable["$transfer-type"]}'`);
}
}
let result: Record<string, any> = {};
for (let [key, val] of Object.entries(transferable)) result[key] = value(val);
return result;
}
return transferable;
}
}

404
src/main.ts Normal file
View file

@ -0,0 +1,404 @@
import { MarkdownView, Plugin } from 'obsidian';
import { StateField, Extension, RangeSet } from '@codemirror/state';
import * as MathLinks from 'obsidian-mathlinks';
import { registerQuickPreview } from 'obsidian-quick-preview';
import { MathContextSettings, DEFAULT_SETTINGS, ExtraSettings, DEFAULT_EXTRA_SETTINGS, UNION_TYPE_MATH_CONTEXT_SETTING_KEYS, UNION_TYPE_EXTRA_SETTING_KEYS } from 'settings/settings';
import { MathSettingTab } from "settings/tab";
import { CleverefProvider } from 'cleveref';
import { createTheoremCalloutPostProcessor } from 'theorem-callouts/renderer';
import { createTheoremCalloutNumberingViewPlugin } from 'theorem-callouts/view-plugin';
import { ContextSettingModal, TheoremCalloutModal } from 'settings/modals';
import { createEquationNumberProcessor } from 'equations/reading-view';
import { createEquationNumberPlugin } from 'equations/live-preview';
import { getMarkdownPreviewViewEl, getMarkdownSourceViewEl, isPluginOlderThan } from 'utils/obsidian';
import { getProfile, staticifyEqNumber, insertDisplayMath, insertTheoremCallout, insertProof } from 'utils/plugin';
import { MathIndexManager } from 'index/manager';
import { DependencyNotificationModal, MigrationModal, PluginSplitNoticeModal, RenameNoticeModal } from 'notice';
import { LinkAutocomplete } from 'search/editor-suggest';
import { MathSearchModal } from 'search/modal';
import { TheoremCalloutInfo, createTheoremCalloutsField } from 'theorem-callouts/state-field';
import { patchLinkCompletion } from 'patches/link-completion';
import { patchPagePreview } from 'patches/page-preview';
import { createProofDecoration } from 'proof/live-preview';
import { createProofProcessor } from 'proof/reading-view';
import { MathBlock } from 'index/typings/markdown';
export const VAULT_ROOT = '/';
export default class LatexReferencer extends Plugin {
settings: Record<string, Partial<MathContextSettings>>;
extraSettings: ExtraSettings;
excludedFiles: string[];
dependencies: Record<string, { id: string, name: string, version: string }> = {
"mathlinks": { id: "mathlinks", name: "MathLinks", version: "0.5.3" }
};
indexManager: MathIndexManager;
editorExtensions: Extension[];
theoremCalloutsField: StateField<RangeSet<TheoremCalloutInfo>>;
// proofPositionField: StateField<ProofPosition[]>;
lastHoverLinktext: string | null;
async onload() {
/** Settings */
const data = await this.loadData();
const first = data === null;
const { version } = data ?? {};
await this.loadSettings();
await this.saveSettings();
this.addSettingTab(new MathSettingTab(this.app, this));
/** Dependencies check */
this.app.workspace.onLayoutReady(async () => {
const dependenciesOK = Object.keys(this.dependencies).every((id) => this.checkDependency(id));
const v1 = !first && ((version as string | undefined)?.startsWith("1.") ?? true);
if (v1 || version.localeCompare('2.2.0', undefined, { numeric: true }) < 0) {
new RenameNoticeModal(this).open();
}
if (v1 || version.localeCompare('2.3.0', undefined, { numeric: true }) < 0) {
new PluginSplitNoticeModal(this).open();
}
if (!dependenciesOK || v1) {
new DependencyNotificationModal(this, dependenciesOK, v1).open();
}
});
/** Indexing */
this.addChild((this.indexManager = new MathIndexManager(this, this.extraSettings)));
this.app.workspace.onLayoutReady(async () => this.indexManager.initialize());
// @ts-ignore
(window['mathIndex'] = this.indexManager.index) && this.register(() => delete window['mathIndex'])
// wait until the layout is ready to ensure MathLinks has been loaded when calling addProvider()
this.app.workspace.onLayoutReady(() => {
this.addChild(
MathLinks.addProvider(this.app, (mathLinks) => new CleverefProvider(mathLinks, this))
);
});
this.registerEvent(
this.indexManager.on("local-settings-updated", async (file) => {
// Add profile's tags as CSS classes
this.app.workspace.iterateRootLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.setProfileTagAsCSSClass(leaf.view);
}
});
})
);
this.registerEvent(
this.indexManager.on("global-settings-updated", async () => {
// Add profile's tags as CSS classes
this.app.workspace.iterateRootLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.setProfileTagAsCSSClass(leaf.view);
}
});
})
);
/** Add profile's tags as CSS classes */
this.app.workspace.onLayoutReady(() => {
this.app.workspace.iterateRootLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.setProfileTagAsCSSClass(leaf.view);
}
});
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf?.view instanceof MarkdownView) {
this.setProfileTagAsCSSClass(leaf.view);
}
})
);
/** Commands */
this.registerCommands();
/** Editor Extensions */
this.editorExtensions = []
this.registerEditorExtension(this.editorExtensions);
this.updateEditorExtensions();
/** Theorem/equation link autocompletion */
this.updateLinkAutocomplete();
this.app.workspace.onLayoutReady(() => patchLinkCompletion(this));
const itemNormalizer = (item: MathBlock) => {
return {
linktext: item.$file,
sourcePath: '',
line: item.$position.start,
};
};
registerQuickPreview(this.app, this, LinkAutocomplete, itemNormalizer);
registerQuickPreview(this.app, this, MathSearchModal, itemNormalizer);
/** Markdown post processors */
// theorem callouts
this.registerMarkdownPostProcessor(createTheoremCalloutPostProcessor(this));
// equation numbers
this.registerMarkdownPostProcessor(createEquationNumberProcessor(this));
this.app.workspace.onLayoutReady(() => this.forceRerender());
// proof environments
this.registerMarkdownPostProcessor(createProofProcessor(this));
// patch hover page preview to display theorem numbers in it
this.lastHoverLinktext = null;
this.app.workspace.onLayoutReady(() => patchPagePreview(this));
/** File menu */
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) => {
menu.addSeparator()
.addItem((item) => {
item.setTitle(`${this.manifest.name}: Open local settings`)
.onClick(() => {
new ContextSettingModal(this.app, this, file).open();
});
})
.addSeparator();
})
);
}
async loadSettings() {
this.settings = { [VAULT_ROOT]: JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) };
this.extraSettings = JSON.parse(JSON.stringify(DEFAULT_EXTRA_SETTINGS));
this.excludedFiles = [];
// this.projectManager = new ProjectManager(this);
const loadedData = await this.loadData();
if (loadedData) {
const { settings, extraSettings, excludedFiles,
// dumpedProjects
} = loadedData;
for (const path in settings) {
if (path != VAULT_ROOT) {
this.settings[path] = {};
}
for (const _key in DEFAULT_SETTINGS) {
const key = _key as keyof MathContextSettings;
let val = settings[path][key];
if (val !== undefined) {
if (key in UNION_TYPE_MATH_CONTEXT_SETTING_KEYS) {
const allowableValues = UNION_TYPE_MATH_CONTEXT_SETTING_KEYS[key];
if (!(allowableValues?.includes(val))) {
// invalid value encountered, substitute the default value instead
val = DEFAULT_SETTINGS[key];
}
}
if (typeof val == typeof DEFAULT_SETTINGS[key]) {
// @ts-ignore
this.settings[path][key] = val;
}
}
}
}
for (const _key in DEFAULT_EXTRA_SETTINGS) {
const key = _key as keyof ExtraSettings;
let val = extraSettings[key];
if (val !== undefined) {
if (key in UNION_TYPE_EXTRA_SETTING_KEYS) {
const allowableValues = UNION_TYPE_EXTRA_SETTING_KEYS[key];
if (!(allowableValues?.includes(val))) {
val = DEFAULT_EXTRA_SETTINGS[key];
}
}
if (typeof val == typeof DEFAULT_EXTRA_SETTINGS[key]) {
(this.extraSettings[key] as ExtraSettings[keyof ExtraSettings]) = val;
}
}
}
this.excludedFiles = excludedFiles;
// At the time the plugin is loaded, the data vault is not ready and
// vault.getAbstractFile() returns null for any path.
// So we have to wait for the vault to start up and store a dumped version of the projects until then.
// this.projectManager = new ProjectManager(this, dumpedProjects);
}
}
async saveSettings() {
await this.saveData({
version: this.manifest.version,
settings: this.settings,
extraSettings: this.extraSettings,
excludedFiles: this.excludedFiles,
// dumpedProjects: this.projectManager.dump(),
});
}
updateLinkAutocomplete() {
// reset editor suggest(s) registered by this plugin
const suggestManager = (this.app.workspace as any).editorSuggest;
for (const suggest of suggestManager.suggests) {
if (suggest instanceof LinkAutocomplete) suggestManager.removeSuggest(suggest);
}
this.registerEditorSuggest(new LinkAutocomplete(this));
}
/**
* Return true if the required plugin with the specified id is enabled and its version matches the requriement.
* @param id
* @returns
*/
checkDependency(id: string): boolean {
if (!this.app.plugins.enabledPlugins.has(id)) {
return false;
}
const depPlugin = this.app.plugins.getPlugin(id);
if (depPlugin) {
return !isPluginOlderThan(depPlugin, this.dependencies[id].version)
}
return false;
}
setProfileTagAsCSSClass(view: MarkdownView) {
if (!view.file) return;
const profile = getProfile(this, view.file);
const classes = [
...profile.meta.tags.map((tag) => `math-booster-${tag}`), // deprecated
...profile.meta.tags.map((tag) => `latex-referencer-${tag}`),
];
for (const el of [getMarkdownSourceViewEl(view), getMarkdownPreviewViewEl(view)]) {
if (el) {
el.classList.forEach((cls) => {
if (cls.startsWith("math-booster-") || cls.startsWith("latex-referencer-")) {
el.classList.remove(cls);
}
});
el?.addClass(...classes);
}
}
}
updateEditorExtensions() {
this.editorExtensions.length = 0;
// theorem callouts
this.editorExtensions.push(this.theoremCalloutsField = createTheoremCalloutsField(this));
this.editorExtensions.push(createTheoremCalloutNumberingViewPlugin(this));
// equation numbers
this.editorExtensions.push(createEquationNumberPlugin(this));
// proofs
if (this.extraSettings.enableProof) {
this.editorExtensions.push(createProofDecoration(this));
// this.editorExtensions.push(this.proofPositionField = proofPositionFieldFactory(this));
// this.editorExtensions.push(proofDecorationFactory(this));
// this.editorExtensions.push(proofFoldFactory(this));
}
this.app.workspace.updateOptions();
}
registerCommands() {
this.addCommand({
id: 'insert-display-math',
name: 'Insert display math',
editorCallback: insertDisplayMath,
});
this.addCommand({
id: 'insert-theorem-callout',
name: 'Insert theorem callout',
editorCheckCallback: (checking, editor, context) => {
if (!context.file) return false;
if (!checking) {
new TheoremCalloutModal(
this.app, this, context.file,
(config) => {
insertTheoremCallout(editor, config);
},
"Insert", "Insert theorem callout",
).open();
}
return true;
}
});
this.addCommand({
id: 'search',
name: 'Search',
callback: () => {
new MathSearchModal(this).open();
}
})
this.addCommand({
id: 'open-local-settings-for-current-note',
name: 'Open local settings for the current note',
callback: () => {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view?.file) {
new ContextSettingModal(this.app, this, view.file).open();
}
}
});
this.addCommand({
id: 'insert-proof',
name: 'Insert proof',
editorCallback: (editor, context) => insertProof(this, editor, context)
});
this.addCommand({
id: 'convert-equation-number-to-tag',
name: 'Convert equation numbers in the current note to static \\tag{}',
callback: () => {
const file = this.app.workspace.getActiveFile();
if (file) staticifyEqNumber(this, file);
}
});
this.addCommand({
id: 'migrate-from-v1',
name: 'Migrate from version 1',
callback: () => {
new MigrationModal(this).open();
}
});
}
forceRerender() {
setTimeout(async () => {
for (const leaf of this.app.workspace.getLeavesOfType('markdown')) {
const view = leaf.view as MarkdownView;
const state = view.getEphemeralState();
view.previewMode.rerender(true);
view.setEphemeralState(state);
}
}, 800);
}
}

371
src/notice.ts Normal file
View file

@ -0,0 +1,371 @@
import { Modal, Setting, Component, MarkdownRenderer, Notice } from "obsidian";
import LatexReferencer from "main";
import { isPluginOlderThan } from "utils/obsidian";
import { rewriteTheoremCalloutFromV1ToV2 } from "utils/plugin";
export class PluginSplitNoticeModal extends Modal {
component: Component;
constructor(public plugin: LatexReferencer) {
super(plugin.app);
this.component = new Component();
}
onOpen() {
const { app, plugin, contentEl, titleEl } = this;
plugin.addChild(this.component);
contentEl.empty();
titleEl.setText(`${plugin.manifest.name} ver. ${plugin.manifest.version}`);
new Setting(contentEl)
.setName('Some features of this plugin have been rewritten with a bunch of improvements, and they are now available as the following separate plugins:')
.setHeading();
for (const { name, id, desc } of [
{
name: 'Better Math in Callouts & Blockquotes',
id: 'math-in-callout',
desc: 'Add better Live Preview support for math rendering inside callouts & blockquotes. It renders math expressions in callouts and provides appropriate handling of multi-line equations inside blockquotes.'
},
{
name: 'Rendered Block Link Suggestions',
id: 'rendered-block-link-suggestions',
desc: 'Render equations and other types of blocks in Obsidian\'s built-in link suggestions'
}
]) {
const installed = id in (app.plugins as any).manifests;
const enabled = app.plugins.enabledPlugins.has(id);
new Setting(contentEl)
.setName(name)
.setDesc(desc)
.addButton((button) => {
button
.setButtonText(enabled ? 'Already enabled!' : installed ? 'Enable' : 'Install')
.then((button) => enabled || button.setCta())
.onClick(() => {
self.open(`obsidian://show-plugin?id=${id}`);
this.component.registerDomEvent(window, 'click', (evt) => {
this.onOpen();
})
});
})
}
}
onClose() {
this.contentEl.empty();
this.component.unload();
}
}
export class RenameNoticeModal extends Modal {
component: Component;
constructor(public plugin: LatexReferencer) {
super(plugin.app);
this.component = new Component();
}
onOpen() {
this.plugin.addChild(this.component)
const { contentEl, titleEl } = this;
contentEl.empty();
titleEl.setText('Math Booster has been renamed');
MarkdownRenderer.render(
this.app,
`Starting from version 2.2.0, Math Booster has been renamed to ***LaTeX-like Theorem & Equation Referencer*** for better clarity and discoverability.\n\nWhile the display name in the community plugin browser may still reflect the previous version, it will be updated shortly.\n\nA big thank you for those who shared their ideas [here](https://github.com/RyotaUshio/obsidian-math-booster/issues/210)!\n\n> [!warning]\n> If you have custom CSS snippets with CSS classes <code>.math-booster-&#42</code>, don't worry, they still work!\n> \n> But I do recommend you to replace them with <code>.latex-referencer-&#42</code> as the old class names might be removed in the future.`,
contentEl,
'',
this.component
);
new Setting(contentEl)
.addButton((button) => {
button.setCta()
.setButtonText('Okay, I got it')
.onClick(() => this.close());
})
.then((setting) => setting.settingEl.style.border = 'none');
}
onClose() {
this.contentEl.empty();
this.component.unload();
}
}
export class DependencyNotificationModal extends Modal {
component: Component;
constructor(public plugin: LatexReferencer, public dependenciesOK: boolean, public v1: boolean) {
super(plugin.app);
this.component = new Component();
}
async onOpen() {
this.plugin.addChild(this.component);
const { contentEl, titleEl } = this;
contentEl.empty();
titleEl.setText(`${this.plugin.manifest.name} ${this.plugin.manifest.version}`)
if (!this.dependenciesOK) this.showDependencies();
if (this.v1) this.showMigrationGuild();
}
showDependencies() {
this.contentEl.createDiv({
text: `${this.plugin.manifest.name} requires the following plugin to work properly.`,
attr: { style: "margin-bottom: 1em;" }
});
/**
* Validity indicator was taken from the Latex Suite plugin (https://github.com/artisticat1/obsidian-latex-suite/blob/a5914c70c16d5763a182ec51d9716110b40965cf/src/settings.ts)
*
* MIT License
*
* Copyright (c) 2022 artisticat1
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
for (const depenedency of Object.values(this.plugin.dependencies)) {
const depPlugin = this.app.plugins.getPlugin(depenedency.id);
const isValid = depPlugin && !isPluginOlderThan(depPlugin, depenedency.version);
const setting = new Setting(this.contentEl)
.setName(depenedency.name)
.addExtraButton((button) => {
button.setIcon(isValid ? "checkmark" : "cross");
const el = button.extraSettingsEl;
el.addClass("math-booster-dependency-validation");
el.removeClass(isValid ? "invalid" : "valid");
el.addClass(isValid ? "valid" : "invalid");
});
setting.descEl.createDiv(
{
text:
`Required version: ${depenedency.version}+ / `
+ (depPlugin ? `Currently installed: ${depPlugin.manifest.version}`
: `Not installed or enabled`)
}
);
}
}
async showMigrationGuild() {
this.contentEl.createEl('h3', { text: "Migration from version 1" });
MarkdownRenderer.render(
this.app,
`LaTeX-like Theorem & Equation Referencer (formerly called Math Booster) version 2 introduces a [new format for theorem callouts](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/theorem-callouts.html).
To fully enjoy version 2, click the button below to convert the old theorem format to the new one. Alternatively, you can do it later by running the command "Migrate from version 1".`,
this.contentEl.createDiv(),
'',
this.component
);
new Setting(this.contentEl)
.addButton((button) => {
button.setButtonText('Convert')
.setCta()
.onClick(() => {
this.close();
new MigrationModal(this.plugin).open();
})
})
.addButton((button) => button.setButtonText('Not now')
.onClick(() => this.close()))
.then(setting => setting.settingEl.style.border = 'none');
const descEl = this.contentEl.createDiv({ cls: 'math-booster-version-2-release-note-modal' });
await MarkdownRenderer.render(this.app,
`### What's new in version 2
- [New format for theorem callouts](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/theorem-callouts.html):
- *much cleaner*,
- *more intuitive*,
- *more keyboard-friendly*,
- and *less plugin-dependent* than the previous format
- New indexing mechanism:
- no longer blocks UI
- no longer hard-codes theorem indices in notes directly
- [Enhancing Obsidian's built-in link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/enhancing-obsidian's-built-in-link-autocomplete.html): now equations are rendered in the built-in autocomplete as well.
- [Custom link autocomplete](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/search-&-link-autocomplete/custom-link-autocomplete.html) improvements: filter theorems & equations (*entire vault/recent notes/active note*)
- [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*
- Adding metadata to [theorems](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/theorem-callouts/theorem-callouts.html) and [equations](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/equations.html) with comments
- Theorem numbers and [equation numbers](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/equations.html) now can be displayed *almost everywhere*:
##### Version 1:
| | Theorem number | Equation number |
| ------------------ | -------------- | --------------- |
| Reading view | | |
| Live preview | | |
| Embeds | | |
| Hover page preview | | |
| PDF export | | |
##### **🎉 Version 2:**
| | Theorem number | Equation number |
| ------------------ | -------------- | --------------- |
| Reading view | | |
| Live preview | | |
| Embeds | | |
| Hover page preview | | |
| PDF export | | |
### No longer supported
- ["Show backlinks" right-click menu](https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/blob/1.0.4/docs/backlinks.md)
- Use [Strange New Worlds](https://github.com/TfTHacker/obsidian42-strange-new-worlds) instead.
- [Projects](https://github.com/RyotaUshio/obsidian-latex-theorem-equation-referencer/blob/1.0.4/docs/projects.md)
- might be supported later with some improvements
`, descEl, '', this.component);
descEl.querySelectorAll('.copy-code-button').forEach((el) => el.remove());
}
onClose() {
this.contentEl.empty();
this.component.unload();
}
}
export class MigrationModal extends Modal {
component: Component;
constructor(public plugin: LatexReferencer) {
super(plugin.app);
this.component = new Component();
this.plugin.addChild(this.component);
}
async onOpen() {
let { contentEl, modalEl, titleEl } = this;
contentEl.empty();
modalEl.querySelector('.modal-close-button')?.remove();
titleEl.setText("Convert theorem callouts' format from v1 to v2")
const descEl = contentEl.createDiv();
await MarkdownRenderer.render(
this.app,
`
In order to enjoy LaTeX-like Theorem & Equation Referencer, you need to convert the old theorem format from Math Booster version 1:
\`\`\`md
> [!math|{"type":"theorem","number":"auto","title":"Main result","label":"main-result","_index":0}] Theorem 1 (Main result).
\`\`\`
to the new format:
\`\`\`md
> [!theorem] Main result
> %% label: main-result %%
\`\`\`
> [!WARNING]
> **MAKE SURE YOU HAVE A BACKUP OF YOUR VAULT BEFORE CONTINUING. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY ARISING FROM THIS OPERATION.**
`,
descEl, '', this.component);
descEl.querySelectorAll('.copy-code-button').forEach(el => el.remove());
await new Promise<void>((resolve) => {
new Setting(contentEl)
.setName('Are you sure to proceed?')
.addButton((button) => {
button.setButtonText('Yes').setWarning().onClick(() => resolve())
})
.addButton((button) => {
button.setButtonText('No').onClick(() => this.close())
});
});
// @ts-ignore
if (!this.app.metadataCache.initialized || !this.plugin.indexManager.initialized) {
new Notice('Obsidian is still indexing the vault. Try again after the cache is fully initialized.');
return;
}
contentEl.empty();
const waitForCacheRefresh = new Setting(contentEl)
.setName('Preparing the fresh cache...');
await new Promise<void>((resolve) => {
waitForCacheRefresh.addProgressBar((bar) => {
let progress = 0;
const timer = window.setInterval(() => {
bar.setValue(progress++)
if (progress >= 100) {
window.clearInterval(timer);
resolve();
}
}, 3 * 10);
})
});
waitForCacheRefresh.setName('Preparing the fresh cache... Done!');
const converting = new Setting(contentEl)
.setName('Converting...')
// .then(setting => setting.controlEl.style.width = '50px');
await new Promise<void>((resolve) => {
converting.addProgressBar(async (bar) => {
const files = this.app.vault.getMarkdownFiles();
let done = 0;
const all = files.length;
for (const file of files) {
await rewriteTheoremCalloutFromV1ToV2(this.plugin, file);
bar.setValue(done++ / all * 100);
}
bar.setValue(100);
resolve();
})
});
converting.setName('Converting... Done!');
new Setting(contentEl)
.addButton((button) => {
button.setButtonText('Close')
.setCta()
.onClick(() => this.close())
})
}
onClose() {
this.contentEl.empty();
this.component.unload();
}
}

View file

@ -0,0 +1,69 @@
import { EditorSuggest, Notice, TFile, renderMath } from "obsidian";
import { around } from "monkey-around";
import LatexReferencer from "main";
import { MarkdownPage, TheoremCalloutBlock } from 'index/typings/markdown';
import { isTheoremCallout, resolveSettings } from 'utils/plugin';
import { formatTitle } from 'utils/format';
import { _readTheoremCalloutSettings } from 'utils/parse';
import { capitalize } from 'utils/general';
import { renderTextWithMath } from "utils/render";
export const patchLinkCompletion = (plugin: LatexReferencer) => {
const suggest = (plugin.app.workspace as any).editorSuggest.suggests[0]; // built-in link completion
if (!Object.hasOwn(suggest, 'suggestManager')) new Notice(`Failed to patch Obsidian\'s built-in link completion. Please reload ${plugin.manifest.name}.`);
const prototype = suggest.constructor.prototype as EditorSuggest<any>;
plugin.register(around(prototype, {
renderSuggestion(old) {
return function (item: any, el: HTMLElement) {
old.call(this, item, el);
if (plugin.extraSettings.showTheoremTitleinBuiltin && item.type === 'block' && item.node.type === 'callout' && isTheoremCallout(plugin, item.node.callout.type)) {
let title: string = item.node.children.find((child: any) => child.type === 'callout-title')?.children.map((child: any) => child.value).join('') ?? '';
const content = item.display.slice(title.length);
const page = plugin.indexManager.index.load(item.file.path);
if (MarkdownPage.isMarkdownPage(page)) {
const block = page.getBlockByLineNumber(item.node.position.start.line - 1); // line number starts from 1
if (TheoremCalloutBlock.isTheoremCalloutBlock(block)) {
renderInSuggestionTitleEl(el, (suggestionTitleEl) => {
el.addClass('math-booster', 'suggestion-item-theorem-callout');
suggestionTitleEl.replaceChildren();
const children = renderTextWithMath(block.$printName);
suggestionTitleEl
.createDiv()
.replaceChildren(...children);
if (plugin.extraSettings.showTheoremContentinBuiltin && content) suggestionTitleEl.createDiv({ text: content });
});
return;
}
}
const parsed = _readTheoremCalloutSettings({ type: item.node.callout.type, metadata: item.node.callout.data }, plugin.extraSettings.excludeExampleCallout);
if (parsed) {
const { type, number } = parsed;
if (title === capitalize(type)) title = '';
const formattedTitle = formatTitle(plugin, item.file as TFile, resolveSettings({ type, number, title }, plugin, item.file as TFile), true);
renderInSuggestionTitleEl(el, (suggestionTitleEl) => {
el.addClass('math-booster', 'suggestion-item-theorem-callout');
suggestionTitleEl.replaceChildren();
const children = renderTextWithMath(formattedTitle);
suggestionTitleEl
.createDiv()
.replaceChildren(...children);
if (plugin.extraSettings.showTheoremContentinBuiltin && content) suggestionTitleEl.createDiv({ text: content });
});
return;
}
}
}
}
}));
};
function renderInSuggestionTitleEl(el: HTMLElement, cb: (suggestionTitleEl: HTMLElement) => void) {
// setTimeout(() => {
const suggestionTitleEl = el.querySelector<HTMLElement>('.suggestion-title');
if (suggestionTitleEl) cb(suggestionTitleEl);
// });
}

View file

@ -0,0 +1,24 @@
import { HoverParent } from 'obsidian';
import { around } from 'monkey-around';
import LatexReferencer from '../main';
// Inspired by Hover Editor (https://github.com/nothingislost/obsidian-hover-editor/blob/c038424acb15c542f0ad5f901d74c75d4316f553/src/main.ts#L396)
// Save the last linktext that triggered hover page preview in the plugin instance to display theorem/equation numbers in it
export const patchPagePreview = (plugin: LatexReferencer) => {
const { app } = plugin;
plugin.register(
// @ts-ignore
around(app.internalPlugins.plugins['page-preview'].instance.constructor.prototype, {
onLinkHover(old: Function) {
return function (parent: HoverParent, targetEl: HTMLElement, linktext: string, ...args: unknown[]) {
old.call(this, parent, targetEl, linktext, ...args);
// Save the linktext in the plugin instance
plugin.lastHoverLinktext = linktext;
}
}
})
);
}

17
src/proof/common.ts Normal file
View file

@ -0,0 +1,17 @@
import { Profile } from "settings/profile";
export function makeProofClasses(which: "begin" | "end", profile: Profile) {
return [
"math-booster-" + which + "-proof", // deprecated
"latex-referencer-" + which + "-proof",
...profile.meta.tags.map((tag) => "math-booster-" + which + "-proof-" + tag), // deprecated
...profile.meta.tags.map((tag) => "latex-referencer-" + which + "-proof-" + tag)
];
}
export function makeProofElement(which: "begin" | "end", profile: Profile) {
return createSpan({
text: profile.body.proof[which],
cls: makeProofClasses(which, profile)
})
}

211
src/proof/live-preview.ts Normal file
View file

@ -0,0 +1,211 @@
import { editorInfoField } from 'obsidian';
import { RangeSetBuilder } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, PluginValue, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { syntaxTree } from '@codemirror/language';
import LatexReferencer from 'main';
import { nodeText, rangesHaveOverlap } from 'utils/editor';
import { Profile } from 'settings/profile';
import { renderMarkdown } from 'utils/render';
import { resolveSettings } from 'utils/plugin';
import { makeProofClasses, makeProofElement } from './common';
export const INLINE_CODE = "inline-code";
export const LINK_BEGIN = "formatting-link_formatting-link-start";
export const LINK = "hmd-internal-link";
export const LINK_END = "formatting-link_formatting-link-end";
abstract class ProofWidget extends WidgetType {
containerEl: HTMLElement | null;
constructor(public plugin: LatexReferencer, public profile: Profile) {
super();
this.containerEl = null;
}
eq(other: EndProofWidget): boolean {
return this.profile.id === other.profile.id;
}
toDOM(): HTMLElement {
return this.containerEl ?? (this.containerEl = this.initDOM());
}
abstract initDOM(): HTMLElement;
ignoreEvent(event: Event): boolean {
// the DOM element won't respond to clicks without this
return false;
}
}
class BeginProofWidget extends ProofWidget {
containerEl: HTMLElement | null;
constructor(
plugin: LatexReferencer, profile: Profile,
public display: string | null,
public linktext: string | null,
public sourcePath: string
) {
super(plugin, profile);
}
eq(other: BeginProofWidget): boolean {
return this.profile.id === other.profile.id && this.display === other.display && this.linktext === other.linktext && this.sourcePath == other.sourcePath;
}
initDOM(): HTMLElement {
let display = this.linktext
? `${this.profile.body.proof.linkedBeginPrefix} [[${this.linktext}]]${this.profile.body.proof.linkedBeginSuffix}`
: this.display;
if (display) {
const el = createSpan({ cls: makeProofClasses("begin", this.profile) });
BeginProofWidget.renderDisplay(el, display, this.sourcePath, this.plugin);
return el;
}
return makeProofElement("begin", this.profile);
}
static async renderDisplay(el: HTMLElement, display: string, sourcePath: string, plugin: LatexReferencer) {
const children = await renderMarkdown(display, sourcePath, plugin);
if (children) {
el.replaceChildren(...children);
}
}
}
class EndProofWidget extends ProofWidget {
containerEl: HTMLElement | null;
initDOM(): HTMLElement {
return makeProofElement("end", this.profile);
}
}
export interface ProofPosition {
display?: string,
linktext?: string,
linknodes?: { linkBegin: SyntaxNodeRef, link: SyntaxNodeRef, linkEnd: SyntaxNodeRef },
}
export const createProofDecoration = (plugin: LatexReferencer) => ViewPlugin.fromClass(
class implements PluginValue {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.makeDeco(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
if (update.view.composing) {
this.decorations = this.decorations.map(update.changes); // User is using IME
} else {
this.decorations = this.makeDeco(update.view);
}
}
}
makeDeco(view: EditorView): DecorationSet {
const { state } = view;
const { app } = plugin;
const tree = syntaxTree(state);
const ranges = state.selection.ranges;
const file = state.field(editorInfoField).file;
const sourcePath = file?.path ?? "";
const settings = resolveSettings(undefined, plugin, file ?? app.vault.getRoot());
const profile = plugin.extraSettings.profiles[settings.profile];
const builder = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from, to,
enter(node) {
if (node.name !== INLINE_CODE) return;
let start = -1;
let end = -1;
let display: string | null = null;
let linktext: string | null = null;
const text = nodeText(node, state);
if (text.startsWith(settings.beginProof)) {
// handle "\begin{proof}"
const rest = text.slice(settings.beginProof.length);
if (!rest) { // only "\begin{proof}"
start = node.from - 1;
end = node.to + 1; // 1 = "`".length
display = null;
} else {
const match = rest.match(/^\[(.*)\]$/);
if (match) { // custom display text is given, e.g. "\begin{proof}[Solutions.]"
start = node.from - 1;
end = node.to + 1; // 1 = "`".length
display = match[1];
}
}
if (start === -1 || end === -1) return; // not proof
if (state.sliceDoc(node.to + 1, node.to + 2) == "@") { // Check if "`\begin{proof}`@[[link]]" or "`\begin{proof}[display]`@[[link]]"
const next = node.node.nextSibling?.nextSibling;
const afterNext = node.node.nextSibling?.nextSibling?.nextSibling;
const afterAfterNext = node.node.nextSibling?.nextSibling?.nextSibling?.nextSibling;
if (next?.name === LINK_BEGIN && afterNext?.name === LINK && afterAfterNext?.name === LINK_END) {
linktext = nodeText(afterNext, state);
end = afterAfterNext.to;
}
}
if (!rangesHaveOverlap(ranges, start, end)) {
builder.add(
start, end,
Decoration.replace({
widget: new BeginProofWidget(plugin, profile, display, linktext, sourcePath)
})
);
}
} else if (text === settings.endProof) {
// handle "\end{proof}"
start = node.from - 1;
end = node.to + 1; // 1 = "`".length
if (!rangesHaveOverlap(ranges, start, end)) {
builder.add(
start, end,
Decoration.replace({
widget: new EndProofWidget(plugin, profile)
})
);
}
}
}
});
}
return builder.finish();
}
}, {
decorations: instance => instance.decorations
});
// export const proofFoldFactory = (plugin: LatexReferencer) => foldService.of((state: EditorState, lineStart: number, lineEnd: number) => {
// const positions = state.field(plugin.proofPositionField);
// for (const pos of positions) {
// if (pos.begin && pos.end && lineStart <= pos.begin.from && pos.begin.to <= lineEnd) {
// return { from: pos.begin.to, to: pos.end.to };
// }
// }
// return null;
// });

120
src/proof/reading-view.ts Normal file
View file

@ -0,0 +1,120 @@
import LatexReferencer from "main";
import { App, MarkdownPostProcessorContext, MarkdownRenderChild, TFile } from "obsidian";
import { resolveSettings } from "utils/plugin";
import { makeProofClasses, makeProofElement } from "./common";
import { renderMarkdown } from "utils/render";
import { Profile } from "settings/profile";
export const createProofProcessor = (plugin: LatexReferencer) => (element: HTMLElement, context: MarkdownPostProcessorContext) => {
if (!plugin.extraSettings.enableProof) return;
const { app } = plugin;
const file = app.vault.getAbstractFileByPath(context.sourcePath);
if (!(file instanceof TFile)) return;
const settings = resolveSettings(undefined, plugin, file);
const codes = element.querySelectorAll<HTMLElement>("code");
for (const code of codes) {
const text = code.textContent;
if (!text) continue;
if (text.startsWith(settings.beginProof)) {
const rest = text.slice(settings.beginProof.length);
let displayMatch;
if (!rest) {
context.addChild(new ProofRenderer(app, plugin, code, "begin", file));
} else if (displayMatch = rest.match(/^\[(.*)\]$/)) {
const display = displayMatch[1];
context.addChild(new ProofRenderer(app, plugin, code, "begin", file, display));
}
} else if (code.textContent == settings.endProof) {
context.addChild(new ProofRenderer(app, plugin, code, "end", file));
}
}
};
function parseAtSignLink(codeEl: HTMLElement) {
const next = codeEl.nextSibling;
const afterNext = next?.nextSibling;
const afterAfterNext = afterNext?.nextSibling;
if (afterNext) {
if (next.nodeType == Node.TEXT_NODE && next.textContent == "@"
&& afterNext instanceof HTMLElement && afterNext.matches("a.original-internal-link")
&& afterAfterNext instanceof HTMLElement && afterAfterNext.matches("a.mathLink-internal-link")) {
return { atSign: next, links: [afterNext, afterAfterNext] };
}
}
}
export class ProofRenderer extends MarkdownRenderChild {
atSignParseResult: { atSign: ChildNode, links: HTMLElement[] } | undefined;
constructor(public app: App, public plugin: LatexReferencer, containerEl: HTMLElement, public which: "begin" | "end", public file: TFile, public display?: string) {
super(containerEl);
this.atSignParseResult = parseAtSignLink(this.containerEl);
}
onload(): void {
this.update();
this.registerEvent(
this.plugin.indexManager.on("local-settings-updated", (file) => {
if (file == this.file) {
this.update();
}
})
);
this.registerEvent(
this.plugin.indexManager.on("global-settings-updated", () => {
this.update();
})
);
}
update(): void {
const settings = resolveSettings(undefined, this.plugin, this.file);
const profile = this.plugin.extraSettings.profiles[settings.profile];
/**
* `\begin{proof}`@[[<link to Theorem 1>]] => Proof of Theorem 1.
*/
if (this.atSignParseResult) {
const { atSign, links } = this.atSignParseResult;
const newEl = createSpan({ cls: makeProofClasses(this.which, profile) });
newEl.replaceChildren(profile.body.proof.linkedBeginPrefix, ...links, profile.body.proof.linkedBeginSuffix);
this.containerEl.replaceWith(newEl);
this.containerEl = newEl;
atSign.textContent = "";
return;
}
/**
* `\begin{proof}[Foo.]` => Foo.
*/
if (this.display) {
this.renderDisplay(profile);
return;
}
/**
* `\begin{proof}` => Proof.
*/
const newEl = makeProofElement(this.which, profile);
this.containerEl.replaceWith(newEl);
this.containerEl = newEl;
}
async renderDisplay(profile: Profile) {
if (this.display) {
const children = await renderMarkdown(this.display, this.file.path, this.plugin);
if (children) {
const el = createSpan({ cls: makeProofClasses(this.which, profile) });
el.replaceChildren(...children);
this.containerEl.replaceWith(el);
this.containerEl = el;
}
}
}
}

311
src/search/core.ts Normal file
View file

@ -0,0 +1,311 @@
import { App, EditorSuggestContext, Instruction, Notice, Scope, SearchResult, TFile, finishRenderMath, prepareFuzzySearch, prepareSimpleSearch, renderMath, sortSearchResults } from 'obsidian';
import LatexReferencer from 'main';
import { MathIndex } from 'index/math-index';
import { EquationBlock, MarkdownBlock, MarkdownPage, MathBlock, TheoremCalloutBlock } from 'index/typings/markdown';
import { getFileTitle } from 'index/utils/normalizers';
import { LEAF_OPTION_TO_ARGS } from 'settings/settings';
import { formatLabel } from 'utils/format';
import { getModifierNameInPlatform, openFileAndSelectPosition } from 'utils/obsidian';
import { insertBlockIdIfNotExist, resolveSettings } from 'utils/plugin';
import { MathSearchModal } from './modal';
import { renderTextWithMath } from 'utils/render';
export type ScoredMathBlock = { match: SearchResult, block: MathBlock };
export type SearchRange = 'active' | 'vault' | 'recent' | 'dataview';
export type QueryType = 'theorem' | 'equation' | 'both';
export type MathSearchCoreCreator = (parent: SuggestParent) => MathSearchCore;
export interface SuggestParent {
app: App;
plugin: LatexReferencer;
scope: Scope;
range: SearchRange;
queryType: QueryType;
dvQuery: string;
getContext(): Omit<EditorSuggestContext, 'query'> | null;
setInstructions(instructions: Instruction[]): void;
getSelectedItem(): MathBlock;
}
export abstract class MathSearchCore {
app: App;
plugin: LatexReferencer;
index: MathIndex;
scope: Scope;
constructor(public parent: SuggestParent) {
this.plugin = parent.plugin;
this.app = this.plugin.app;
this.index = this.plugin.indexManager.index;
this.scope = parent.scope;
}
static getCore(parent: SuggestParent): MathSearchCore | null {
const { app, range, queryType, dvQuery } = parent;
if (range === 'dataview') {
const dv = app.plugins.plugins.dataview?.api;
if (!dv) return null;
return new DataviewQuerySearchCore(parent, queryType, dv, dvQuery);
}
if (range === 'vault') {
if (queryType === 'both') return new WholeVaultTheoremEquationSearchCore(parent);
else if (queryType === 'theorem') return new WholeVaultTheoremSearchCore(parent);
else if (queryType === 'equation') return new WholeVaultEquationSearchCore(parent);
} else if (range === 'recent') return new RecentNotesSearchCore(parent, queryType);
else if (range === 'active') return new ActiveNoteSearchCore(parent, queryType);
return null;
}
setScope() {
// Mod (by default) + Enter to jump to the selected item
this.scope.register([this.plugin.extraSettings.modifierToJump], "Enter", () => {
const context = this.parent.getContext();
if (context) {
const { editor, start, end } = context;
editor.replaceRange("", start, end);
}
const item = this.parent.getSelectedItem();
const file = this.app.vault.getAbstractFileByPath(item.$file); // the file containing the selected item
if (!(file instanceof TFile)) return;
openFileAndSelectPosition(this.app, file, item.$pos, ...LEAF_OPTION_TO_ARGS[this.plugin.extraSettings.suggestLeafOption]);
if (this.parent instanceof MathSearchModal) this.parent.close();
return false;
});
// Shift (by default) + Enter to insert a link to the note containing the selected item
this.scope.register([this.plugin.extraSettings.modifierToNoteLink], "Enter", () => {
const item = this.parent.getSelectedItem();
this.selectSuggestionImpl(item, true);
if (this.parent instanceof MathSearchModal) this.parent.close();
return false;
});
if (this.plugin.extraSettings.showModifierInstruction) {
this.parent.setInstructions([
{ command: "↑↓", purpose: "to navigate" },
{ command: "↵", purpose: "to insert link" },
{ command: `${getModifierNameInPlatform(this.plugin.extraSettings.modifierToNoteLink)} + ↵`, purpose: "to insert link to note" },
{ command: `${getModifierNameInPlatform(this.plugin.extraSettings.modifierToJump)} + ↵`, purpose: "to jump" },
]);
}
}
async getSuggestions(query: string): Promise<MathBlock[]> {
const ids = await this.getUnsortedSuggestions();
const results = this.gradeSuggestions(ids, query);
this.postProcessResults(results);
sortSearchResults(results);
return results.map((result) => result.block);
}
abstract getUnsortedSuggestions(): Promise<Array<string>> | Promise<Set<string>>;
postProcessResults(results: ScoredMathBlock[]) { }
gradeSuggestions(ids: Array<string> | Set<string>, query: string) {
const callback = (this.plugin.extraSettings.searchMethod == "Fuzzy" ? prepareFuzzySearch : prepareSimpleSearch)(query);
const results: ScoredMathBlock[] = [];
for (const id of ids) {
const block = this.index.load(id) as MathBlock;
let text = `${block.$printName} ${block.$file}}`;
if (block.$type === "theorem") {
text += ` ${(block as TheoremCalloutBlock).$settings.type}`;
if (this.plugin.extraSettings.searchLabel) {
const file = this.app.vault.getAbstractFileByPath(block.$file);
if (file instanceof TFile) {
const resolvedSettings = resolveSettings((block as TheoremCalloutBlock).$settings, this.plugin, file);
text += ` ${formatLabel(resolvedSettings) ?? ""}`
}
}
} else if (block.$type === "equation") {
text += " " + (block as EquationBlock).$mathText;
}
// run search
const result = callback(text);
if (result) {
results.push({ match: result, block });
}
}
return results;
}
renderSuggestion(block: MathBlock, el: HTMLElement): void {
const baseEl = el.createDiv({ cls: "math-booster-search-item" });
if (block.$printName) {
const children = renderTextWithMath(block.$printName);
baseEl.createDiv()
.replaceChildren(...children);
}
const smallEl = baseEl.createEl(
"small", {
text: `${getFileTitle(block.$file)}, line ${block.$position.start + 1}`,
cls: "math-booster-search-item-description"
});
if (block.$type === "equation") {
if (this.plugin.extraSettings.renderMathInSuggestion) {
const mjxContainerEl = renderMath((block as EquationBlock).$mathText, true);
baseEl.insertBefore(mjxContainerEl, smallEl);
} else {
const mathTextEl = createDiv({ text: (block as EquationBlock).$mathText });
baseEl.insertBefore(mathTextEl, smallEl);
}
}
}
selectSuggestion(item: MathBlock, evt: MouseEvent | KeyboardEvent): void {
this.selectSuggestionImpl(item, false);
// I don't know when to call finishRenderMath...
finishRenderMath();
}
async selectSuggestionImpl(block: MathBlock, insertNoteLink: boolean): Promise<void> {
const context = this.parent.getContext();
if (!context) return;
const fileContainingBlock = this.app.vault.getAbstractFileByPath(block.$file);
const cache = this.app.metadataCache.getCache(block.$file);
if (!(fileContainingBlock instanceof TFile) || !cache) return;
const { editor, start, end, file } = context;
const settings = resolveSettings(undefined, this.plugin, file);
let success = false;
const result = await insertBlockIdIfNotExist(this.plugin, fileContainingBlock, cache, block);
if (result) {
const { id, lineAdded } = result;
// We can't use FileManager.generateMarkdownLink here.
// This is because, when the user is turning off "Use [[Wikilinks]]",
// FileManager.generateMarkdownLink inserts a markdown link [](), not a wikilink [[]].
// Markdown links are hard to deal with for the purpose of this plugin, and also
// MathLinks has some issues with markdown links (https://github.com/zhaoshenzhai/obsidian-mathlinks/issues/47).
// So we have to explicitly generate a wikilink here.
let linktext = "";
if (fileContainingBlock != file) {
linktext += this.app.metadataCache.fileToLinktext(fileContainingBlock, file.path);
}
if (!insertNoteLink) {
linktext += `#^${id}`;
}
const link = `[[${linktext}]]`
const insertText = link + (settings.insertSpace ? " " : "");
if (fileContainingBlock == file) {
editor.replaceRange(
insertText,
{ line: start.line + lineAdded, ch: start.ch },
{ line: end.line + lineAdded, ch: end.ch }
);
} else {
editor.replaceRange(insertText, start, end);
}
success = true;
}
if (!success) {
new Notice(`${this.plugin.manifest.name}: Failed to read cache. Retry again later.`, 5000);
}
}
}
abstract class WholeVaultSearchCore extends MathSearchCore {
postProcessResults(results: ScoredMathBlock[]) {
results.forEach((result) => {
if (this.app.workspace.getLastOpenFiles().contains(result.block.$file)) {
result.match.score += this.plugin.extraSettings.upWeightRecent;
}
});
}
}
export class WholeVaultTheoremEquationSearchCore extends WholeVaultSearchCore {
async getUnsortedSuggestions(): Promise<Set<string>> {
return this.index.getByType('block-math-booster');
}
}
export class WholeVaultTheoremSearchCore extends WholeVaultSearchCore {
async getUnsortedSuggestions(): Promise<Set<string>> {
return this.index.getByType('block-theorem');
}
}
export class WholeVaultEquationSearchCore extends WholeVaultSearchCore {
async getUnsortedSuggestions(): Promise<Set<string>> {
return this.index.getByType('block-equation');
}
}
/** Suggest theorems and/or equations from the given set of files. */
export abstract class PartialSearchCore extends MathSearchCore {
constructor(parent: SuggestParent, public type: QueryType) {
super(parent);
}
abstract getPaths(): Promise<Array<string>>;
filterBlock(block: MarkdownBlock): boolean {
if (this.type === 'theorem') return TheoremCalloutBlock.isTheoremCalloutBlock(block);
if (this.type === 'equation') return EquationBlock.isEquationBlock(block);
return MathBlock.isMathBlock(block);
}
async getUnsortedSuggestions() {
const ids: string[] = [];
const pages = (await this.getPaths()).map((path) => this.index.load(path));
for (const page of pages) {
if (!MarkdownPage.isMarkdownPage(page)) continue;
for (const section of page.$sections) {
for (const block of section.$blocks) {
if (this.filterBlock(block)) ids.push(block.$id);
}
}
}
return ids;
}
}
export class RecentNotesSearchCore extends PartialSearchCore {
async getPaths() {
return this.app.workspace.getLastOpenFiles();
}
}
export class ActiveNoteSearchCore extends PartialSearchCore {
async getPaths() {
const path = this.app.workspace.getActiveFile()?.path;
return path?.endsWith('.md') ? [path] : [];
}
}
export class DataviewQuerySearchCore extends PartialSearchCore {
public dvQuery: string;
constructor(parent: SuggestParent, type: 'theorem' | 'equation' | 'both', public dv: any, dvQuery?: string) {
super(parent, type);
this.dvQuery = dvQuery ?? '';
}
async getPaths() {
const result = await this.dv.query(this.dvQuery);
if (result.successful && result.value.type === 'list') {
const links = result.value.values as { path: string }[];
return links.map((link) => link.path);
}
return [];
}
}

View file

@ -0,0 +1,120 @@
import { Editor, EditorPosition, EditorSuggest, EditorSuggestContext, EditorSuggestTriggerInfo, Keymap, UserEvent } from "obsidian";
import LatexReferencer from "main";
import { MathBlock } from "index/typings/markdown";
import { MathSearchCore, SuggestParent, WholeVaultTheoremEquationSearchCore } from "./core";
import { QueryType, SearchRange } from './core';
export class LinkAutocomplete extends EditorSuggest<MathBlock> implements SuggestParent {
queryType: QueryType;
range: SearchRange;
core: MathSearchCore;
triggers: Map<string, { range: SearchRange, queryType: QueryType }>;
/**
* @param type The type of the block to search for. See: index/typings/markdown.ts
*/
constructor(public plugin: LatexReferencer) {
super(plugin.app);
this.setTriggers();
this.core = new WholeVaultTheoremEquationSearchCore(this);
this.core.setScope();
}
get dvQuery(): string {
return this.plugin.extraSettings.autocompleteDvQuery;
}
getContext() {
return this.context;
}
getSelectedItem() {
// Reference: https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/be4a12c3f861c31f2be3c0f81809cfc5ab6bb5fd/src/ui/AutoCompleteSuggest.ts#L595-L619
return this.suggestions.values[this.suggestions.selectedItem];
}
onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo | null {
for (const [trigger, { range, queryType }] of this.triggers) {
const text = editor.getLine(cursor.line);
const index = text.lastIndexOf(trigger);
if (index >= 0) {
const query = text.slice(index + trigger.length);
if (query.startsWith("[[")) return null; // avoid conflict with the built-in link auto-completion
this.queryType = queryType;
this.range = range;
const core = MathSearchCore.getCore(this);
if (!core) return null;
this.core = core;
this.limit = this.plugin.extraSettings.suggestNumber;
return {
start: { line: cursor.line, ch: index },
end: cursor,
query
};
}
}
return null;
}
getSuggestions(context: EditorSuggestContext): Promise<MathBlock[]> {
return this.core.getSuggestions(context.query);
}
renderSuggestion(block: MathBlock, el: HTMLElement): void {
this.core.renderSuggestion(block, el);
}
selectSuggestion(item: MathBlock, evt: MouseEvent | KeyboardEvent): void {
this.core.selectSuggestion(item, evt);
}
setTriggers() {
const unsortedTriggers = {} as Record<string, { range: SearchRange, queryType: QueryType }>;
if (this.plugin.extraSettings.enableSuggest) {
unsortedTriggers[this.plugin.extraSettings.triggerSuggest] = { range: "vault", queryType: "both" };
}
if (this.plugin.extraSettings.enableTheoremSuggest) {
unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggest] = { range: "vault", queryType: "theorem" };
}
if (this.plugin.extraSettings.enableEquationSuggest) {
unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggest] = { range: "vault", queryType: "equation" };
}
if (this.plugin.extraSettings.enableSuggestRecentNotes) {
unsortedTriggers[this.plugin.extraSettings.triggerSuggestRecentNotes] = { range: "recent", queryType: "both" };
}
if (this.plugin.extraSettings.enableTheoremSuggestRecentNotes) {
unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestRecentNotes] = { range: "recent", queryType: "theorem" };
}
if (this.plugin.extraSettings.enableEquationSuggestRecentNotes) {
unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestRecentNotes] = { range: "recent", queryType: "equation" };
}
if (this.plugin.extraSettings.enableSuggestActiveNote) {
unsortedTriggers[this.plugin.extraSettings.triggerSuggestActiveNote] = { range: "active", queryType: "both" };
}
if (this.plugin.extraSettings.enableTheoremSuggestActiveNote) {
unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestActiveNote] = { range: "active", queryType: "theorem" };
}
if (this.plugin.extraSettings.enableEquationSuggestActiveNote) {
unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestActiveNote] = { range: "active", queryType: "equation" };
}
if (this.plugin.extraSettings.enableSuggestDataview) {
unsortedTriggers[this.plugin.extraSettings.triggerSuggestDataview] = { range: "dataview", queryType: "both" };
}
if (this.plugin.extraSettings.enableTheoremSuggestDataview) {
unsortedTriggers[this.plugin.extraSettings.triggerTheoremSuggestDataview] = { range: "dataview", queryType: "theorem" };
}
if (this.plugin.extraSettings.enableEquationSuggestDataview) {
unsortedTriggers[this.plugin.extraSettings.triggerEquationSuggestDataview] = { range: "dataview", queryType: "equation" };
}
const sortedTriggers = new Map<string, { range: SearchRange, queryType: QueryType }>;
Object.entries(unsortedTriggers)
.sort((a, b) => b[0].length - a[0].length) // sort by descending order of trigger length
.forEach(([k, v]) => sortedTriggers.set(k, v));
this.triggers = sortedTriggers;
}
}

149
src/search/modal.ts Normal file
View file

@ -0,0 +1,149 @@
import { DataviewQuerySearchCore, QueryType, SearchRange, WholeVaultTheoremEquationSearchCore } from 'search/core';
import LatexReferencer from "main";
import { App, EditorSuggestContext, MarkdownView, Setting, SuggestModal, TextAreaComponent } from "obsidian";
import { MathSearchCore, SuggestParent } from "./core";
import { MathBlock } from "index/typings/markdown";
export class MathSearchModal extends SuggestModal<MathBlock> implements SuggestParent {
app: App;
core: MathSearchCore;
queryType: QueryType;
range: SearchRange;
dvQueryField: Setting;
topEl: HTMLElement;
constructor(public plugin: LatexReferencer) {
super(plugin.app);
this.app = plugin.app;
this.core = new WholeVaultTheoremEquationSearchCore(this);
this.core.setScope();
this.setPlaceholder('Type here...');
this.queryType = this.plugin.extraSettings.searchModalQueryType;
this.range = this.plugin.extraSettings.searchModalRange;
this.topEl = this.modalEl.createDiv({ cls: 'math-booster-modal-top' });
this.modalEl.insertBefore(this.topEl, this.modalEl.firstChild)
this.inputEl.addClass('math-booster-search-input');
this.limit = this.plugin.extraSettings.suggestNumber;
new Setting(this.topEl)
.setName('Query type')
.addDropdown((dropdown) => {
dropdown.addOption('both', 'Theorems and equations')
.addOption('theorem', 'Theorems')
.addOption('equation', 'Equations');
// recover the last state
dropdown.setValue(this.plugin.extraSettings.searchModalQueryType)
dropdown.onChange((value: QueryType) => {
this.queryType = value;
this.resetCore();
// @ts-ignore
this.onInput();
// remember the last state
this.plugin.extraSettings.searchModalQueryType = value;
this.plugin.saveSettings();
})
});
new Setting(this.topEl)
.setName('Search range')
.addDropdown((dropdown) => {
dropdown.addOption('vault', 'Vault')
.addOption('recent', 'Recent notes')
.addOption('active', 'Active note')
.addOption('dataview', 'Dataview query');
// recover the last state
dropdown.setValue(this.plugin.extraSettings.searchModalRange)
dropdown.onChange((value: SearchRange) => {
this.range = value;
this.resetCore();
// @ts-ignore
this.onInput();
// remember the last state
this.plugin.extraSettings.searchModalRange = value;
this.plugin.saveSettings();
})
});
this.dvQueryField = new Setting(this.topEl)
.setName('Dataview query')
.setDesc('Only LIST queries are supported.')
.then(setting => {
setting.controlEl.style.width = '60%';
})
.addTextArea((text) => {
text.inputEl.addClass('math-booster-dv-query')
text.inputEl.style.width = '100%';
text.setValue(this.plugin.extraSettings.searchModalDvQuery) // recover the last state
.setPlaceholder('LIST ...')
.onChange((dvQuery) => {
if (this.core instanceof DataviewQuerySearchCore) {
this.core.dvQuery = dvQuery;
// @ts-ignore
this.onInput();
// remember the last state
this.plugin.extraSettings.searchModalDvQuery = dvQuery;
this.plugin.saveSettings();
}
})
});
this.modalEl.insertBefore(this.inputEl, this.modalEl.firstChild);
this.resetCore();
}
resetCore() {
const core = MathSearchCore.getCore(this);
if (core) this.core = core;
if (this.range === 'dataview') {
if (!core) {
this.dvQueryField.setDisabled(true);
this.dvQueryField.setDesc('Retry after enabling Dataview.')
.then(setting => setting.descEl.style.color = '#ea5555');
}
this.dvQueryField.settingEl.show();
}
else this.dvQueryField.settingEl.hide();
}
get dvQuery(): string {
return (this.dvQueryField.components[0] as TextAreaComponent).getValue();
}
getContext(): Omit<EditorSuggestContext, 'query'> | null {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view?.file) return null;
const start = view.editor.getCursor('from');
const end = view.editor.getCursor('to');
return { file: view.file, editor: view.editor, start, end }
}
getSelectedItem(): MathBlock {
return this.chooser.values![this.chooser.selectedItem];
};
getSuggestions(query: string) {
return this.core.getSuggestions(query);
}
renderSuggestion(value: MathBlock, el: HTMLElement) {
this.core.renderSuggestion(value, el);
}
onChooseSuggestion(item: MathBlock, evt: MouseEvent | KeyboardEvent) {
this.core.selectSuggestion(item, evt);
}
}

545
src/settings/helper.ts Normal file
View file

@ -0,0 +1,545 @@
import { ButtonComponent, Setting, SliderComponent, TAbstractFile, TFile, TFolder, TextComponent, ToggleComponent, MarkdownRenderer, Component } from 'obsidian';
import LatexReferencer from 'main';
import { THEOREM_LIKE_ENV_IDs, THEOREM_LIKE_ENVs, TheoremLikeEnvID } from 'env';
import { DEFAULT_SETTINGS, ExtraSettings, LEAF_OPTIONS, THEOREM_REF_FORMATS, THEOREM_CALLOUT_STYLES, TheoremCalloutSettings, MathContextSettings, NUMBER_STYLES, FoldOption, DEFAULT_EXTRA_SETTINGS } from 'settings/settings';
import { formatTheoremCalloutType } from 'utils/format';
import { NumberKeys, BooleanKeys } from 'utils/general';
import { DEFAULT_PROFILES, ManageProfileModal } from './profile';
export class TheoremCalloutSettingsHelper {
constructor(
public contentEl: HTMLElement,
public settings: TheoremCalloutSettings,
public defaultSettings: Required<MathContextSettings> & Partial<TheoremCalloutSettings>,
public plugin: LatexReferencer,
public file: TFile,
) { }
makeSettingPane() {
const { contentEl } = this;
new Setting(contentEl)
.setName("Type")
.addDropdown((dropdown) => {
for (const id of THEOREM_LIKE_ENV_IDs) {
const envName = formatTheoremCalloutType(this.plugin, { type: id, profile: this.defaultSettings.profile })
dropdown.addOption(id, envName);
if (this.defaultSettings.type) {
dropdown.setValue(String(this.defaultSettings.type));
}
}
const initType = dropdown.getValue();
this.settings.type = initType;
const numberSetting = new Setting(contentEl).setName("Number");
const numberSettingDescList = numberSetting.descEl.createEl("ul");
numberSettingDescList.createEl(
"li",
{ text: '"auto" - automatically numbered' }
);
numberSettingDescList.createEl(
"li",
{ text: "blank - unnumbered" }
);
numberSettingDescList.createEl(
"li",
{ text: "otherwise - used as is" }
);
numberSetting.addText((text) => {
text.setValue(
this.defaultSettings.number ?? this.defaultSettings.numberDefault
);
this.settings.number = text.getValue();
text.onChange((value) => {
this.settings.number = value;
});
})
const titlePane = new Setting(contentEl)
.setName("Title")
.setDesc("You can include inline math in the title.");
const labelPane = this.plugin.extraSettings.setLabelInModal ? new Setting(contentEl).setName("Pandoc label") : undefined;
const labelPrefixEl = labelPane?.controlEl.createDiv({
text: THEOREM_LIKE_ENVs[this.settings.type as TheoremLikeEnvID].prefix + ":" + (this.defaultSettings.labelPrefix ?? "")
});
titlePane.addText((text) => {
text.inputEl.classList.add("math-booster-title-form");
if (this.defaultSettings.title) {
text.setValue(this.defaultSettings.title);
}
let labelTextComp: TextComponent | undefined;
labelPane?.addText((text) => {
labelTextComp = text;
text.inputEl.classList.add("math-booster-label-form");
if (this.defaultSettings.label) {
text.setValue(this.defaultSettings.label);
}
text.onChange((value) => {
this.settings.label = value;
});
});
text
.setPlaceholder("Ex) $\\sigma$-algebra")
.onChange((value) => {
this.settings.title = value;
if (this.plugin.extraSettings.setLabelInModal) {
let labelInit = this.settings.title.replaceAll(' ', '-').replaceAll("'s", '').toLowerCase();
labelInit = labelInit.replaceAll(/[^a-z0-1\-]/g, '');
labelTextComp?.setValue(labelInit);
this.settings.label = labelInit;
}
})
});
dropdown.onChange((value) => {
this.settings.type = value;
if (labelPrefixEl) {
labelPrefixEl.textContent = THEOREM_LIKE_ENVs[this.settings.type as TheoremLikeEnvID].prefix + ":";
if (this.defaultSettings.labelPrefix) {
labelPrefixEl.textContent += this.defaultSettings.labelPrefix;
}
}
});
});
addFoldOptionSetting(contentEl, 'Collapse', (fold) => { this.settings.fold = fold }, this.defaultSettings.fold ?? this.plugin.extraSettings.foldDefault);
}
}
export abstract class SettingsHelper<SettingsType = MathContextSettings | ExtraSettings> extends Component {
settingRefs: Record<keyof SettingsType, Setting>;
constructor(
public contentEl: HTMLElement,
public settings: Partial<SettingsType>,
public defaultSettings: Required<SettingsType>,
public plugin: LatexReferencer,
public allowUnset: boolean,
public addClear: boolean,
) {
super();
this.settingRefs = {} as Record<keyof SettingsType, Setting>;
}
addClearButton(name: keyof SettingsType, setting: Setting, additionalCallback: () => void) {
setting.addButton((button) => {
button.setButtonText("Clear").onClick(async () => {
delete this.settings[name];
additionalCallback();
})
});
}
addDropdownSetting(name: keyof SettingsType, options: readonly string[], prettyName: string, description?: string, defaultValue?: string, additionalOnChange?: () => void) {
const setting = new Setting(this.contentEl).setName(prettyName);
if (description) {
setting.setDesc(description);
}
setting.addDropdown((dropdown) => {
if (this.allowUnset) {
dropdown.addOption("", "");
}
for (const option of options) {
dropdown.addOption(option, option);
}
dropdown.setValue(
defaultValue ?? (
this.allowUnset
? (this.settings[name] ? this.settings[name] as unknown as string : "")
: (this.settings[name] ?? this.defaultSettings[name]) as unknown as string
)
);
dropdown.onChange(async (value: string): Promise<void> => {
if (this.allowUnset && !value) {
delete this.settings[name];
} else {
Object.assign(this.settings, { [name]: value });
}
additionalOnChange?.();
})
});
this.settingRefs[name] = setting;
return setting;
}
addTextSetting(name: keyof SettingsType, prettyName: string, description?: string, number: boolean = false): Setting {
const setting = new Setting(this.contentEl).setName(prettyName);
if (description) {
setting.setDesc(description);
}
let textComponent: TextComponent;
setting.addText((text) => {
textComponent = text;
text.setPlaceholder(String(this.defaultSettings[name] ?? ""))
.setValue(String(this.settings[name] ?? ""))
.onChange((value: string) => {
if (number) {
Object.assign(this.settings, { [name]: +value });
} else {
Object.assign(this.settings, { [name]: value });
}
})
});
if (this.addClear) {
this.addClearButton(name, setting, () => {
textComponent.setPlaceholder(String(this.defaultSettings[name] ?? "")).setValue("")
});
}
this.settingRefs[name] = setting;
return setting;
}
addToggleSetting(name: BooleanKeys<SettingsType>, prettyName: string, description?: string, additionalOnChange?: () => void): Setting {
const setting = new Setting(this.contentEl).setName(prettyName);
if (description) {
setting.setDesc(description);
}
let toggleComponent: ToggleComponent;
setting.addToggle((toggle) => {
toggleComponent = toggle;
toggle.setValue(this.defaultSettings[name] as unknown as boolean);
if (typeof this.settings[name] == "boolean") {
toggle.setValue(this.settings[name] as unknown as boolean);
}
toggle.onChange((value) => {
Object.assign(this.settings, { [name]: value });
additionalOnChange?.();
});
});
if (this.addClear) {
this.addClearButton(name, setting, () => {
toggleComponent.setValue(this.defaultSettings[name] as unknown as boolean)
});
}
this.settingRefs[name] = setting;
return setting;
}
addSliderSetting(name: NumberKeys<SettingsType>, limits: { min: number, max: number, step: number | 'any' }, prettyName: string, description?: string): Setting {
const setting = new Setting(this.contentEl).setName(prettyName);
if (description) {
setting.setDesc(description);
}
let sliderComponent: SliderComponent;
setting.addSlider((slider) => {
sliderComponent = slider;
slider.setLimits(limits.min, limits.max, limits.step)
.setDynamicTooltip()
.setValue(this.defaultSettings[name] as unknown as number);
if (typeof this.settings[name] == "number") {
slider.setValue(this.settings[name] as unknown as number);
}
slider.onChange((value) => {
Object.assign(this.settings, { [name]: value });
})
});
if (this.addClear) {
this.addClearButton(name, setting, () => {
sliderComponent.setValue(this.defaultSettings[name] as unknown as number);
});
}
this.settingRefs[name] = setting;
return setting;
}
addHeading(text: string, cls?: string[]) {
const setting = new Setting(this.contentEl).setName(text).setHeading();
if (cls) setting.settingEl.classList.add(...cls);
return setting;
}
}
export class MathContextSettingsHelper extends SettingsHelper<MathContextSettings> {
constructor(
contentEl: HTMLElement,
settings: Partial<MathContextSettings>,
defaultSettings: Required<MathContextSettings>,
plugin: LatexReferencer,
public file: TAbstractFile,
) {
const isRoot = file instanceof TFolder && file.isRoot();
super(contentEl, settings, defaultSettings, plugin, !isRoot, !isRoot);
}
onload() {
this.addHeading('Theorem callouts - general');
this.addProfileSetting();
const styleSetting = this.addDropdownSetting("theoremCalloutStyle", THEOREM_CALLOUT_STYLES, "Style", undefined, undefined, () => this.plugin.forceRerender());
styleSetting.descEl.replaceChildren(
"Choose between your custom style and preset styles. You might need to reopen the notes or reload the app to see the changes. See the documentation for how to customize the appearance of theorem callouts. \"Custom\" is recommended, since it will give you the most control. You can view the CSS snippets for all the preset styles in the documentation or README on GitHub. The preset styles are only for a trial purpose, and they might not work well with some non-default themes.",
);
this.addToggleSetting("theoremCalloutFontInherit", "Don't override the app's font setting when using preset styles", "You will need to reload the note to see the changes.");
this.addTextSetting("titleSuffix", "Title suffix", "Ex) \"\" > Definition 2 (Group) / \".\" > Definition 2 (Group).");
this.addTextSetting("labelPrefix", "Pandoc label prefix", 'Useful for ensuring no label collision. Ex) When "Pandoc label prefix" = "foo:", A theorem with "Pandoc label" = "bar" is assigned "thm:foo:bar."');
this.addHeading('Theorem callouts - numbering');
this.addToggleSetting(
"inferNumberPrefix",
"Infer prefix from note title or properties",
`Automatically infer a prefix from the note title or properties. See the documentation (Settings > Prefix inference) for an example.`
);
this.addTextSetting("inferNumberPrefixFromProperty", "Use property as source", "If set, use this property as the source of prefix inference. If not set, the note title will be used as the source.")
this.addTextSetting("inferNumberPrefixRegExp", "Regular expression for parsing");
this.addTextSetting("numberPrefix", "Manual prefix", "Even if \"Infer prefix from note title or properties\" is turned on, the inferred prefix will be overwritten by the value set here.");
this.addTextSetting("numberSuffix", "Suffix");
this.addTextSetting("numberInit", "Initial count");
this.addDropdownSetting("numberStyle", NUMBER_STYLES, "Style");
this.addTextSetting("numberDefault", "Default value for the \"Number\" field of \"Insert theorem callout\" modal");
this.addHeading('Theorem callouts - referencing');
this.addDropdownSetting("refFormat", THEOREM_REF_FORMATS, "Format");
this.addDropdownSetting(
"noteMathLinkFormat",
THEOREM_REF_FORMATS,
'Format for a note that has its "main" theorem callout',
`When a theorem callout is set as main by a markdown comment "%% main %%", this format will be used for links to the note containing that theorem callout.`
);
this.addToggleSetting('ignoreMainTheoremCalloutWithoutTitle', 'Ignore a "main" theorem callout without its own title');
this.addHeading('Equations - numbering', ['equation-heading']);
this.addToggleSetting("numberOnlyReferencedEquations", "Number only referenced equations");
this.addToggleSetting(
"inferEqNumberPrefix",
"Infer prefix from note title or properties",
`Automatically infer a prefix from the note title or properties. See the documentation (Settings > Prefix inference) for an example.`
);
this.addTextSetting("inferEqNumberPrefixFromProperty", "Use property as source", "If set, use this property as the source of prefix inference. If not set, the note title will be used as the source.")
this.addTextSetting("inferEqNumberPrefixRegExp", "Regular expression for parsing");
this.addTextSetting("eqNumberPrefix", "Manual prefix", "Even if \"Infer prefix from note title\" is turned on, the inferred prefix will be overwritten by the value set here.");
this.addTextSetting("eqNumberSuffix", "Suffix");
this.addTextSetting("eqNumberInit", "Initial count");
this.addDropdownSetting("eqNumberStyle", NUMBER_STYLES, "Style");
this.addToggleSetting("lineByLine", "Number line by line in align");
this.addHeading('Equations - referencing');
this.addTextSetting("eqRefPrefix", "Prefix");
this.addTextSetting("eqRefSuffix", "Suffix");
this.addHeading('Proofs (experimental)', ['proof-heading']);
this.addTextSetting("beginProof", "Beginning of a proof");
this.addTextSetting("endProof", "End of a proof");
this.addHeading('Search & link auto-completion - general')
.then(async (setting) => {
setting.descEl.addClass('math-booster-new-feature');
await MarkdownRenderer.render(this.plugin.app, '**NOTE:** If you have the [**Quick Preview**](https://github.com/RyotaUshio/obsidian-quick-preview) plugin installed, holding down `Alt`/`Option` _(by default)_ will trigger a quick preview of the selected suggestion with the context around it.', setting.descEl, '', this);
})
this.addToggleSetting("insertSpace", "Append whitespace after inserted link");
}
addProfileSetting(defaultValue?: string): Setting {
const profileSetting = this.addDropdownSetting("profile", Object.keys(this.plugin.extraSettings.profiles), "Profile", "A profile defines the displayed name of each environment.", defaultValue);
new ButtonComponent(profileSetting.controlEl)
.setButtonText("Manage profiles")
.onClick(() => {
new ManageProfileModal(this.plugin, this, profileSetting).open();
});
profileSetting.controlEl.classList.add("math-booster-profile-setting");
return profileSetting;
}
}
export class ExtraSettingsHelper extends SettingsHelper<ExtraSettings> {
onload(): void {
this.settingRefs["foldDefault"] = addFoldOptionSetting(
this.contentEl, 'Default collapsibility when using the "Insert theorem callout" command', (fold) => {
this.settings.foldDefault = fold;
}, this.defaultSettings.foldDefault);
this.addToggleSetting("noteTitleInTheoremLink", "Show the note title at the head of a link to a theorem", "If turned on, a link to \"Theorem 1\" will look like \"Note title > Theorem 1\".");
this.addToggleSetting("noteTitleInEquationLink", "Show the note title at the head of a link to an equation", "If turned on, a link to \"Eq.(1)\" will look like \"Note title > Eq.(1)\".");
this.addToggleSetting("excludeExampleCallout", 'Don\'t treat "> [!example]" as a theorem callout', 'If turned on, a callout of the form "> [!example]" will be treated as Obsidian\'s built-in "Example" callout, and you will need to type "> [!exm]" instead to insert a theorem callout of "Example" type.');
this.addToggleSetting("showTheoremCalloutEditButton", "Show an edit button on a theorem callout");
this.addToggleSetting("setOnlyTheoremAsMain", "If a note has only one theorem callout, automatically set it as main", 'Regardless of this setting, putting "%% main %%" or "%% main: true %%" in a theorem callout will set it as main one of the note, which means any link to that note will be displayed with the theorem\'s title. Enabling this option implicitly sets a theorem callout as main when it\'s the only one in the note.');
this.addToggleSetting("setLabelInModal", "Show LaTeX/Pandoc label input form in theorem callout insert/edit modal");
this.addToggleSetting("enableProof", "Enable proof environment", `For example, you can replace a pair of inline codes \`${DEFAULT_SETTINGS.beginProof}\` & \`${DEFAULT_SETTINGS.endProof}\` with \"${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.begin}\" & \"${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.end}\". You can style it with CSS snippets. See the documentation for the details.`, () => this.plugin.updateEditorExtensions());
// Suggest
this.addSliderSetting("suggestNumber", { min: 1, max: 50, step: 1 }, "Number of suggestions", "Specify how many items are suggested at one time. Set it to a smaller value if you have a performance issue when equation suggestions with math rendering on.");
this.addToggleSetting("renderMathInSuggestion", "Render math in equation suggestions", "Turn this off if you have a performance issue and reducing the number of suggestions doesn't fix it.");
this.addDropdownSetting("searchMethod", ["Fuzzy", "Simple"], "Search method", "Fuzzy search is more flexible, but simple search is lighter-weight.");
this.addToggleSetting("searchLabel", "Include theorem callout label for search target");
this.addSliderSetting("upWeightRecent", { min: 0, max: 0.5, step: 0.01 }, "Up-weight recently opened notes by", "It takes effect only if \"Search only recently opened notes\" is turned off.");
this.addDropdownSetting("modifierToJump", ['Mod', 'Ctrl', 'Meta', 'Shift', 'Alt'], "Modifier key for jumping to suggestion", "Press Enter and this modifier key to jump to the currently selected suggestion. Changing this option requires to reloading " + this.plugin.manifest.name + " to take effect.");
this.addDropdownSetting("modifierToNoteLink", ['Mod', 'Ctrl', 'Meta', 'Shift', 'Alt'], "Modifier key for insert link to note", "Press Enter and this modifier key to insert a link to the note containing the currently selected item. Changing this option requires to reloading " + this.plugin.manifest.name + " to take effect.");
this.addToggleSetting("showModifierInstruction", "Show modifier key instruction", "Show the instruction for the modifier key at the bottom of suggestion box. " + `Changing this option requires to reloading ${this.plugin.manifest.name} to take effect.`);
const list = this.settingRefs.modifierToJump.descEl.createEl("ul");
list.createEl("li", { text: "Mod is Cmd on MacOS and Ctrl on other OS." });
list.createEl("li", { text: "Meta is Cmd on MacOS and Win key on Windows." });
this.addDropdownSetting("suggestLeafOption", LEAF_OPTIONS, "Opening option", "Specify how to open the selected suggestion.")
this.addHeading('Enhance Obsidian\'s built-in link auto-completion (experimental)')
.setDesc('Configure how this plugin modifies the appearance of Obsidian\'s built-in link auto-completion (the one that pops up when you type "[["). This feature dives deep into Obsidian\'s internals, so it might break when Obsidian is updated. If you encounter any issue, please report it on GitHub.');
this.addToggleSetting("showTheoremTitleinBuiltin", "Show theorem title");
this.addToggleSetting("showTheoremContentinBuiltin", "Show theorem content", "Only effective when \"Show theorem title\" is turned on.");
this.addHeading('Configure this plugin\'s custom editor link auto-completion')
.setDesc(`It is recommended to turn off unnecessary auto-completions to improve performance.`);
this.addTextSetting("autocompleteDvQuery", "Dataview query for editor link auto-completion", "Only LIST queries are supported.");
this.addHeading('Theorem & equation suggestion')
this.addHeading("From entire vault", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableSuggest", "Enable");
this.addTextSetting("triggerSuggest", "Trigger");
this.addHeading("From recently opened notes", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableSuggestRecentNotes", "Enable");
this.addTextSetting("triggerSuggestRecentNotes", "Trigger");
this.addHeading("From active note", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableSuggestActiveNote", "Enable");
this.addTextSetting("triggerSuggestActiveNote", "Trigger");
this.addHeading("From Dataview query", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableSuggestDataview", "Enable");
this.addTextSetting("triggerSuggestDataview", "Trigger");
this.addHeading('Theorem suggestion', ['editor-suggest-setting-heading']);
this.addHeading("From entire vault", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableTheoremSuggest", "Enable");
this.addTextSetting("triggerTheoremSuggest", "Trigger");
this.addHeading("From recently opened notes", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableTheoremSuggestRecentNotes", "Enable");
this.addTextSetting("triggerTheoremSuggestRecentNotes", "Trigger");
this.addHeading("From active note", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableTheoremSuggestActiveNote", "Enable");
this.addTextSetting("triggerTheoremSuggestActiveNote", "Trigger");
this.addHeading("From Dataview query", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableTheoremSuggestDataview", "Enable");
this.addTextSetting("triggerTheoremSuggestDataview", "Trigger");
this.addHeading('Equation suggestion', ['editor-suggest-setting-heading'])
this.addHeading("From entire vault", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableEquationSuggest", "Enable");
this.addTextSetting("triggerEquationSuggest", "Trigger");
this.addHeading("From recently opened notes", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableEquationSuggestRecentNotes", "Enable");
this.addTextSetting("triggerEquationSuggestRecentNotes", "Trigger");
this.addHeading("From active note", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableEquationSuggestActiveNote", "Enable");
this.addTextSetting("triggerEquationSuggestActiveNote", "Trigger");
this.addHeading("From Dataview query", ['editor-suggest-setting-indented-heading']);
this.addToggleSetting("enableEquationSuggestDataview", "Enable");
this.addTextSetting("triggerEquationSuggestDataview", "Trigger");
// projects
// this.addTextSetting("projectInfix", "Link infix", "Specify the infix to connect a project name and a theorem title or an equation number.");
// this.addTextSetting("projectSep", "Separator for nested projects");
// indexer/importer
// this.contentEl.createEl("h3", { text: "Indexing" });
this.addHeading('Indexing');
this.addSliderSetting('importerNumThreads', { min: 1, max: 10, step: 1 }, "Indexer threads", "The maximum number of thread used for indexing.");
this.addSliderSetting('importerUtilization', { min: 0.1, max: 1.0, step: 0.01 }, 'Indexer CPU utilization', "The CPU utilization that indexer threads should use.");
}
}
// export class ProjectSettingsHelper {
// plugin: LatexReferencer;
// file: TAbstractFile;
// constructor(public contentEl: HTMLElement, public parent: ContextSettingModal) {
// this.plugin = parent.plugin;
// this.file = parent.file;
// }
// makeSettingPane() {
// const project = this.plugin.projectManager.getProject(this.file);
// const noteOrFolder = this.file instanceof TFile ? "note" : "folder";
// let status = "";
// if (project) {
// if (project.root == this.file) {
// status = `This ${noteOrFolder} is a project's root.`;
// } else {
// status = `This ${noteOrFolder} belongs to the project "${project.name}" (root: ${project.root.path}).`;
// }
// } else {
// status = `This ${noteOrFolder} doesn't belong to any project.`;
// }
// this.contentEl.createEl("h4", {text: "Project (experimental)"})
// this.contentEl.createDiv({
// text: PROJECT_DESCRIPTION + " " + status,
// cls: ["setting-item-description", "math-booster-setting-item-description"]
// });
// this.addRootSetting();
// if (project) {
// this.addNameSetting(project);
// }
// }
// addRootSetting(): Setting | undefined {
// const prettyName = "Set as project root";
// const description = this.file instanceof TFile
// ? "If turned on, this file itself will be treated as a project."
// : "If turned on, all the files under this folder will be treated as a single project.";
// let index: AbstractFileIndex | undefined
// if (this.file instanceof TFile) {
// index = this.plugin.index.getNoteIndex(this.file)
// } else if (this.file instanceof TFolder) {
// index = this.plugin.index.getFolderIndex(this.file)
// }
// if (index) {
// const setting = new Setting(this.contentEl).setName(prettyName).setDesc(description);
// setting.addToggle((toggle) => {
// toggle.setValue(index!.isProjectRoot)
// .onChange((value) => {
// if (value) {
// this.plugin.projectManager.add(this.file);
// } else {
// this.plugin.projectManager.delete(this.file);
// }
// this.parent.close();
// this.parent.open();
// });
// });
// return setting;
// }
// }
// addNameSetting(project: Project): Setting {
// const prettyName = "Project name";
// const description = "A project name can contain inline math and doesn't have to be unique.";
// const setting = new Setting(this.contentEl).setName(prettyName).setDesc(description);
// setting.addText((text) => {
// text.setValue(project.name)
// .onChange((value) => {
// project.name = value;
// })
// });
// return setting;
// }
// }
function addFoldOptionSetting(el: HTMLElement, name: string, onChange: (fold: FoldOption) => any, defaultValue?: FoldOption) {
return new Setting(el)
.setName(name)
.addDropdown((dropdown) => {
dropdown.addOption('', 'Unfoldable');
dropdown.addOption('+', 'Foldable & expanded by default');
dropdown.addOption('-', 'Foldable & folded by default');
dropdown.setValue(defaultValue ?? DEFAULT_EXTRA_SETTINGS.foldDefault);
dropdown.onChange(onChange)
});
}

292
src/settings/modals.ts Normal file
View file

@ -0,0 +1,292 @@
import { TAbstractFile, TFile, App, Modal, Setting, FuzzySuggestModal, TFolder, Component } from 'obsidian';
import LatexReferencer from 'main';
import { MathSettings, MathContextSettings, DEFAULT_SETTINGS, MinimalTheoremCalloutSettings } from 'settings/settings';
import { MathSettingTab } from "settings/tab";
import { TheoremCalloutSettingsHelper, MathContextSettingsHelper } from "settings/helper";
import { isEqualToOrChildOf } from 'utils/obsidian';
import { resolveSettings } from 'utils/plugin';
abstract class MathSettingModal<SettingsType> extends Modal {
settings: SettingsType;
constructor(
app: App,
public plugin: LatexReferencer,
public callback?: (settings: SettingsType) => void,
) {
super(app);
}
onClose(): void {
this.contentEl.empty();
}
addButton(buttonText: string) {
const { contentEl } = this;
new Setting(contentEl)
.addButton((btn) => {
btn.setButtonText(buttonText)
.setCta()
.onClick(() => {
this.close();
if (this.callback) {
this.callback(this.settings);
}
});
btn.buttonEl.classList.add("insert-math-item-button");
});
const button = contentEl.querySelector(".insert-math-item-button");
const settingTextboxes = contentEl.querySelectorAll("input");
if (button) {
settingTextboxes.forEach((textbox) => {
/**
* 'keypress' is deprecated, but here we need to use it.
* 'keydown' causes a serious inconvenience at least for Japanese users.
* See https://qiita.com/ledsun/items/31e43a97413dd3c8e38e
*/
this.plugin.registerDomEvent(textbox, "keypress", (event) => {
if (event.key === "Enter") {
(button as HTMLElement).click();
}
});
});
}
}
}
export class TheoremCalloutModal extends MathSettingModal<MathSettings> {
defaultSettings: Required<MathContextSettings>;
constructor(
app: App,
plugin: LatexReferencer,
public file: TFile,
callback: (settings: MathSettings) => void,
public buttonText: string,
public headerText: string,
public currentCalloutSettings?: MinimalTheoremCalloutSettings,
) {
super(app, plugin, callback);
}
resolveDefaultSettings(currentFile: TAbstractFile) {
// The if statement is redundant, but probably necessary for the Typescript compiler to work
if (this.currentCalloutSettings === undefined) {
this.defaultSettings = resolveSettings(this.currentCalloutSettings, this.plugin, currentFile)
} else {
this.defaultSettings = resolveSettings(this.currentCalloutSettings, this.plugin, currentFile)
}
}
onOpen(): void {
this.settings = this.currentCalloutSettings ?? {} as MathSettings;
const { contentEl } = this;
contentEl.empty();
this.resolveDefaultSettings(this.file);
if (this.headerText) {
// contentEl.createEl("h4", { text: this.headerText });
this.titleEl.setText(this.headerText);
}
const helper = new TheoremCalloutSettingsHelper(contentEl, this.settings, this.defaultSettings, this.plugin, this.file);
helper.makeSettingPane();
new Setting(contentEl)
.setName('Open local settings for the current note')
.addButton((button) => {
button.setButtonText("Open")
.onClick(() => {
const modal = new ContextSettingModal(
this.app,
this.plugin,
this.file,
undefined,
this
);
modal.open();
})
});
this.addButton(this.buttonText);
}
}
export class ContextSettingModal extends MathSettingModal<MathContextSettings> {
component: Component;
constructor(
app: App,
plugin: LatexReferencer,
public file: TAbstractFile,
callback?: (settings: MathContextSettings) => void,
public parent?: TheoremCalloutModal | undefined
) {
super(app, plugin, callback);
this.component = new Component();
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
this.component.load();
// contentEl.createEl('h4', { text: 'Local settings for ' + this.file.path });
this.titleEl.setText('Local settings for ' + this.file.path);
contentEl.createDiv({
text: "If you want the change to apply to the entire vault, go to the plugin settings.",
cls: ["setting-item-description", "math-booster-setting-item-description"],
});
if (this.plugin.settings[this.file.path] === undefined) {
this.plugin.settings[this.file.path] = {} as MathContextSettings;
}
const defaultSettings = this.file.parent ? resolveSettings(undefined, this.plugin, this.file.parent) : DEFAULT_SETTINGS;
const contextSettingsHelper = new MathContextSettingsHelper(contentEl, this.plugin.settings[this.file.path], defaultSettings, this.plugin, this.file);
this.component.addChild(contextSettingsHelper);
// if (!(this.file instanceof TFolder && this.file.isRoot())) {
// new ProjectSettingsHelper(contentEl, this).makeSettingPane();
// }
this.addButton('Save');
}
onClose(): void {
super.onClose();
this.plugin.saveSettings();
this.plugin.indexManager.trigger('local-settings-updated', this.file);
if (this.parent) {
this.parent.open();
}
this.component.unload();
}
}
abstract class FileSuggestModal extends FuzzySuggestModal<TAbstractFile> {
constructor(app: App, public plugin: LatexReferencer) {
super(app);
}
getItems(): TAbstractFile[] {
return this.app.vault
.getAllLoadedFiles()
.filter(this.filterCallback.bind(this));
}
getItemText(file: TAbstractFile): string {
return file.path;
}
filterCallback(abstractFile: TAbstractFile): boolean {
if (abstractFile instanceof TFile && abstractFile.extension != 'md') {
return false;
}
if (abstractFile instanceof TFolder && abstractFile.isRoot()) {
return false;
}
for (const path of this.plugin.excludedFiles) {
const file = this.app.vault.getAbstractFileByPath(path)
if (file && isEqualToOrChildOf(abstractFile, file)) {
return false
}
}
return true;
}
}
export class LocalContextSettingsSuggestModal extends FileSuggestModal {
constructor(app: App, plugin: LatexReferencer, public settingTab: MathSettingTab) {
super(app, plugin);
}
onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) {
const modal = new ContextSettingModal(this.app, this.plugin, file);
modal.open();
}
}
export class FileExcludeSuggestModal extends FileSuggestModal {
constructor(app: App, plugin: LatexReferencer, public manageModal: ExcludedFileManageModal) {
super(app, plugin);
}
onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) {
this.plugin.excludedFiles.push(file.path);
this.manageModal.newDisplay();
}
filterCallback(abstractFile: TAbstractFile): boolean {
for (const path in this.plugin.settings) {
if (path == abstractFile.path) {
return false;
}
}
return super.filterCallback(abstractFile);
}
}
export class ExcludedFileManageModal extends Modal {
constructor(app: App, public plugin: LatexReferencer) {
super(app);
}
onOpen() {
this.newDisplay();
}
async newDisplay() {
await this.plugin.saveSettings();
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h3', { text: 'Excluded files/folders' });
new Setting(contentEl)
.setName('The files/folders in this list and their descendants will be excluded from suggestion for local settings.')
.addButton((btn) => {
btn.setIcon("plus")
.onClick((event) => {
new FileExcludeSuggestModal(this.app, this.plugin, this).open();
});
});
if (this.plugin.excludedFiles.length) {
const list = contentEl.createEl('ul');
for (const path of this.plugin.excludedFiles) {
const item = list.createEl('li').createDiv();
new Setting(item).setName(path).addExtraButton((btn) => {
btn.setIcon('x').onClick(async () => {
this.plugin.excludedFiles.remove(path);
this.newDisplay();
await this.plugin.saveSettings();
});
});
}
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

446
src/settings/profile.ts Normal file
View file

@ -0,0 +1,446 @@
import { ButtonComponent, DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian';
import LatexReferencer, { VAULT_ROOT } from '../main';
import { THEOREM_LIKE_ENV_IDs, TheoremLikeEnvID } from '../env';
import { MathContextSettingsHelper } from '../settings/helper';
import { DEFAULT_SETTINGS } from './settings';
export type ProfileMeta = { tags: string[] };
export type TheoremLinkEnvDisplay = { [k in TheoremLikeEnvID]: string };
export const PROOF_SETTING_KEYS = [
"begin",
"end",
"linkedBeginPrefix",
"linkedBeginSuffix",
] as const;
export type ProofSettingKey = typeof PROOF_SETTING_KEYS[number];
export type ProofDisplay = { [k in ProofSettingKey]: string };
export type ProfileBody = {
theorem: TheoremLinkEnvDisplay;
proof: ProofDisplay;
};
export type Profile = {
id: string;
meta: ProfileMeta;
body: ProfileBody;
};
export const DEFAULT_PROFILES: Record<string, Profile> = {
"English": {
id: "English",
meta: {
tags: ["en"],
},
body: {
theorem: {
"axiom": "Axiom",
"definition": "Definition",
"lemma": "Lemma",
"proposition": "Proposition",
"theorem": "Theorem",
"corollary": "Corollary",
"claim": "Claim",
"assumption": "Assumption",
"example": "Example",
"exercise": "Exercise",
"conjecture": "Conjecture",
"hypothesis": "Hypothesis",
"remark": "Remark",
},
proof: {
begin: "Proof.",
end: "□",
linkedBeginPrefix: "Proof of ",
linkedBeginSuffix: ".",
},
},
},
"日本語": {
id: "日本語",
meta: {
tags: ["ja"],
},
body: {
theorem: {
"axiom": "公理",
"definition": "定義",
"lemma": "補題",
"proposition": "命題",
"theorem": "定理",
"corollary": "系",
"claim": "主張",
"assumption": "仮定",
"example": "例",
"exercise": "演習問題",
"conjecture": "予想",
"hypothesis": "仮説",
"remark": "注",
},
proof: {
begin: "証明.",
end: "□",
linkedBeginPrefix: "",
linkedBeginSuffix: "の証明.",
},
},
},
};
export class ManageProfileModal extends Modal {
constructor(public plugin: LatexReferencer, public helper: MathContextSettingsHelper, public profileSetting: Setting) {
super(plugin.app);
}
onOpen() {
let { contentEl } = this;
contentEl.empty();
// contentEl.createEl("h4", { text: "Manage profiles" });
this.titleEl.setText("Manage profiles");
new Setting(contentEl)
.setName("Add profile")
.addButton((button) => {
button.setIcon("plus").onClick(() => {
new AddProfileModal(this).open()
});
});
for (const id in this.plugin.extraSettings.profiles) {
new Setting(contentEl)
.setName(id)
.addButton((editButton) => {
editButton.setIcon("pencil")
.setTooltip("Edit")
.setCta()
.onClick(() => {
new EditProfileModal(
this.plugin.extraSettings.profiles[id],
this
).open();
});
}).addButton((editButton) => {
editButton.setIcon("copy")
.setTooltip("Copy")
.onClick(() => {
const copied = JSON.parse(JSON.stringify(this.plugin.extraSettings.profiles[id]));
copied.id = makeIdOfCopy(id, this.plugin.extraSettings.profiles);
this.plugin.extraSettings.profiles[copied.id] = copied;
this.open();
});
}).addButton((deleteButton) => {
deleteButton.setIcon("trash-2")
.setTooltip("Delete")
.onClick(() => {
new ConfirmProfileDeletionModal(id, this).open();
});
});
}
}
async onClose() {
let { contentEl } = this;
contentEl.empty();
await this.plugin.saveSettings();
this.plugin.indexManager.trigger('global-settings-updated');
this.profileSetting.settingEl.replaceWith(
this.helper.addProfileSetting(
this.plugin.settings[this.helper.file.path].profile
).settingEl
);
}
}
class EditProfileModal extends Modal {
settingRefs: Record<TheoremLikeEnvID | ProofSettingKey, Setting>;
constructor(public profile: Profile, public parent: ManageProfileModal) {
super(parent.app);
this.settingRefs = {} as Record<TheoremLikeEnvID | ProofSettingKey, Setting>;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
// contentEl.createEl("h4", { text: `Edit profile` });
this.titleEl.setText(`Edit profile`);
new Setting(contentEl)
.setName("Name")
.addText((text) => {
text.setValue(this.profile.id)
.onChange((value) => {
this.profile.id = value;
});
});
new Setting(contentEl)
.setName("Tags")
.setDesc("Comma-separated list of tags. Only lower-case alphabets or hyphens are allowed. Each tag is converted into a CSS class \".theorem-callout-<tag>\".")
.addText((text) => {
text.setValue(this.profile.meta.tags.join(", "))
.onChange((value) => {
const tags = value.split(",").map((item) => item.trim());
if (tags.every((tag) => tag.match(/^[a-z\-]+$/) ?? !tag)) {
this.profile.meta.tags = tags;
} else {
new Notice("A tag can only contain lower-case alphabets or hyphens.", 5000);
}
});
});
// contentEl.createEl("h5", { text: "Theorem-like environments" });
new Setting(contentEl).setName("Theorem-like environments").setHeading();
for (const envID of THEOREM_LIKE_ENV_IDs) {
this.settingRefs[envID] = new Setting(contentEl).setName(envID).addText((text) => {
text.setValue(this.profile.body.theorem[envID] ?? "")
.onChange((value) => {
this.profile.body.theorem[envID] = value;
})
});
}
// contentEl.createEl("h5", { text: "Proofs" });
new Setting(contentEl).setName("Proofs").setHeading();
const prettyNames = [
"Beginning of proof",
"Ending of proof",
"Prefix",
"Suffix",
];
for (let i = 0; i < PROOF_SETTING_KEYS.length; i++) {
const key = PROOF_SETTING_KEYS[i];
const name = prettyNames[i];
this.settingRefs[key] = new Setting(contentEl).setName(name).addText((text) => {
text.setValue(this.profile.body.proof[key] ?? "")
.onChange((value) => {
this.profile.body.proof[key] = value;
})
});
}
// const linkedProofHeading = contentEl.createEl("h6", {text: "Linked proofs"});
const linkedProofHeading = new Setting(contentEl)
.setName("Linked proofs")
.setDesc(`For example, you can render \`${DEFAULT_SETTINGS.beginProof}\`@[[link to Theorem 1]] as "${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.linkedBeginPrefix}Theorem 1${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.linkedBeginSuffix}".`)
.setHeading().settingEl;
// const linkedProofDesc = contentEl.createDiv({
// text: `For example, you can render \`${DEFAULT_SETTINGS.beginProof}\`@[[link to Theorem 1]] as "${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.linkedBeginPrefix}Theorem 1${DEFAULT_PROFILES[DEFAULT_SETTINGS.profile].body.proof.linkedBeginSuffix}".`,
// cls: ["setting-item-description", "math-booster-setting-item-description"],
// });
contentEl.insertBefore(linkedProofHeading, this.settingRefs.linkedBeginPrefix.settingEl);
// contentEl.insertBefore(linkedProofDesc, this.settingRefs.linkedBeginPrefix.settingEl);
}
onClose() {
const profiles = this.parent.plugin.extraSettings.profiles;
for (const oldID in profiles) {
const newID = profiles[oldID].id;
if (newID != oldID) {
profiles[newID] = profiles[oldID];
delete profiles[oldID];
const affected = getAffectedFiles(this.parent.plugin, oldID);
updateProfile(this.parent.plugin, affected, newID);
}
}
let { contentEl } = this;
contentEl.empty();
this.parent.open();
}
}
class ConfirmProfileDeletionModal extends Modal {
constructor(public id: string, public parent: ManageProfileModal) {
super(parent.app);
}
onOpen() {
let { contentEl } = this;
contentEl.empty();
// contentEl.createEl("h3", { text: "Delete profile" });
this.titleEl.setText("Delete profile");
contentEl.createDiv({ text: `Are you sure you want to delete the profile "${this.id}"?` });
const buttonContainerEl = contentEl.createDiv({ cls: "math-booster-button-container" });
new ButtonComponent(buttonContainerEl)
.setButtonText("Delete")
.setCta()
.onClick(() => {
const affected = getAffectedFiles(this.parent.plugin, this.id);
if (affected.length) {
new UpdateProfileModal(this, this.id, affected).open();
} else {
delete this.parent.plugin.extraSettings.profiles[this.id];
this.close();
}
});
new ButtonComponent(buttonContainerEl)
.setButtonText("Cancel")
.onClick(() => {
this.close();
});
}
onClose() {
let { contentEl } = this;
contentEl.empty();
this.parent.open();
}
}
class AddProfileModal extends Modal {
constructor(public parent: ManageProfileModal) {
super(parent.app);
}
onOpen() {
let { contentEl } = this;
contentEl.empty();
let id: string;
// contentEl.createEl("h3", { text: "Add profile" });
this.titleEl.setText("Add profile");
const addProfileEl = contentEl.createDiv({ cls: "math-booster-add-profile" });
new TextComponent(addProfileEl)
.setPlaceholder("Enter name...")
.onChange((value) => {
id = value;
});
const buttonContainerEl = addProfileEl.createDiv({ cls: "math-booster-button-container" });
new ButtonComponent(buttonContainerEl)
.setButtonText("Add")
.setCta()
.onClick(() => {
const newBody = {theorem: {}} as ProfileBody;
for (const envID of THEOREM_LIKE_ENV_IDs) {
newBody.theorem[envID] = "";
}
this.parent.plugin.extraSettings.profiles[id] = {
id,
meta: { tags: [] },
body: newBody
};
new EditProfileModal(this.parent.plugin.extraSettings.profiles[id], this.parent).open();
this.close();
});
new ButtonComponent(buttonContainerEl)
.setButtonText("Cancel")
.onClick(() => {
this.close();
});
}
onClose() {
let { contentEl } = this;
contentEl.empty();
this.parent.open();
}
}
class UpdateProfileModal extends Modal {
constructor(public parent: ConfirmProfileDeletionModal, public deletedID: string, public affected: string[]) {
super(parent.app);
}
onOpen() {
let { contentEl } = this;
contentEl.empty();
// contentEl.createEl("h3", { text: "Update profiles" });
this.titleEl.setText("Update profiles");
contentEl.createDiv({ text: `The following ${this.affected.length > 1 ? this.affected.length : ""} local setting${this.affected.length > 1 ? "s are" : " is"} affected by the deletion of profile "${this.deletedID}." Select a new profile to be applied for them.` });
const profiles = this.parent.parent.plugin.extraSettings.profiles;
const ids = Object.keys(profiles);
let newProfileID: string | undefined;
const buttonContainerEl = contentEl.createDiv({ cls: "math-booster-button-container" });
const dropdown = new DropdownComponent(buttonContainerEl);
dropdown.addOption("", "");
for (const id of ids) {
if (id != this.deletedID) {
dropdown.addOption(id, id);
}
}
newProfileID = dropdown.getValue();
dropdown.onChange((value) => {
newProfileID = value;
});
new ButtonComponent(buttonContainerEl)
.setButtonText("Confirm")
.setCta()
.onClick(async () => {
updateProfile(this.parent.parent.plugin, this.affected, newProfileID);
this.close();
})
new ButtonComponent(buttonContainerEl)
.setButtonText("Cancel")
.onClick(() => {
this.close();
});
const listEl = contentEl.createEl("ul");
for (const path of this.affected) {
listEl.createEl("li", { text: path == VAULT_ROOT ? "(Vault root)" : path });
}
}
onClose() {
let { contentEl } = this;
contentEl.empty();
delete this.parent.parent.plugin.extraSettings.profiles[this.parent.id];
this.parent.close();
}
}
function getAffectedFiles(plugin: LatexReferencer, oldProfileId: string) {
const affected: string[] = [];
for (const path in plugin.settings) {
const localSettings = plugin.settings[path];
if (localSettings.profile == oldProfileId) {
affected.push(path);
}
}
return affected;
}
function makeIdOfCopy(oldID: string, profiles: Record<string, Profile>) {
let newId = `Copy of ${oldID}`;
if (!(newId in profiles)) {
return newId;
}
const ids = Object.keys(profiles);
const numbers: number[] = ids.map((id) => id.slice(oldID.length + 1).match(/\(([1-9][0-9]*)\)/)?.[1] ?? "0").map((numStr: string): number => +numStr);
const max = Math.max(...numbers);
return `${newId} (${max + 1})`;
}
function updateProfile(plugin: LatexReferencer, paths: string[], newID?: string) {
for (const path of paths) {
const localSettings = plugin.settings[path];
if (newID) {
localSettings.profile = newID;
} else {
delete localSettings.profile;
}
}
}

277
src/settings/settings.ts Normal file
View file

@ -0,0 +1,277 @@
import { Modifier } from "obsidian";
import { DEFAULT_PROFILES, Profile } from "./profile";
import { LeafArgs } from "../typings/type";
import { QueryType, SearchRange } from "search/core";
// Types
export const NUMBER_STYLES = [
"arabic",
"alph",
"Alph",
"roman",
"Roman"
] as const;
export type NumberStyle = typeof NUMBER_STYLES[number];
export const THEOREM_CALLOUT_STYLES = [
"Custom",
"Plain",
"Framed",
"MathWiki",
"Vivid",
] as const;
export type TheoremCalloutStyle = typeof THEOREM_CALLOUT_STYLES[number];
export const THEOREM_REF_FORMATS = [
"[type] [number] ([title])",
"[type] [number]",
"[title] ([type] [number]) if title exists, [type] [number] otherwise",
"[title] if title exists, [type] [number] otherwise",
] as const;
export type TheoremRefFormat = typeof THEOREM_REF_FORMATS[number];
export const LEAF_OPTIONS = [
"Current tab",
"Split right",
"Split down",
"New tab",
"New window",
] as const;
export type LeafOption = typeof LEAF_OPTIONS[number];
export const LEAF_OPTION_TO_ARGS: Record<LeafOption, LeafArgs> = {
"Current tab": [false],
"Split right": ["split", "vertical"],
"Split down": ["split", "horizontal"],
"New tab": ["tab"],
"New window": ["window"],
}
export const SEARCH_METHODS = [
"Fuzzy",
"Simple"
] as const;
export type SearchMethod = typeof SEARCH_METHODS[number];
// Context settings are called "local settings" in the documentation and UI.
// Sorry for confusion, this is due to a historical reason. I'll fix it later.
export interface MathContextSettings {
profile: string;
titleSuffix: string;
inferNumberPrefix: boolean;
inferNumberPrefixFromProperty: string;
inferNumberPrefixRegExp: string;
numberPrefix: string;
numberSuffix: string;
numberInit: number;
numberStyle: NumberStyle;
numberDefault: string;
refFormat: TheoremRefFormat;
noteMathLinkFormat: TheoremRefFormat;
ignoreMainTheoremCalloutWithoutTitle: boolean;
numberOnlyReferencedEquations: boolean;
inferEqNumberPrefix: boolean;
inferEqNumberPrefixFromProperty: string;
inferEqNumberPrefixRegExp: string;
eqNumberPrefix: string;
eqNumberSuffix: string;
eqNumberInit: number;
eqNumberStyle: NumberStyle;
eqRefPrefix: string;
eqRefSuffix: string;
labelPrefix: string;
lineByLine: boolean;
theoremCalloutStyle: TheoremCalloutStyle;
theoremCalloutFontInherit: boolean;
beginProof: string;
endProof: string;
insertSpace: boolean;
}
export const UNION_TYPE_MATH_CONTEXT_SETTING_KEYS: {[k in keyof Partial<MathContextSettings>]: readonly string[]} = {
"numberStyle": NUMBER_STYLES,
"refFormat": THEOREM_REF_FORMATS,
"noteMathLinkFormat": THEOREM_REF_FORMATS,
"eqNumberStyle": NUMBER_STYLES,
"theoremCalloutStyle": THEOREM_CALLOUT_STYLES,
};
export type FoldOption = '' | '+' | '-';
export type MinimalTheoremCalloutSettings = {
type: string;
number: string;
title?: string;
fold?: FoldOption;
}
export type TheoremCalloutSettings = MinimalTheoremCalloutSettings & {
// type: string;
// number: string;
// title?: string;
label?: string;
// setAsNoteMathLink: boolean;
}
// legacy interface; currently only used temporarily when computing formatted theorem titles
// TODO: refactor
export interface TheoremCalloutPrivateFields {
_index?: number;
}
export interface ImporterSettings {
importerNumThreads: number;
importerUtilization: number;
}
export type ExtraSettings = ImporterSettings & {
foldDefault: FoldOption;
noteTitleInTheoremLink: boolean;
noteTitleInEquationLink: boolean;
profiles: Record<string, Profile>;
showTheoremTitleinBuiltin: boolean;
showTheoremContentinBuiltin: boolean;
triggerSuggest: string;
triggerTheoremSuggest: string;
triggerEquationSuggest: string;
triggerSuggestActiveNote: string;
triggerTheoremSuggestActiveNote: string;
triggerEquationSuggestActiveNote: string;
triggerSuggestRecentNotes: string;
triggerTheoremSuggestRecentNotes: string;
triggerEquationSuggestRecentNotes: string;
triggerSuggestDataview: string;
triggerTheoremSuggestDataview: string;
triggerEquationSuggestDataview: string;
enableSuggest: boolean;
enableTheoremSuggest: boolean;
enableEquationSuggest: boolean;
enableSuggestActiveNote: boolean;
enableTheoremSuggestActiveNote: boolean;
enableEquationSuggestActiveNote: boolean;
enableSuggestRecentNotes: boolean;
enableTheoremSuggestRecentNotes: boolean;
enableEquationSuggestRecentNotes: boolean;
enableSuggestDataview: boolean;
enableTheoremSuggestDataview: boolean;
enableEquationSuggestDataview: boolean;
renderMathInSuggestion: boolean;
suggestNumber: number;
searchMethod: SearchMethod;
upWeightRecent: number;
searchLabel: boolean;
modifierToJump: Modifier;
modifierToNoteLink: Modifier;
showModifierInstruction: boolean;
suggestLeafOption: LeafOption;
// projectInfix: string;
// projectSep: string;
showTheoremCalloutEditButton: boolean;
setOnlyTheoremAsMain: boolean;
setLabelInModal: boolean;
excludeExampleCallout: boolean;
enableProof: boolean;
autocompleteDvQuery: string;
// searchModal*: not congigurable from the setting tab, just remenbers the last state
searchModalQueryType: QueryType;
searchModalRange: SearchRange;
searchModalDvQuery: string;
}
export const UNION_TYPE_EXTRA_SETTING_KEYS: {[k in keyof Partial<ExtraSettings>]: readonly string[]} = {
"searchMethod": SEARCH_METHODS,
"suggestLeafOption": LEAF_OPTIONS,
};
export type MathSettings = Partial<MathContextSettings> & TheoremCalloutSettings & TheoremCalloutPrivateFields;
export type ResolvedMathSettings = Required<MathContextSettings> & TheoremCalloutSettings & TheoremCalloutPrivateFields;
export const DEFAULT_SETTINGS: Required<MathContextSettings> = {
profile: Object.keys(DEFAULT_PROFILES)[0],
titleSuffix: ".",
inferNumberPrefix: true,
inferNumberPrefixFromProperty: "",
inferNumberPrefixRegExp: "^[0-9]+(\\.[0-9]+)*",
numberPrefix: "",
numberSuffix: "",
numberInit: 1,
numberStyle: "arabic",
numberDefault: "auto",
refFormat: "[type] [number] ([title])",
noteMathLinkFormat: "[title] if title exists, [type] [number] otherwise",
ignoreMainTheoremCalloutWithoutTitle: false,
numberOnlyReferencedEquations: true,
inferEqNumberPrefix: true,
inferEqNumberPrefixFromProperty: "",
inferEqNumberPrefixRegExp: "^[0-9]+(\\.[0-9]+)*",
eqNumberPrefix: "",
eqNumberSuffix: "",
eqNumberInit: 1,
eqNumberStyle: "arabic",
eqRefPrefix: "",
eqRefSuffix: "",
labelPrefix: "",
lineByLine: true,
theoremCalloutStyle: "Framed",
theoremCalloutFontInherit: false,
beginProof: "\\begin{proof}",
endProof: "\\end{proof}",
insertSpace: true,
}
export const DEFAULT_EXTRA_SETTINGS: Required<ExtraSettings> = {
foldDefault: '',
noteTitleInTheoremLink: true,
noteTitleInEquationLink: true,
profiles: DEFAULT_PROFILES,
showTheoremTitleinBuiltin: true,
showTheoremContentinBuiltin: false,
triggerSuggest: "\\ref",
triggerTheoremSuggest: "\\tref",
triggerEquationSuggest: "\\eqref",
triggerSuggestActiveNote: "\\ref:a",
triggerTheoremSuggestActiveNote: "\\tref:a",
triggerEquationSuggestActiveNote: "\\eqref:a",
triggerSuggestRecentNotes: "\\ref:r",
triggerTheoremSuggestRecentNotes: "\\tref:r",
triggerEquationSuggestRecentNotes: "\\eqref:r",
triggerSuggestDataview: "\\ref:d",
triggerTheoremSuggestDataview: "\\tref:d",
triggerEquationSuggestDataview: "\\eqref:d",
enableSuggest: true,
enableTheoremSuggest: true,
enableEquationSuggest: true,
enableSuggestActiveNote: true,
enableTheoremSuggestActiveNote: true,
enableEquationSuggestActiveNote: true,
enableSuggestRecentNotes: true,
enableTheoremSuggestRecentNotes: true,
enableEquationSuggestRecentNotes: true,
enableSuggestDataview: true,
enableTheoremSuggestDataview: true,
enableEquationSuggestDataview: true,
renderMathInSuggestion: true,
suggestNumber: 20,
searchMethod: "Fuzzy",
upWeightRecent: 0.1,
searchLabel: false,
modifierToJump: "Mod",
modifierToNoteLink: "Shift",
showModifierInstruction: true,
suggestLeafOption: "Current tab",
// projectInfix: " > ",
// projectSep: "/",
importerNumThreads: 2,
importerUtilization: 0.75,
showTheoremCalloutEditButton: false,
setOnlyTheoremAsMain: false,
setLabelInModal: false,
excludeExampleCallout: false,
enableProof: true,
autocompleteDvQuery: '',
searchModalQueryType: 'both',
searchModalRange: 'recent',
searchModalDvQuery: '',
};

146
src/settings/tab.ts Normal file
View file

@ -0,0 +1,146 @@
import { App, Component, PluginSettingTab, Setting } from "obsidian";
import LatexReferencer, { VAULT_ROOT } from "../main";
import { DEFAULT_EXTRA_SETTINGS, DEFAULT_SETTINGS } from "./settings";
import { ExtraSettingsHelper, MathContextSettingsHelper } from "./helper";
import { ExcludedFileManageModal, LocalContextSettingsSuggestModal } from "settings/modals";
// import { PROJECT_DESCRIPTION } from "project";
export class MathSettingTab extends PluginSettingTab {
component: Component;
constructor(app: App, public plugin: LatexReferencer) {
super(app, plugin);
this.component = new Component();
}
addRestoreDefaultsButton() {
new Setting(this.containerEl)
.addButton((btn) => {
btn.setButtonText("Restore defaults");
btn.onClick(async () => {
Object.assign(this.plugin.settings[VAULT_ROOT], DEFAULT_SETTINGS);
Object.assign(this.plugin.extraSettings, DEFAULT_EXTRA_SETTINGS);
this.display();
})
});
}
display() {
const { containerEl } = this;
containerEl.empty();
this.component.load();
containerEl.createEl("h4", { text: "Global" });
const root = this.app.vault.getRoot();
const globalHelper = new MathContextSettingsHelper(
this.containerEl,
this.plugin.settings[VAULT_ROOT],
DEFAULT_SETTINGS,
this.plugin,
root
);
this.component.addChild(globalHelper);
const extraHelper = new ExtraSettingsHelper(
this.containerEl,
this.plugin.extraSettings,
this.plugin.extraSettings,
this.plugin,
false,
false
);
this.component.addChild(extraHelper);
const heading = extraHelper.addHeading('Equations - general');
const numberingHeading = this.containerEl.querySelector<HTMLElement>('.equation-heading')!;
this.containerEl.insertBefore(
heading.settingEl,
numberingHeading
);
this.containerEl.insertAfter(
extraHelper.settingRefs.enableProof.settingEl,
this.containerEl.querySelector('.proof-heading')!
);
this.containerEl.insertAfter(
extraHelper.settingRefs.showTheoremCalloutEditButton.settingEl,
globalHelper.settingRefs.profile.settingEl
);
this.containerEl.insertAfter(
extraHelper.settingRefs.excludeExampleCallout.settingEl,
globalHelper.settingRefs.profile.settingEl
);
this.containerEl.insertBefore(
extraHelper.settingRefs.foldDefault.settingEl,
globalHelper.settingRefs.labelPrefix.settingEl
);
this.containerEl.insertBefore(
extraHelper.settingRefs.setOnlyTheoremAsMain.settingEl,
globalHelper.settingRefs.labelPrefix.settingEl
);
this.containerEl.insertBefore(
extraHelper.settingRefs.setLabelInModal.settingEl,
globalHelper.settingRefs.labelPrefix.settingEl
);
this.containerEl.insertAfter(
extraHelper.settingRefs.noteTitleInTheoremLink.settingEl,
globalHelper.settingRefs.refFormat.settingEl
);
this.containerEl.insertAfter(
extraHelper.settingRefs.noteTitleInEquationLink.settingEl,
globalHelper.settingRefs.eqRefSuffix.settingEl
);
this.containerEl.insertBefore(
globalHelper.settingRefs.insertSpace.settingEl,
extraHelper.settingRefs.searchMethod.settingEl,
);
// const projectHeading = containerEl.createEl("h3", { text: "Projects (experimental)" });
// const projectDesc = containerEl.createDiv({
// text: PROJECT_DESCRIPTION,
// cls: ["setting-item-description", "math-booster-setting-item-description"]
// });
// this.containerEl.insertBefore(
// projectHeading,
// extraHelper.settingRefs.projectInfix.settingEl
// );
// this.containerEl.insertAfter(
// projectDesc,
// projectHeading,
// );
this.addRestoreDefaultsButton();
containerEl.createEl("h4", { text: "Local" });
new Setting(containerEl).setName("Local settings")
.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.")
.addButton((btn) => {
btn.setButtonText("Search files & folders")
.onClick(() => {
new LocalContextSettingsSuggestModal(this.app, this.plugin, this).open();
});
});
new Setting(containerEl)
.setName("Excluded files")
.setDesc("You can make your search results more visible by excluding certain files or folders.")
.addButton((btn) => {
btn.setButtonText("Manage")
.onClick(() => {
new ExcludedFileManageModal(this.app, this.plugin).open();
});
});
}
async hide() {
super.hide();
await this.plugin.saveSettings();
this.plugin.indexManager.trigger('global-settings-updated');
this.plugin.updateLinkAutocomplete();
this.component.unload();
}
}

View file

@ -0,0 +1,479 @@
import { App, ExtraButtonComponent, MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownView, Notice, TFile, editorInfoField } from "obsidian";
import LatexReferencer from 'main';
import { TheoremCalloutModal } from 'settings/modals';
import { TheoremCalloutSettings, TheoremCalloutPrivateFields } from 'settings/settings';
import { generateTheoremCalloutFirstLine, isTheoremCallout, resolveSettings } from 'utils/plugin';
import { isEditingView } from 'utils/editor';
import { capitalize } from 'utils/general';
import { formatTitleWithoutSubtitle } from "utils/format";
import { renderTextWithMath } from "utils/render";
import { MarkdownPage, TheoremCalloutBlock } from "index/typings/markdown";
import { MathIndex } from 'index/math-index';
import { parseTheoremCalloutMetadata, readTheoremCalloutSettings } from 'utils/parse';
import { THEOREM_LIKE_ENV_ID_PREFIX_MAP, THEOREM_LIKE_ENV_PREFIX_ID_MAP, TheoremLikeEnvID, TheoremLikeEnvPrefix } from 'env';
import { getIO } from 'file-io';
import { MutationObservingChild, getSectionCacheFromMouseEvent, getSectionCacheOfDOM, isPdfExport, resolveLinktext } from 'utils/obsidian';
export const createTheoremCalloutPostProcessor = (plugin: LatexReferencer) => async (element: HTMLElement, context: MarkdownPostProcessorContext) => {
const file = plugin.app.vault.getAbstractFileByPath(context.sourcePath) ?? plugin.app.workspace.getActiveFile();
if (!(file instanceof TFile)) return null;
const pdf = isPdfExport(element);
let index = 0; // for numbering theorems in PDf export
for (const calloutEl of element.querySelectorAll<HTMLElement>(`.callout`)) {
const type = calloutEl.getAttribute('data-callout')!.toLowerCase();
if (isTheoremCallout(plugin, type)) {
if (pdf) { // preprocess for theorem numbering in PDF export
const settings = readSettingsFromEl(calloutEl);
if (settings?.number === 'auto') calloutEl.setAttribute('data-theorem-index', String(index++));
}
const theoremCallout = new TheoremCalloutRenderer(calloutEl, context, file, plugin);
context.addChild(theoremCallout);
}
}
}
export interface TheoremCalloutInfo {
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "theorem" */
theoremType: string;
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "Theorem 1.1" */
theoremMainTitle: string;
/** e.g. Theorem 1.1 (Cauchy-Schwarz) -> "Cauchy-Schwarz" */
theoremSubtitleEl: HTMLElement | null;
titleSuffix: string;
}
/**
* Renders theorem callouts. The rendering strategy varys depending on the given situation:
*
* Reading view:
* Use the index. Listen to the index update event.
* Live preview:
* The index might be out-of-date. Also, context.getSectionInfo() returns null.
* Thus, I'm currently taking a rather hacky or dirty approach, where I set "data-theorem-index" attribute
* indicating a 0-based index of auto-numbered theorems in a document for every editor updates
* using a CodeMirror6 view plugin called theoremCalloutNumberingViewPlugin.
* Embeds:
* Read the target note path and the block id from the `src` attribute, and
* use it to find the corresponding TheoremCalloutBlock object from the index.
* Hover popover:
* The core page preview plugin is patched (src/patches/page-preview.ts) so that the linktext is
* saved in the plugin instance. Read it then proceed as in the embed case.
*/
class TheoremCalloutRenderer extends MarkdownRenderChild {
app: App;
index: MathIndex;
observer: MutationObservingChild;
/** The info on which the last DOM update was based on. Used to reduce redundant updates. */
info: TheoremCalloutInfo | null = null;
/** Set to the linktext when this theorem callout is inside an embed or a hover page preview. */
linktext: string | null = null;
editButton: HTMLElement | null = null;
constructor(
containerEl: HTMLElement,
public context: MarkdownPostProcessorContext,
public file: TFile,
public plugin: LatexReferencer
) {
super(containerEl);
this.app = plugin.app;
this.index = plugin.indexManager.index;
// update: for Live Preview & PDF export
this.addChild(this.observer = new MutationObservingChild(this.containerEl, (mutations) => {
for (const mutation of mutations) {
if (mutation.oldValue !== this.containerEl.getAttribute('data-theorem-index')) {
this.update();
}
}
}, {
attributeFilter: ['data-theorem-index'],
attributeOldValue: true,
}))
// update when the math index is updated
this.registerEvent(this.plugin.indexManager.on('index-updated', (file) => {
if (file.path === this.file.path) {
this.update();
}
}));
// remove the edit button when this plugin gets disabled
this.plugin.addChild(this);
this.register(() => this.removeEditButton());
// remove the edit button when the relevent setting is disabled
this.registerEvent(this.plugin.indexManager.on('global-settings-updated', () => {
if (this.plugin.extraSettings.showTheoremCalloutEditButton) {
this.addEditButton();
} else {
this.removeEditButton();
}
}));
}
getPage(): MarkdownPage | null {
const page = this.plugin.indexManager.index.load(this.context.sourcePath);
if (MarkdownPage.isMarkdownPage(page)) return page;
return null;
}
isLivePreview(): boolean {
return this.containerEl.closest('[src]') === null && this.containerEl.closest('.markdown-source-view.is-live-preview') !== null;
}
onload() {
this.update();
}
update() {
const existingMainTitleEl = this.containerEl.querySelector<HTMLElement>(".theorem-callout-main-title");
if (existingMainTitleEl && this.isLivePreview()) {
// only update the main title part (e.g. "Theorem 1.2")
// Here, settings.title might be incorrect (e.g. "Theorem 1.2 (Cauchy-Schwarz)" instead of "Cauchy-Schwarz"),
// but it is not a problem because we are only updating the main title part.
const settings: (TheoremCalloutSettings & TheoremCalloutPrivateFields) | null = readSettingsFromEl(this.containerEl);
if (!settings) return null;
const livePreviewIndex = this.containerEl.getAttribute('data-theorem-index');
if (livePreviewIndex !== null) settings._index = +livePreviewIndex;
const resolvedSettings = resolveSettings(settings, this.plugin, this.file);
const newMainTitle = formatTitleWithoutSubtitle(this.plugin, this.file, resolvedSettings);
existingMainTitleEl.setText(newMainTitle);
this.info = null;
return;
}
let block = this.getTheoremCalloutInfoFromIndex();
let info: TheoremCalloutInfo | null = block ?? this.getTheoremCalloutInfoFromEl();
if (!info) {
this.info = null;
return
};
if (!this.info || TheoremCalloutRenderer.areDifferentInfo(info, this.info)) {
this.renderTitle(info, existingMainTitleEl);
this.addCssClasses(info);
this.addEditButton();
this.info = info;
}
// In embeds or hover popover, we can get an incorrect TheoremCalloutBlock because
// MarkdownPostProcessorContext.getSectionInfo() returns incorrect line numbers.
// So we have to correct it manually.
setTimeout(() => {
// hover editor has no problem with line numbers, so there is no job to do!
if (this.containerEl.closest('.hover-popover.hover-editor')) return;
const update = this.correctEmbedOrHoverPagePreview(block, info!); // correct line number
if (update) {
block = update.block;
this.info = info = update.info;
}
});
}
correctEmbedOrHoverPagePreview(block: (TheoremCalloutInfo & { blockId?: string }) | null, info: TheoremCalloutInfo) {
// For embeds, we can get the linktext from the "src" attribute.
// In the case of hover page preview, we cannot get the linktext from the "src" attribute.
// So we patched the core page preview plugin so that it saves the linktext in the plugin instance.
// See src/patches/page-preview.ts
let linktext = this.containerEl.closest('[src]')?.getAttribute('src');
if (!linktext) {
const hoverEl = this.containerEl.closest<HTMLElement>('.hover-popover:not(.hover-editor)');
if (hoverEl) {
// The current context is hover page preview; read the linktext saved in the plugin instance.
linktext = this.plugin.lastHoverLinktext;
if (!linktext) {
// somehow failed to get the linktext; abort.
const update = this.correctHoverWithoutSrc();
if (update) {
block = update.block;
this.info = info = update.info;
}
return;
}
}
}
if (linktext) {
this.linktext = linktext;
const { file, subpathResult: result } = resolveLinktext(this.app, linktext, this.context.sourcePath) ?? {};
if (!file || !result) return;
if (result.type === 'block') {
if (result.block.id !== block?.blockId) {
const page = this.getPage();
if (!page) return;
const _block = page.$blocks.get(result.block.id);
if (TheoremCalloutBlock.isTheoremCalloutBlock(_block)) {
info = block = this.blockToInfo(_block);
if (!this.info || TheoremCalloutRenderer.areDifferentInfo(info, this.info)) {
this.renderTitle(info);
this.addCssClasses(info);
}
}
}
} else if (result.type === 'heading') {
const _block = this.findTheoremCalloutBlock(result.start.line);
if (_block) {
info = block = this.blockToInfo(_block);
if (!this.info || TheoremCalloutRenderer.areDifferentInfo(info, this.info)) {
this.renderTitle(info);
this.addCssClasses(info);
}
}
}
}
this.addEditButton();
return { block, info };
}
correctHoverWithoutSrc() {
const block = null;
const info = this.getTheoremCalloutInfoFromEl();
if (!info) return;
if (!this.info || TheoremCalloutRenderer.areDifferentInfo(info, this.info)) {
this.renderTitle(info);
this.addCssClasses(info);
}
this.removeEditButton();
return { block, info };
}
/** Find the corresponding TheoremCalloutBlock object from the index. */
findTheoremCalloutBlock(lineOffset: number = 0): TheoremCalloutBlock | null {
const page = this.getPage();
if (!page) return null;
const info = this.context.getSectionInfo(this.containerEl);
if (!info) return null;
const block = page.getBlockByLineNumber(info.lineStart + lineOffset) ?? page.getBlockByLineNumber(info.lineEnd + lineOffset);
if (!TheoremCalloutBlock.isTheoremCalloutBlock(block)) return null;
return block;
}
blockToInfo(block: TheoremCalloutBlock): TheoremCalloutInfo & { blockId?: string } {
let theoremSubtitleEl: HTMLElement | null = null;
if (block.$theoremSubtitle) {
theoremSubtitleEl = createSpan({ cls: "theorem-callout-subtitle" });
const subtitle = renderTextWithMath(`(${block.$theoremSubtitle})`);
theoremSubtitleEl.replaceChildren(...subtitle);
}
return {
theoremType: block.$theoremType,
theoremMainTitle: block.$theoremMainTitle,
theoremSubtitleEl,
titleSuffix: block.$titleSuffix,
blockId: block.$blockId,
};
}
getTheoremCalloutInfoFromIndex(): TheoremCalloutInfo & { blockId?: string } | null {
const block = this.findTheoremCalloutBlock();
if (!block) return null;
return this.blockToInfo(block);
}
// this method is expected to be called for live preview only
getTheoremCalloutInfoFromEl(): TheoremCalloutInfo | null {
const settings: (TheoremCalloutSettings & TheoremCalloutPrivateFields) | null = readSettingsFromEl(this.containerEl);
if (!settings) return null;
const livePreviewIndex = this.containerEl.getAttribute('data-theorem-index');
if (livePreviewIndex !== null) settings._index = +livePreviewIndex;
const resolvedSettings = resolveSettings(settings, this.plugin, this.file);
let theoremSubtitleEl = this.containerEl.querySelector<HTMLElement>('.theorem-callout-subtitle');
if (theoremSubtitleEl === null) {
const titleInnerEl = this.containerEl.querySelector<HTMLElement>('.callout-title-inner');
if (titleInnerEl?.childNodes.length) {
if (titleInnerEl.textContent !== capitalize(settings.type) && titleInnerEl.textContent !== capitalize(THEOREM_LIKE_ENV_ID_PREFIX_MAP[settings.type as TheoremLikeEnvID])) {
theoremSubtitleEl = createSpan({ cls: "theorem-callout-subtitle" });
theoremSubtitleEl.replaceChildren('(', ...titleInnerEl.childNodes, ')');
}
}
}
return {
theoremType: settings.type,
theoremMainTitle: formatTitleWithoutSubtitle(this.plugin, this.file, resolvedSettings),
theoremSubtitleEl,
titleSuffix: resolvedSettings.titleSuffix
};
}
renderTitle(info: TheoremCalloutInfo, existingMainTitleEl?: HTMLElement | null) {
const titleInner = this.containerEl.querySelector<HTMLElement>('.callout-title-inner');
if (!titleInner) throw Error(`${this.plugin.manifest.name}: Failed to find the title element of a theorem callout.`);
const newMainTitleEl = createSpan({
text: info.theoremMainTitle,
cls: "theorem-callout-main-title"
});
if (existingMainTitleEl) {
// only update the main title part
existingMainTitleEl.replaceWith(newMainTitleEl);
return;
}
const titleElements: (HTMLElement | string)[] = [newMainTitleEl];
if (info.theoremSubtitleEl) {
titleElements.push(" ", info.theoremSubtitleEl);
}
if (info.titleSuffix) {
titleElements.push(info.titleSuffix);
}
titleInner.replaceChildren(...titleElements);
}
addCssClasses(info: TheoremCalloutInfo) {
this.containerEl.classList.forEach((cls, _, list) => {
if (cls.startsWith('theorem-callout')) list.remove(cls);
});
this.containerEl.classList.add("theorem-callout");
const resolvedSettings = resolveSettings(undefined, this.plugin, this.file);
const profile = this.plugin.extraSettings.profiles[resolvedSettings.profile];
for (const tag of profile.meta.tags) {
this.containerEl.classList.add("theorem-callout-" + tag);
}
this.containerEl.classList.add("theorem-callout-" + info.theoremType);
this.containerEl.toggleClass(`theorem-callout-${resolvedSettings.theoremCalloutStyle.toLowerCase()}`, resolvedSettings.theoremCalloutStyle != "Custom");
this.containerEl.toggleClass("theorem-callout-font-family-inherit", resolvedSettings.theoremCalloutStyle != "Custom" && resolvedSettings.theoremCalloutFontInherit);
}
removeEditButton() {
if (this.editButton) {
this.editButton.remove();
this.editButton = null;
}
}
addEditButton() {
if (!this.plugin.extraSettings.showTheoremCalloutEditButton) return;
if (this.editButton) return; // already exists
const button = new ExtraButtonComponent(this.containerEl)
.setIcon("settings-2")
.setTooltip("Edit theorem callout settings");
this.editButton = button.extraSettingsEl;
this.editButton.addClass("theorem-callout-setting-button");
button.extraSettingsEl.addEventListener("click", async (ev) => {
ev.stopPropagation();
const io = getIO(this.plugin, this.file);
// Make sure to get the line number BEFORE opening the modal!!
const lineNumber = this.getLineNumber(ev);
if (lineNumber === null) return;
const line = await io.getLine(lineNumber);
new TheoremCalloutModal(this.app, this.plugin, this.file, async (settings) => {
if (lineNumber !== undefined) {
await io.setLine(lineNumber, generateTheoremCalloutFirstLine(settings));
} else {
new Notice(
`${this.plugin.manifest.name}: Could not find the line number to overwrite. Retry later.`,
5000
)
}
},
"Confirm",
"Edit theorem callout settings",
readTheoremCalloutSettings(line, this.plugin.extraSettings.excludeExampleCallout)
).open();
});
}
getLineNumber(event: MouseEvent): number | null {
let lineOffset = 0;
// handle the embed case
if (this.linktext !== null) {
const { subpathResult } = resolveLinktext(this.app, this.linktext, this.context.sourcePath) ?? {};
if (subpathResult) lineOffset = subpathResult.start.line;
}
const info = this.context.getSectionInfo(this.containerEl);
if (info) return lineOffset + info.lineStart;
const cache = this.app.metadataCache.getFileCache(this.file);
if (!cache) return null;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return null;
if (isEditingView(view) && this.file.path === view.file?.path && view.editor.cm) {
let sec = getSectionCacheOfDOM(this.containerEl, "callout", view.editor.cm, cache)
?? getSectionCacheFromMouseEvent(event, "callout", view.editor.cm, cache)
if (sec) return sec.position.start.line;
}
// What can I do in reading view??
return null;
}
static areDifferentInfo(info1: TheoremCalloutInfo, info2: TheoremCalloutInfo) {
return info1.theoremMainTitle !== info2.theoremMainTitle;
}
}
/** Read TheoremCalloutSettings from the element's attribute. */
function readSettingsFromEl(calloutEl: HTMLElement): TheoremCalloutSettings | null {
let type = calloutEl.getAttribute('data-callout')?.trim().toLowerCase();
if (type === undefined) return null;
const metadata = calloutEl.getAttribute('data-callout-metadata');
if (metadata === null) return null;
if (type === 'math') {
// legacy format
const settings = JSON.parse(metadata) as TheoremCalloutSettings;
// @ts-ignore
delete settings['_index']; // do not use the legacy "_index" value
return settings;
}
// new format
if (type.length <= 4) { // use length to avoid iterating over all the prefixes
// convert a prefix to an ID (e.g. "thm" -> "theorem")
type = THEOREM_LIKE_ENV_PREFIX_ID_MAP[type as TheoremLikeEnvPrefix];
}
const number = parseTheoremCalloutMetadata(metadata);
const title = '' // calloutEl.querySelector<HTMLElement>('.callout-title-inner')?.textContent?.trim();
return { type, number, title }
}

View file

@ -0,0 +1,96 @@
import { StateField, EditorState, Transaction, RangeSet, RangeValue, Range, Text } from '@codemirror/state';
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
import LatexReferencer from 'main';
import { readTheoremCalloutSettings } from 'utils/parse';
import { editorInfoField } from 'obsidian';
export const CALLOUT = /HyperMD-callout_HyperMD-quote_HyperMD-quote-([1-9][0-9]*)/;
export class TheoremCalloutInfo extends RangeValue {
constructor(public index: number | null) {
super();
}
}
export const createTheoremCalloutsField = (plugin: LatexReferencer) => StateField.define<RangeSet<TheoremCalloutInfo>>({
create(state: EditorState) {
// Since because canvas files cannot be indexed currently,
// do not number theorems in canvas to make live preview consistent with reading view
if (!state.field(editorInfoField).file) return RangeSet.empty;
const ranges = getTheoremCalloutInfos(plugin, state, state.doc, 0, 0);
return RangeSet.of(ranges);
},
update(value: RangeSet<TheoremCalloutInfo>, tr: Transaction) {
// Since because canvas files cannot be indexed currently,
// do not number theorems in canvas to make live preview consistent with reading view
if (!tr.state.field(editorInfoField).file) return RangeSet.empty;
// Because the field can be perfectly determined by the document content,
// we don't need to update it when the document is not changed
if (!tr.docChanged) return value;
// In order to make the updates efficient, we only update the theorem callout infos that are affected by the changes,
// that is, theorem callouts after the insertion point.
// 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)
let minChangedPosition = tr.newDoc.length - 1;
const changeDesc = tr.changes.desc;
changeDesc.iterChangedRanges((fromA, toA, fromB, toB) => {
if (fromB < minChangedPosition) {
minChangedPosition = fromB;
}
});
value = value.map(changeDesc);
let init = 0;
value.between(0, minChangedPosition, (from, to, info) => {
if (to < minChangedPosition && info.index !== null) init = info.index + 1;
});
const updatedRanges = getTheoremCalloutInfos(plugin, tr.state, tr.newDoc, minChangedPosition, init);
return value.update({
add: updatedRanges,
filter: () => false,
filterFrom: minChangedPosition,
});
}
});
function getTheoremCalloutInfos(plugin: LatexReferencer, state: EditorState, doc: Text, from: number, init: number): Range<TheoremCalloutInfo>[] {
const ranges: Range<TheoremCalloutInfo>[] = [];
// syntaxTree returns a potentially imcomplete tree (limited by viewport), so we need to ensure it's complete
const tree = ensureSyntaxTree(state, doc.length) ?? syntaxTree(state);
let theoremIndex = init; // incremented when a auto-numbered theorem is found
tree.iterate({
from, to: doc.length,
enter(node) {
if (node.name === 'Document') return; // skip the node for the entire document
if (node.node.parent?.name !== 'Document') return false; // skip sub-nodes of a line
const text = doc.sliceString(node.from, node.to);
const match = node.name.match(CALLOUT);
if (!match) return false;
const settings = readTheoremCalloutSettings(text, plugin.extraSettings.excludeExampleCallout);
if (!settings) return false;
const value = new TheoremCalloutInfo(settings.number === 'auto' ? theoremIndex++ : null);
const range = value.range(node.from, node.to);
ranges.push(range);
return false;
}
});
return ranges;
}

View file

@ -0,0 +1,34 @@
import { PluginValue, EditorView, ViewUpdate, ViewPlugin } from '@codemirror/view';
import LatexReferencer from 'main';
import { MathIndex } from 'index/math-index';
export const createTheoremCalloutNumberingViewPlugin = (plugin: LatexReferencer) => ViewPlugin.fromClass(
class implements PluginValue {
index: MathIndex = plugin.indexManager.index;
constructor(public view: EditorView) {
// Wait until the initial rendering is done so that we can find the callout elements using qeurySelectorAll().
setTimeout(() => this._update(view));
}
update(update: ViewUpdate) {
this._update(update.view);
}
_update(view: EditorView) {
const infos = view.state.field(plugin.theoremCalloutsField);
for (const calloutEl of view.contentDOM.querySelectorAll<HTMLElement>('.callout.theorem-callout')) {
const pos = view.posAtDOM(calloutEl);
const iter = infos.iter(pos);
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
const index = iter.value?.index;
if (typeof index === 'number') calloutEl.setAttribute('data-theorem-index', String(index));
else calloutEl.removeAttribute('data-theorem-index');
}
}
}
);

Some files were not shown because too many files have changed in this diff Show more