feat: implement robust offline-first TikZ diagram rendering support

- Added a modular `TikzRenderer` in `src/features/tikz/renderer.ts` to process `tikz` code blocks.
- Fetches and caches the offline-friendly, self-contained 7MB TikZJax JS engine from artisticat1/obsidian-tikzjax.
- Dynamically patches the engine's initialization to execute immediately (bypassing window.onload and document.readyState race conditions).
- Implements a programmatic `TikzJaxCleanup` function that terminates WebAssembly worker pools and disconnects MutationObservers when the plugin unloads, supporting safe Hot Reloads.
- Simplifies block element styling to render transparently and center-aligned, matching standard notes.
- Added Settings UI controls (`enableTikzjax` and `invertColorsInDarkMode`) to manage diagram color inversion and toggle rendering status.
- Integrated `TikzRenderer` setup and cleanup into the main plugin lifecycle.
This commit is contained in:
JK 2026-06-04 13:30:53 +02:00
parent 451ff80edf
commit a460ca5bea
4 changed files with 308 additions and 0 deletions

View file

@ -66,6 +66,10 @@ export interface PluginSettings {
// Custom Note Hotkeys
customNoteHotkeys: CustomNoteHotkey[];
// TikZJax Settings
enableTikzjax: boolean;
invertColorsInDarkMode: boolean;
}
export interface CustomNoteHotkey {
@ -136,4 +140,8 @@ export const DEFAULT_SETTINGS: Required<PluginSettings> = {
// Custom Note Hotkeys
customNoteHotkeys: [],
// TikZJax Settings
enableTikzjax: true,
invertColorsInDarkMode: true,
};

View file

@ -274,6 +274,30 @@ export class MathSettingTab extends PluginSettingTab {
});
});
containerEl.createEl("h2", { text: "TikZJax Rendering" });
new Setting(containerEl)
.setName("Enable TikZ rendering")
.setDesc("Renders ```tikz code blocks into diagrams using TikZJax (requires an internet connection on first run to fetch WebAssembly resources).")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableTikzjax)
.onChange(async value => {
this.plugin.settings.enableTikzjax = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Invert dark colors in dark mode")
.setDesc("Automatically maps hardcoded dark colors (like black) to currentColor so they adapt to Obsidian's dark theme.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.invertColorsInDarkMode)
.onChange(async value => {
this.plugin.settings.invertColorsInDarkMode = value;
await this.plugin.saveSettings();
})
);
containerEl.createEl("h2", { text: "Zotero Cleanup" });
new Setting(containerEl)

View file

@ -0,0 +1,266 @@
import { Notice, requestUrl } from "obsidian";
import LatexReferencer from "../../main";
export class TikzRenderer {
private tikzjaxJs: string = "";
private tikzjaxCss: string = "";
private isLoaded: boolean = false;
constructor(public plugin: LatexReferencer) {}
async onLoad() {
if (!this.plugin.settings.enableTikzjax) {
return;
}
try {
await this.ensureResourcesCached();
await this.loadResourcesFromCache();
// Support pop-out windows and main window
this.plugin.app.workspace.onLayoutReady(() => {
this.loadTikZJaxAllWindows();
this.plugin.registerEvent(
this.plugin.app.workspace.on("window-open", (win, window) => {
this.loadTikZJax(window.document);
})
);
});
this.registerCodeBlockProcessor();
this.isLoaded = true;
} catch (error) {
console.error("Latex Referencer: Failed to initialize TikZJax rendering", error);
new Notice("Failed to initialize TikZJax diagram rendering.");
}
}
onUnload() {
if (!this.isLoaded) return;
this.unloadTikZJaxAllWindows();
}
private async fetchWithFallback(urls: string[]): Promise<string> {
let lastError: any = null;
for (const url of urls) {
try {
const res = await requestUrl({
url,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
"Accept": "*/*",
"Referer": "https://tikzjax.com/"
}
});
if (res.status === 200) {
return res.text;
}
throw new Error(`Request failed with status ${res.status}`);
} catch (err) {
console.warn(`Latex Referencer: Failed to fetch TikZJax asset from ${url}. Trying next fallback.`, err);
lastError = err;
}
}
throw lastError || new Error("All download attempts failed.");
}
private async ensureResourcesCached() {
const adapter = this.plugin.app.vault.adapter;
const pluginDir = this.plugin.manifest.dir;
if (!pluginDir) {
throw new Error("Plugin manifest directory is not defined.");
}
const jsPath = `${pluginDir}/tikzjax.js`;
// If the cached file is the wrong one (official 458KB instead of 7MB offline version), delete it to trigger redownload
if (await adapter.exists(jsPath)) {
const content = await adapter.read(jsPath);
if (!content.includes("MutationObserver")) {
new Notice("Outdated TikZJax engine detected. Clearing cache to fetch offline-capable version...");
await adapter.remove(jsPath);
}
}
// Check and fetch tikzjax.js
if (!(await adapter.exists(jsPath))) {
new Notice("Downloading TikZJax engine (one-time setup)...");
const jsUrls = [
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@main/tikzjax.js",
"https://raw.githubusercontent.com/artisticat1/obsidian-tikzjax/main/tikzjax.js"
];
try {
const text = await this.fetchWithFallback(jsUrls);
await adapter.write(jsPath, text);
new Notice("TikZJax engine cached successfully.");
} catch (err) {
console.error("Failed to download tikzjax.js", err);
throw new Error("Failed to download TikZJax JavaScript resource.");
}
}
}
private async loadResourcesFromCache() {
const adapter = this.plugin.app.vault.adapter;
const pluginDir = this.plugin.manifest.dir;
if (!pluginDir) return;
let jsText = await adapter.read(`${pluginDir}/tikzjax.js`);
// Patch tikzjax.js to handle dynamic execution, reload, and proper cleanup
const originalTrigger = `"complete"==document.readyState?w():window.addEventListener("load",w),window.addEventListener("unload",(async function(){u&&u.disconnect(),await e.terminate(await H)}))`;
const patchedTrigger = `window.TikzJaxCleanup=async function(){u&&u.disconnect();if(H){try{await e.terminate(await H)}catch(err){}}window.TikzJax=undefined;window.TikzJaxCleanup=undefined;},document.readyState!=="loading"?w():window.addEventListener("load",w),window.addEventListener("unload",(async function(){if(window.TikzJaxCleanup)await window.TikzJaxCleanup();}))`;
if (jsText.includes(originalTrigger)) {
jsText = jsText.replace(originalTrigger, patchedTrigger);
} else {
console.warn("Latex Referencer: Could not find original trigger in tikzjax.js for patching.");
}
this.tikzjaxJs = jsText;
}
private loadTikZJax(doc: Document) {
if (doc.getElementById("tikzjax")) return;
// Ingest Custom Styling to integrate seamlessly with the note theme
const customStyle = doc.createElement("style");
customStyle.id = "tikzjax-custom-styles";
customStyle.textContent = `
.block-language-tikz {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem 0;
margin: 1.5em 0;
overflow-x: auto;
background-color: transparent;
}
.block-language-tikz svg {
max-width: 100%;
height: auto;
color: var(--text-normal);
}
.block-language-tikz .tikzjax-error {
color: var(--text-error);
font-family: var(--font-monospace);
font-size: 0.9em;
padding: 1rem;
}
`;
doc.head.appendChild(customStyle);
// Ingest JS Script
const script = doc.createElement("script");
script.id = "tikzjax";
script.type = "text/javascript";
script.textContent = this.tikzjaxJs;
doc.body.appendChild(script);
doc.addEventListener("tikzjax-load-finished", this.postProcessSvg);
}
private unloadTikZJax(doc: Document) {
// Trigger the cleanup function in the document's window context if it exists
const win = doc.defaultView as any;
if (win && typeof win.TikzJaxCleanup === "function") {
win.TikzJaxCleanup().catch((err: any) => console.error("Latex Referencer: Error cleaning up TikZJax", err));
}
doc.getElementById("tikzjax")?.remove();
doc.getElementById("tikzjax-css")?.remove();
doc.getElementById("tikzjax-custom-styles")?.remove();
doc.removeEventListener("tikzjax-load-finished", this.postProcessSvg);
}
private loadTikZJaxAllWindows() {
for (const win of this.getAllWindows()) {
this.loadTikZJax(win.document);
}
}
private unloadTikZJaxAllWindows() {
for (const win of this.getAllWindows()) {
this.unloadTikZJax(win.document);
}
}
private getAllWindows(): Window[] {
const windows: Window[] = [];
if (typeof window !== "undefined") {
windows.push(window);
}
// Retrieve pop-out windows from workspace
const workspace = this.plugin.app.workspace as any;
const floatingSplit = workspace.floatingSplit;
if (floatingSplit && floatingSplit.children) {
for (const child of floatingSplit.children) {
const win = child.view?.containerEl?.win;
if (win && !windows.includes(win)) {
windows.push(win);
}
}
}
return windows;
}
private postProcessSvg = (e: Event) => {
if (!this.plugin.settings.invertColorsInDarkMode) return;
const svg = e.target as SVGElement;
if (!svg || svg.tagName.toLowerCase() !== "svg") return;
// Ensure text and lines adapt cleanly in dark mode
const elements = svg.querySelectorAll("[stroke], [fill]");
elements.forEach((el) => {
const stroke = el.getAttribute("stroke");
if (stroke === "black" || stroke === "#000" || stroke === "#000000") {
el.setAttribute("stroke", "currentColor");
}
const fill = el.getAttribute("fill");
if (fill === "black" || fill === "#000" || fill === "#000000") {
el.setAttribute("fill", "currentColor");
}
});
};
private tidyTikzSource(tikzSource: string): string {
// Remove non-breaking space characters, otherwise we get errors
tikzSource = tikzSource.replaceAll("&nbsp;", "");
let lines = tikzSource.split("\n");
// Trim whitespace and remove empty lines
lines = lines.map(line => line.trim()).filter(line => line);
return lines.join("\n");
}
private registerCodeBlockProcessor() {
this.plugin.registerMarkdownCodeBlockProcessor("tikz", (source, el, ctx) => {
el.empty();
if (!this.plugin.settings.enableTikzjax) {
const pre = el.createEl("pre");
const code = pre.createEl("code");
code.textContent = source;
return;
}
let code = this.tidyTikzSource(source);
// Wrap in LaTeX document template if not already present
if (!code.includes("\\begin{document}")) {
code = "\\begin{document}\n" + code + "\n\\end{document}";
}
const script = el.createEl("script");
script.setAttribute("type", "text/tikz");
script.setAttribute("data-show-console", "true");
script.textContent = code;
});
}
}

View file

@ -29,6 +29,7 @@ import { traverseFolder } from "./features/export-pdf/utils";
import { SnippetManager } from 'features/snippets/manager';
import { processZoteroCleanup } from 'features/zotero-cleanup';
import { CustomNoteManager } from 'features/custom-notes/manager';
import { TikzRenderer } from "./features/tikz/renderer";
const isDev = process.env.NODE_ENV === "development";
@ -39,6 +40,7 @@ export default class LatexReferencer extends Plugin {
internalProviders: Provider[] = [];
snippetManager: SnippetManager;
customNoteManager: CustomNoteManager;
tikzRenderer: TikzRenderer;
async onload() {
await this.loadSettings();
@ -53,6 +55,10 @@ export default class LatexReferencer extends Plugin {
this.customNoteManager = new CustomNoteManager(this);
this.customNoteManager.onLoad();
// TikZJax Rendering
this.tikzRenderer = new TikzRenderer(this);
await this.tikzRenderer.onLoad();
this.addSettingTab(new MathSettingTab(this.app, this));
// Commands
@ -192,6 +198,10 @@ export default class LatexReferencer extends Plugin {
this.register(setupDOMObserver(this));
}
onunload() {
this.tikzRenderer.onUnload();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}