fix(callout): Implement custom rendering for equation links and numbers in dynamically rendered callouts.

This commit is contained in:
JK 2026-03-06 18:35:56 +01:00
parent 94cd23a4c3
commit a3d9e1e494
10 changed files with 210 additions and 47 deletions

View file

@ -53,4 +53,5 @@ if (prod) {
await context.watch();
}
fs.copyFileSync("src/styles/main.css", "../plugin-full-calendar/obsidian-dev-vault/.obsidian/plugins/Latex-like-equations/styles.css");
fs.copyFileSync("src/styles/main.css", "../plugin-full-calendar/obsidian-dev-vault/.obsidian/plugins/Latex-like-equations/styles.css");
fs.copyFileSync("./manifest.json", "../plugin-full-calendar/obsidian-dev-vault/.obsidian/plugins/Latex-like-equations/manifest.json");

View file

@ -1,12 +1,12 @@
{
"id": "math-booster",
"id": "latex-referencer",
"name": "LaTeX Equation Referencer",
"version": "2.2.4",
"version": "2.2.5",
"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",
"author": "Jovi Koikkara",
"authorUrl": "https://github.com/YouFoundJK",
"fundingUrl": "https://github.com/YouFoundJK",
"helpUrl": "https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/",
"isDesktopOnly": false
}

21
package-lock.json generated
View file

@ -1,15 +1,16 @@
{
"name": "obsidian-math-booster",
"version": "2.2.4",
"version": "2.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-math-booster",
"version": "2.2.0",
"version": "2.2.4",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.5.4",
"@lezer/common": "^1.0.3",
"@lucide/svelte": "^0.562.0",
"esbuild": "^0.25.12",
@ -59,9 +60,13 @@
}
},
"node_modules/@codemirror/state": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz",
"integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw=="
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.18.1",
@ -660,6 +665,12 @@
"svelte": "^5"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-math-booster",
"version": "2.2.4",
"version": "2.2.5",
"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",
@ -31,6 +31,7 @@
},
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.5.4",
"@lezer/common": "^1.0.3",
"@lucide/svelte": "^0.562.0",
"esbuild": "^0.25.12",

View file

@ -7,7 +7,6 @@ import LatexReferencer from 'main';
import { processActiveNoteEquations } from './numbering';
import { EquationBlock } from 'types';
export const createEquationNumberProcessor = (plugin: LatexReferencer): MarkdownPostProcessor => {
return (el, ctx) => {
const file = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);

View file

@ -0,0 +1,119 @@
/**
* DOM Observer for Callout Equation Links
*
* When Obsidian renders a callout in Live Preview (cursor leaves the callout),
* it replaces the CodeMirror content with a native HTML Block Widget. This
* native rendering bypasses MarkdownPostProcessor hooks, so our equation link
* rendering never fires.
*
* Additionally, Obsidian copies the 'math-link-processed' class from the
* CodeMirror state into the callout HTML, which means we must strip it
* before reprocessing.
*
* This observer watches for DOM mutations and manually processes equation
* links inside dynamically rendered callout blocks. It also performs an
* initial scan on setup to catch callouts rendered before the cache was ready.
*/
import { MarkdownView } from "obsidian";
import LatexReferencer from "main";
import { processInternalLink } from "./reading-view-linker";
/**
* Process all equation links within a given HTML element, stripping any
* stale 'math-link-processed' class and reprocessing them.
*/
function processEquationLinksInElement(node: HTMLElement, plugin: LatexReferencer): void {
let sourcePath: string | undefined = undefined;
const resolveSourcePath = () => {
if (sourcePath) return;
const leaves = plugin.app.workspace.getLeavesOfType("markdown");
for (const leaf of leaves) {
if (leaf.view.containerEl.contains(node)) {
sourcePath = (leaf.view as MarkdownView).file?.path;
break;
}
}
if (!sourcePath) {
const active = plugin.app.workspace.getActiveViewOfType(MarkdownView);
sourcePath = active?.file?.path;
}
};
const equationLinks = node.querySelectorAll?.('a.internal-link') as NodeListOf<HTMLAnchorElement>;
if (equationLinks && equationLinks.length > 0) {
for (let i = 0; i < equationLinks.length; i++) {
const link = equationLinks[i];
const dataHref = link.getAttribute('data-href');
if (dataHref && dataHref.includes('#^eq-')) {
link.classList.remove('math-link-processed');
resolveSourcePath();
if (sourcePath) {
processInternalLink(link, plugin, sourcePath);
}
}
}
}
// The node itself could be the link
if (node.matches && node.matches('a.internal-link')) {
const dataHref = (node as HTMLAnchorElement).getAttribute('data-href');
if (dataHref && dataHref.includes('#^eq-')) {
node.classList.remove('math-link-processed');
resolveSourcePath();
if (sourcePath) {
processInternalLink(node as HTMLAnchorElement, plugin, sourcePath);
}
}
}
}
/**
* Scan all existing callout blocks in the DOM and process any equation links.
* This handles callouts that were rendered before the equation cache was ready
* (e.g., on initial page load).
*/
function scanExistingCallouts(plugin: LatexReferencer): void {
const calloutBlocks = document.querySelectorAll('.cm-embed-block.cm-callout');
for (const block of calloutBlocks) {
if (block instanceof HTMLElement) {
processEquationLinksInElement(block, plugin);
}
}
}
/**
* Sets up a MutationObserver on document.body to detect callout rendering
* and reprocess equation links within them. Also performs an initial scan
* after a delay to catch callouts rendered before the cache was ready.
*
* @param plugin - The LatexReferencer plugin instance
* @returns A cleanup function that disconnects the observer
*/
export function setupDOMObserver(plugin: LatexReferencer): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
if (!node.querySelector && !node.matches) continue;
processEquationLinksInElement(node, plugin);
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial scan: process callouts that were rendered before the observer
// started or before the equation cache was ready. We use onLayoutReady
// to ensure the DOM is fully initialized, plus a small delay to ensure
// the equation cache has been built.
plugin.app.workspace.onLayoutReady(() => {
setTimeout(() => scanExistingCallouts(plugin), 1000);
});
return () => observer.disconnect();
}

