diff --git a/src/features/settings/settings.ts b/src/features/settings/settings.ts index 6937d44..68a0e20 100644 --- a/src/features/settings/settings.ts +++ b/src/features/settings/settings.ts @@ -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 = { // Custom Note Hotkeys customNoteHotkeys: [], + + // TikZJax Settings + enableTikzjax: true, + invertColorsInDarkMode: true, }; diff --git a/src/features/settings/tab.ts b/src/features/settings/tab.ts index 1fc4d6e..b549217 100644 --- a/src/features/settings/tab.ts +++ b/src/features/settings/tab.ts @@ -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) diff --git a/src/features/tikz/renderer.ts b/src/features/tikz/renderer.ts new file mode 100644 index 0000000..1b1210f --- /dev/null +++ b/src/features/tikz/renderer.ts @@ -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 { + 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(" ", ""); + + 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; + }); + } +} diff --git a/src/main.ts b/src/main.ts index 6bbcbc7..5830ee2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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()); }