mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
479 lines
20 KiB
TypeScript
479 lines
20 KiB
TypeScript
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 }
|
|
}
|