View file

@ -1,5 +1,5 @@
import { editorLivePreviewField, finishRenderMath, renderMath, editorInfoField } from "obsidian";
import { EditorSelection, RangeSetBuilder, Extension } from "@codemirror/state";
import { EditorSelection, RangeSetBuilder, Extension, Prec } from "@codemirror/state";
import { Decoration, DecorationSet, ViewUpdate, EditorView, ViewPlugin, PluginValue, WidgetType } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import { getMathLink } from "./helper";
@ -21,22 +21,22 @@ function selectionAndRangeOverlap(selection: EditorSelection, rangeFrom: number,
* A helper function to render a string with inline math.
*/
function setMathLink(source: string, mathLinkEl: HTMLElement) {
mathLinkEl.replaceChildren();
const mathPattern = /\$(.*?[^\s])\$/g;
let textFrom = 0, textTo = 0;
let result;
while ((result = mathPattern.exec(source)) !== null) {
const mathString = result[1];
textTo = result.index;
if (textTo > textFrom) mathLinkEl.createSpan().replaceWith(source.slice(textFrom, textTo));
mathLinkEl.replaceChildren();
const mathPattern = /\$(.*?[^\s])\$/g;
let textFrom = 0, textTo = 0;
let result;
while ((result = mathPattern.exec(source)) !== null) {
const mathString = result[1];
textTo = result.index;
if (textTo > textFrom) mathLinkEl.createSpan().replaceWith(source.slice(textFrom, textTo));
const mathEl = renderMath(mathString, false);
mathLinkEl.createSpan({ cls: ["math", "math-inline", "is-loaded"] }).replaceWith(mathEl);
const mathEl = renderMath(mathString, false);
mathLinkEl.createSpan({ cls: ["math", "math-inline", "is-loaded"] }).replaceWith(mathEl);
textFrom = mathPattern.lastIndex;
}
textFrom = mathPattern.lastIndex;
}
if (textFrom < source.length) mathLinkEl.createSpan().replaceWith(source.slice(textFrom));
if (textFrom < source.length) mathLinkEl.createSpan().replaceWith(source.slice(textFrom));
}
/** Given a LatexReferencer plugin instance, create a CodeMirror6 view plugin that renders equation links. */
@ -44,12 +44,12 @@ export const createLivePreviewLinkRendererPlugin = (plugin: LatexReferencer): Ex
const { app } = plugin;
class MathWidget extends WidgetType {
constructor(public outLinkText: string, public outLinkMathLink: string, public sourcePath: string) {
constructor(public outLinkText: string, public outLinkMathLink: string, public sourcePath: string, public from: number, public to: number) {
super();
}
eq(other: MathWidget) {
return this.outLinkText === other.outLinkText && this.outLinkMathLink === other.outLinkMathLink && this.sourcePath === other.sourcePath;
return this.outLinkText === other.outLinkText && this.outLinkMathLink === other.outLinkMathLink && this.sourcePath === other.sourcePath && this.from === other.from && this.to === other.to;
}
toDOM() {
@ -127,18 +127,16 @@ export const createLivePreviewLinkRendererPlugin = (plugin: LatexReferencer): Ex
endNode?.name.includes("formatting-link-end")
) {
const linkText = state.sliceDoc(linkNode.from, linkNode.to);
if (linkText.startsWith("#^eq-")) {
const start = startNode.from;
const end = endNode.to;
const outLinkMathLink = getMathLink(plugin, linkText, sourcePath);
if (outLinkMathLink && !selectionAndRangeOverlap(state.selection, start, end)) {
if (outLinkMathLink && !selectionAndRangeOverlap(state.selection, linkNode.from, linkNode.to)) {
builder.add(
start,
end,
linkNode.from,
linkNode.to,
Decoration.replace({
widget: new MathWidget(linkText, outLinkMathLink, sourcePath),
widget: new MathWidget(linkText, outLinkMathLink, sourcePath, linkNode.from, linkNode.to),
})
);
}
@ -156,6 +154,5 @@ export const createLivePreviewLinkRendererPlugin = (plugin: LatexReferencer): Ex
}
}, { decorations: v => v.decorations }
);
return viewPlugin;
return Prec.highest(viewPlugin);
}

View file

@ -48,25 +48,38 @@ export class LatexRenderChild extends MarkdownRenderChild {
if (mathLink) {
// The containerEl is now the link element itself
const linkEl = this.containerEl as HTMLElement;
// Verify it's still in the DOM (though MarkdownRenderChild handles unloading)
if (linkEl.isConnected) {
setMathLink(mathLink, linkEl);
}
setMathLink(mathLink, linkEl);
}
finishRenderMath();
}
}
/**
* Manually process a single internal link element, creating a LatexRenderChild
* and calling onload() to render the equation number. Used by the DOM observer
* to handle links inside dynamically rendered callouts.
*/
export const processInternalLink = (link: HTMLAnchorElement, plugin: LatexReferencer, sourcePath: string) => {
if (link.classList.contains("math-link-processed")) return;
const dataHref = link.getAttribute('data-href');
if (dataHref && dataHref.includes('#^eq-')) {
link.classList.add("math-link-processed");
const child = new LatexRenderChild(link, plugin, sourcePath, dataHref);
child.onload();
}
};
export const CustomMathLinksProcessor = (plugin: LatexReferencer): MarkdownPostProcessor => {
return (element: HTMLElement, context: MarkdownPostProcessorContext) => {
const links = element.querySelectorAll<HTMLAnchorElement>('a.internal-link');
for (const link of links) {
const href = link.getAttribute('data-href');
if (href && href.contains('#^eq-')) {
// This is one of our equation links.
// Pass the specific link element as the container to scope the child correctly.
if (link.classList.contains("math-link-processed")) continue;
const dataHref = link.getAttribute('data-href');
if (dataHref && dataHref.includes('#^eq-')) {
link.classList.add("math-link-processed");
context.addChild(
new LatexRenderChild(link, plugin, context.sourcePath, href)
new LatexRenderChild(link, plugin, context.sourcePath, dataHref)
);
}
}

View file

@ -11,6 +11,7 @@ import { Provider } from './features/linker/provider-link-render';
import { LatexLinkProvider } from 'latex-provider';
import { createEquationNumberProcessor } from 'features/equations/reading-view-equations';
import { CustomMathLinksProcessor } from './features/linker/reading-view-linker';
import { setupDOMObserver } from './features/linker/dom-observer';
import { createEquationNumberPlugin } from 'features/equations/live-preview-equations';
import { createLivePreviewLinkRendererPlugin } from './features/linker/live-preview-link-renderer';
@ -27,6 +28,7 @@ import { checkAndFixCalloutMath } from 'utils/fixer';
import { traverseFolder } from "./features/export-pdf/utils";
import { SnippetManager } from 'features/snippets/manager';
const isDev = process.env.NODE_ENV === "development";
export default class LatexReferencer extends Plugin {
@ -190,6 +192,7 @@ export default class LatexReferencer extends Plugin {
this.app.workspace.onLayoutReady(() => this.forceRerender());
this.patchPagePreview();
this.register(setupDOMObserver(this));
}
async loadSettings() {
@ -260,6 +263,9 @@ export default class LatexReferencer extends Plugin {
this.register(uninstaller);
}
async generateToc(root: TFolder | TFile) {
// @ts-ignore
const basePath = this.app.vault.adapter.basePath;

16
src/utils/debug.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* A simple synchronous file logger used for intense UI diagnostic tracing.
*
* Usage:
* Inject `logDebug("My message")` aggressively into CodeMirror rendering loops
* or DOM observers to output an absolute timeline of execution to `debug.log`.
* This is especially useful for capturing logs in contexts where the Obsidian
* Developer Console might be silenced, asynchronous, or overwhelmed.
*/
import * as fs from "fs";
export function logDebug(msg: string) {
try {
fs.appendFileSync("d:\\Codes\\plugin-latex-referencer\\debug.log", msg + "\n", { encoding: "utf-8" });
} catch (e) { }
}