mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
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:
parent
451ff80edf
commit
a460ca5bea
4 changed files with 308 additions and 0 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
266
src/features/tikz/renderer.ts
Normal file
266
src/features/tikz/renderer.ts
Normal 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(" ", "");
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
10
src/main.ts
10
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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue