feat(settings): redesign settings tab with categories, search, i18n, and what's new modal

- Redesigned the settings tab to support structured categories (General, PDF Export, TikZJax, Custom Notes, and What's New)
- Implemented a search-based filter in settings that dynamically hides/shows items and highlights matching terms
- Added i18n support with `en.json` and a type-safe `t()` utility to handle localized sentence-case labels
- Integrated a startup check and "What's New" modal to display the latest updates on first launch or version updates
- Declared global `activeDocument` and `activeWindow` properties on the Window interface to ensure strict TypeScript type-safety
- Fixed all ESLint issues including deprecated `display()` calls, manual HTML headings, sentence case warnings, and unused variables
- Added documentation links support and a feedback footer at the bottom of the settings tab
This commit is contained in:
JK 2026-06-05 14:18:45 +02:00
parent 3dc3333ab6
commit 7c08b356d4
13 changed files with 995 additions and 47 deletions

View file

@ -60,3 +60,10 @@ declare module 'obsidian' {
export type LeafArgs =
| [newLeaf?: PaneType | boolean]
| [newLeaf?: 'split', direction?: SplitDirection];
declare global {
interface Window {
activeDocument: Document;
activeWindow: Window;
}
}

33
src/i18n/en.json Normal file
View file

@ -0,0 +1,33 @@
{
"settings": {
"title": "ObsiTeXcore settings",
"tab": {
"general": "General",
"generalDesc": "Configure equation numbering, referencing, autocomplete, and Zotero clean up settings.",
"pdf": "PDF Export",
"pdfDesc": "Configure layout, templates, and rendering limits for PDF exports.",
"tikz": "TikZJax",
"tikzDesc": "Configure TikZ rendering options and dark mode behavior.",
"hotkeys": "Custom Notes",
"hotkeysDesc": "Configure keyboard shortcuts to quickly open specific notes.",
"changelog": "What's new",
"changelogDesc": "View recent changes and new features."
},
"header": {
"whatsNew": "What's new"
},
"footer": {
"question": "Do you like what you see?",
"supportOnKofi": "☕ Buy me a coffee",
"suggestFeature": "💡 Suggest a new feature",
"reportBug": "🐛 Raise an issue"
}
},
"modals": {
"whatsNew": {
"title": "What's new in ObsiTeXcore",
"openSettings": "Open settings",
"supportKofi": "Support development / ko-fi"
}
}
}

23
src/i18n/t.ts Normal file
View file

@ -0,0 +1,23 @@
import en from './en.json';
export function t(key: string, vars?: Record<string, string>): string {
const parts = key.split('.');
let current: unknown = en;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return key;
}
}
if (typeof current !== 'string') {
return key;
}
let result: string = current;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
result = result.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return result;
}

View file

@ -59,6 +59,20 @@ export default class LatexReferencer extends Plugin {
async onload() {
await this.loadSettings();
// Check version and show What's New modal if upgraded/first install
const releaseVersion = this.manifest.version;
this.app.workspace.onLayoutReady(async () => {
if (
this.settings.currentVersion === null ||
this.settings.currentVersion !== releaseVersion
) {
const { WhatsNewModal } = await import('./ui/modals/WhatsNewModal');
new WhatsNewModal(this.app, this.manifest.id).open();
this.settings.currentVersion = releaseVersion;
await this.saveSettings();
}
});
this.internalProviders.push(new LatexLinkProvider(this));
// Snippets

View file

@ -0,0 +1,50 @@
/**
* @file changelogData.ts
* @brief Version changelog data for ObsiTeXcore.
*/
export interface ChangelogItem {
version: string;
date: string;
changes: {
type: 'new' | 'improvement' | 'fix';
description: string;
}[];
}
export const changelogData: ChangelogItem[] = [
{
version: '0.0.1',
date: '2026-06-04',
changes: [
{
type: 'new',
description: 'Equation Numbering: Automatic theorem environments and equation referencing.'
},
{
type: 'new',
description:
'LaTeX Autocomplete: Fast suggestions and search for equations/notes in your vault.'
},
{
type: 'new',
description:
'PDF Export: Custom print templates (headers, footers) with background graphics support.'
},
{
type: 'new',
description: 'TikZJax Rendering: Adapt diagrams dynamically to light and dark themes.'
},
{
type: 'new',
description:
'Custom Notes: Assign custom keyboard shortcuts to instantly open preferred notes.'
},
{
type: 'new',
description:
'Quick Preview: Inline equation and reference previewing directly in autocomplete lists.'
}
]
}
];

60
src/settings/docsLinks.ts Normal file
View file

@ -0,0 +1,60 @@
/**
* @file docsLinks.ts
* @brief Shared helpers for adding documentation links to settings descriptions.
*/
import {
createLinksFragment,
createMarkdownLinksFragment,
LinkItem,
linkItemsToSegments
} from './linkTextFragments';
export interface DocsLink {
text: string;
path: string;
}
const DOCS_ROOT = 'https://youfoundjk.github.io/ObsiTeXcore/';
export function toDocsUrl(path: string): string {
return `${DOCS_ROOT}${path.replace(/^\/+/, '')}`;
}
export function createDocsLinksFragment(
links: DocsLink[],
prefix = 'Learn more'
): DocumentFragment {
const doc = window.activeDocument ?? window.activeWindow?.document ?? window.document;
if (!doc) {
return new DocumentFragment();
}
if (links.length === 0) {
return doc.createDocumentFragment();
}
const fragment = doc.createDocumentFragment();
fragment.append(prefix, ' ');
const linkItems: LinkItem[] = links.map(link => ({
text: link.text,
href: toDocsUrl(link.path)
}));
fragment.append(createLinksFragment(linkItemsToSegments(linkItems), { betweenLinksText: ' | ' }));
return fragment;
}
export function createDescWithDocs(description: string, links: DocsLink[]): DocumentFragment {
const doc = window.activeDocument ?? window.activeWindow?.document ?? window.document;
if (!doc) {
return createMarkdownLinksFragment(description);
}
const fragment = doc.createDocumentFragment();
fragment.append(createMarkdownLinksFragment(description));
if (links.length > 0) {
fragment.append(' ');
fragment.append(createDocsLinksFragment(links));
}
return fragment;
}

View file

@ -0,0 +1,82 @@
/**
* @file linkTextFragments.ts
* @brief Shared helpers to render text with links as DocumentFragment segments.
*/
export type LinkTextSegment =
| { kind: 'text'; text: string }
| { kind: 'link'; text: string; href: string };
export interface LinkItem {
text: string;
href: string;
}
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
export function parseMarkdownLinks(text: string): LinkTextSegment[] {
const segments: LinkTextSegment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) {
const [fullMatch, linkText, href] = match;
if (match.index > lastIndex) {
segments.push({ kind: 'text', text: text.substring(lastIndex, match.index) });
}
segments.push({ kind: 'link', text: linkText, href });
lastIndex = match.index + fullMatch.length;
}
if (lastIndex < text.length) {
segments.push({ kind: 'text', text: text.substring(lastIndex) });
}
return segments;
}
export function createMarkdownLinksFragment(text: string): DocumentFragment {
return createLinksFragment(parseMarkdownLinks(text));
}
export function createLinksFragment(
segments: LinkTextSegment[],
options?: { betweenLinksText?: string }
): DocumentFragment {
const doc = window.activeDocument ?? window.activeWindow?.document ?? window.document;
if (!doc) {
return new DocumentFragment();
}
const fragment = doc.createDocumentFragment();
const betweenLinksText = options?.betweenLinksText;
let previousSegmentWasLink = false;
segments.forEach(segment => {
if (betweenLinksText && previousSegmentWasLink && segment.kind === 'link') {
fragment.append(betweenLinksText);
}
if (segment.kind === 'text') {
fragment.append(segment.text);
previousSegmentWasLink = false;
return;
}
const linkEl = doc.createElement('a');
linkEl.textContent = segment.text;
linkEl.href = segment.href;
linkEl.target = '_blank';
linkEl.rel = 'noopener';
fragment.append(linkEl);
previousSegmentWasLink = true;
});
return fragment;
}
export function linkItemsToSegments(items: LinkItem[]): LinkTextSegment[] {
return items.map(item => ({ kind: 'link', text: item.text, href: item.href }));
}

View file

@ -76,6 +76,9 @@ export interface PluginSettings {
// TikZJax Settings
enableTikzjax: boolean;
invertColorsInDarkMode: boolean;
// Version tracking
currentVersion: string | null;
}
export interface CustomNoteHotkey {
@ -87,6 +90,7 @@ export interface CustomNoteHotkey {
}
export const DEFAULT_SETTINGS: Required<PluginSettings> = {
currentVersion: null,
// Numbering
numberOnlyReferencedEquations: true,
eqNumberPrefix: '',

View file

@ -1,10 +1,54 @@
import { App, PluginSettingTab, Setting, TextAreaComponent } from 'obsidian';
import { App, PluginSettingTab, Setting, TextAreaComponent, setIcon } from 'obsidian';
import LatexReferencer from 'main';
import { NUMBER_STYLES } from './settings';
import { NoteSuggestModal } from '../ui/custom-notes/modal';
import { setCssProps } from 'utils/obsidian';
import { createDescWithDocs } from './docsLinks';
import { changelogData } from './changelogData';
import { t } from '../i18n/t';
type SettingsTabId = 'general' | 'pdf' | 'tikz' | 'hotkeys' | 'changelog';
interface TabCategory {
id: SettingsTabId;
labelKey: string;
descKey: string;
}
const TABS: TabCategory[] = [
{
id: 'general',
labelKey: 'settings.tab.general',
descKey: 'settings.tab.generalDesc'
},
{
id: 'pdf',
labelKey: 'settings.tab.pdf',
descKey: 'settings.tab.pdfDesc'
},
{
id: 'tikz',
labelKey: 'settings.tab.tikz',
descKey: 'settings.tab.tikzDesc'
},
{
id: 'hotkeys',
labelKey: 'settings.tab.hotkeys',
descKey: 'settings.tab.hotkeysDesc'
},
{
id: 'changelog',
labelKey: 'settings.tab.changelog',
descKey: 'settings.tab.changelogDesc'
}
];
export class MathSettingTab extends PluginSettingTab {
private activeTab: SettingsTabId = 'general';
private searchQuery = '';
private searchExpanded = false;
private searchDebounceId: number | null = null;
constructor(
app: App,
public plugin: LatexReferencer
@ -13,14 +57,199 @@ export class MathSettingTab extends PluginSettingTab {
}
display() {
this.render();
}
render() {
const { containerEl } = this;
containerEl.empty();
const shellEl = containerEl.createDiv('obsitexcore-settings-shell');
// Header
const headerEl = shellEl.createDiv('obsitexcore-settings-header');
new Setting(headerEl).setName(t('settings.title')).setHeading();
// Tabs & Search row
const tabsRowEl = shellEl.createDiv('obsitexcore-settings-tabs-row');
const tabsEl = tabsRowEl.createDiv('obsitexcore-settings-tabs');
TABS.forEach(tab => {
const isActive = tab.id === this.activeTab;
const button = tabsEl.createEl('button', {
cls: `full-calendar-settings-tab${isActive ? ' is-active' : ''}`,
text: t(tab.labelKey)
});
button.type = 'button';
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
button.addEventListener('click', () => {
if (this.activeTab === tab.id) return;
this.activeTab = tab.id;
this.render();
});
});
// Search wrap
const searchWrapEl = tabsRowEl.createDiv('obsitexcore-settings-search-wrap');
const searchButtonEl = searchWrapEl.createEl('button', {
cls: 'clickable-icon obsitexcore-settings-search-trigger'
});
searchButtonEl.type = 'button';
searchButtonEl.ariaLabel = 'Search settings';
setIcon(searchButtonEl, 'search');
const inputWrapEl = searchWrapEl.createDiv('obsitexcore-settings-search-input-wrap');
setCssProps(inputWrapEl, {
position: 'relative',
width: this.searchExpanded || this.searchQuery ? '170px' : '0px',
overflow: 'hidden',
transition: 'width 140ms ease'
});
const searchInputEl = inputWrapEl.createEl('input', {
cls: 'obsitexcore-settings-search-input'
});
searchInputEl.type = 'text';
searchInputEl.placeholder = 'Search settings...';
searchInputEl.value = this.searchQuery;
const clearButtonEl = inputWrapEl.createEl('button', {
cls: 'clickable-icon obsitexcore-settings-search-clear'
});
clearButtonEl.type = 'button';
clearButtonEl.ariaLabel = 'Clear search';
setCssProps(clearButtonEl, {
position: 'absolute',
right: '6px',
top: '50%',
transform: 'translateY(-50%)',
display: this.searchQuery ? 'inline-flex' : 'none'
});
setIcon(clearButtonEl, 'x');
const renderSearchResults = () => {
this.renderSettingsContent(contentEl);
setCssProps(clearButtonEl, { display: this.searchQuery ? 'inline-flex' : 'none' });
setCssProps(searchButtonEl, {
display: this.searchExpanded || !!this.searchQuery ? 'none' : 'inline-flex'
});
searchButtonEl.toggleClass('is-active', this.searchExpanded || !!this.searchQuery);
inputWrapEl.toggleClass('is-active-query', !!this.searchQuery);
};
searchButtonEl.addEventListener('click', () => {
this.searchExpanded = true;
setCssProps(inputWrapEl, { width: '170px' });
setCssProps(searchButtonEl, { display: 'none' });
searchInputEl.focus();
searchButtonEl.toggleClass('is-active', true);
});
searchInputEl.addEventListener('blur', () => {
if (this.searchQuery) return;
this.searchExpanded = false;
setCssProps(inputWrapEl, { width: '0px' });
setCssProps(searchButtonEl, { display: 'inline-flex' });
searchButtonEl.toggleClass('is-active', false);
});
searchInputEl.addEventListener('input', () => {
this.searchQuery = searchInputEl.value;
if (this.searchDebounceId !== null) {
window.clearTimeout(this.searchDebounceId);
}
this.searchDebounceId = window.setTimeout(renderSearchResults, 80);
});
clearButtonEl.addEventListener('mousedown', evt => {
evt.preventDefault();
this.searchQuery = '';
searchInputEl.value = '';
renderSearchResults();
searchInputEl.focus();
});
// Content Panel
const contentEl = shellEl.createDiv('obsitexcore-settings-content');
this.renderSettingsContent(contentEl);
// Footer
this.renderFooter(shellEl);
}
private renderSettingsContent(containerEl: HTMLElement): void {
containerEl.empty();
const query = this.searchQuery.trim();
if (!query) {
const activeTab = TABS.find(t => t.id === this.activeTab);
if (activeTab) {
const introEl = containerEl.createDiv('obsitexcore-settings-category-intro');
introEl.createEl('p', { text: t(activeTab.descKey) });
}
const panelEl = containerEl.createDiv('obsitexcore-settings-panel');
this.renderCategoryContent(this.activeTab, panelEl);
return;
}
let hasAnyMatches = false;
for (const tab of TABS) {
if (tab.id === 'changelog') continue; // Skip changelog in search filters
const sectionEl = containerEl.createDiv('obsitexcore-settings-search-section');
const introEl = sectionEl.createDiv('obsitexcore-settings-category-intro');
new Setting(introEl).setName(t(tab.labelKey)).setHeading();
introEl.createEl('p', { text: t(tab.descKey) });
const panelEl = sectionEl.createDiv('obsitexcore-settings-panel');
this.renderCategoryContent(tab.id, panelEl);
const sectionHasMatches = this.applySearchFilter(panelEl, query);
if (!sectionHasMatches) {
sectionEl.remove();
} else {
hasAnyMatches = true;
}
}
if (!hasAnyMatches) {
const emptyEl = containerEl.createDiv('obsitexcore-settings-search-empty');
emptyEl.createEl('p', { text: `No settings match "${query}".` });
}
}
private renderCategoryContent(tabId: SettingsTabId, panelEl: HTMLElement): void {
switch (tabId) {
case 'general':
this.renderGeneral(panelEl);
break;
case 'pdf':
this.renderPdf(panelEl);
break;
case 'tikz':
this.renderTikz(panelEl);
break;
case 'hotkeys':
this.renderHotkeys(panelEl);
break;
case 'changelog':
this.renderChangelog(panelEl);
break;
}
}
private renderGeneral(containerEl: HTMLElement): void {
new Setting(containerEl).setName('Equation numbering & referencing').setHeading();
new Setting(containerEl)
.setName('Number only referenced equations')
.setDesc('If turned on, only equations that are referenced somewhere will be numbered.')
.setDesc(
createDescWithDocs(
'If turned on, only equations that are referenced somewhere will be numbered.',
[{ text: 'Learn more', path: 'features/equations/' }]
)
)
.addToggle(toggle =>
toggle
.setValue(this.plugin.settings.numberOnlyReferencedEquations)
@ -90,12 +319,19 @@ export class MathSettingTab extends PluginSettingTab {
new Setting(containerEl).setName('Autocomplete & search').setHeading();
new Setting(containerEl).setName('Enable autocompletion').addToggle(toggle =>
toggle.setValue(this.plugin.settings.enableSuggest).onChange(async value => {
this.plugin.settings.enableSuggest = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Enable autocompletion')
.setDesc(
createDescWithDocs('Enable auto-suggestions for equations and theorems as you type.', [
{ text: 'Learn more', path: 'features/search/' }
])
)
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.enableSuggest).onChange(async value => {
this.plugin.settings.enableSuggest = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName('Trigger for autocompletion').addText(text =>
text.setValue(this.plugin.settings.triggerSuggest).onChange(async value => {
@ -111,34 +347,51 @@ export class MathSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl).setName('Zotero cleanup').setHeading();
new Setting(containerEl)
.setName('Directories to search')
.setDesc(
"Comma-separated list of directories to search recursively for Zotero annotations (e.g. 'Zotero,notes/readings')."
)
.addTextArea(textArea => {
textArea.setValue(this.plugin.settings.zoteroCleanDirectories).onChange(async value => {
this.plugin.settings.zoteroCleanDirectories = value;
await this.plugin.saveSettings();
});
textArea.inputEl.setAttr('rows', 3);
});
}
private renderPdf(containerEl: HTMLElement): void {
new Setting(containerEl).setName('Pdf export').setHeading();
new Setting(containerEl).setName('Add file name as title').addToggle(toggle =>
toggle
.setTooltip('Add file name as title')
.setValue(this.plugin.settings.showTitle)
.onChange(async value => {
new Setting(containerEl)
.setName('Add file name as title')
.setDesc(
createDescWithDocs('Add the current file name as the heading/title in exported PDFs.', [
{ text: 'Learn more', path: 'features/pdf-export/' }
])
)
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.showTitle).onChange(async value => {
this.plugin.settings.showTitle = value;
await this.plugin.saveSettings();
})
);
);
new Setting(containerEl).setName('Display headers').addToggle(toggle =>
toggle
.setTooltip('Display header')
.setValue(this.plugin.settings.displayHeader)
.onChange(async value => {
this.plugin.settings.displayHeader = value;
await this.plugin.saveSettings();
})
toggle.setValue(this.plugin.settings.displayHeader).onChange(async value => {
this.plugin.settings.displayHeader = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName('Display footer').addToggle(toggle =>
toggle
.setTooltip('Display footer')
.setValue(this.plugin.settings.displayFooter)
.onChange(async value => {
this.plugin.settings.displayFooter = value;
await this.plugin.saveSettings();
})
toggle.setValue(this.plugin.settings.displayFooter).onChange(async value => {
this.plugin.settings.displayFooter = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
@ -263,7 +516,9 @@ export class MathSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
}
private renderTikz(containerEl: HTMLElement): void {
new Setting(containerEl).setName('TikZJax rendering').setHeading();
new Setting(containerEl)
@ -289,22 +544,9 @@ export class MathSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
})
);
}
new Setting(containerEl).setName('Zotero cleanup').setHeading();
new Setting(containerEl)
.setName('Directories to search')
.setDesc(
"Comma-separated list of directories to search recursively for Zotero annotations (e.g. 'Zotero,notes/readings')."
)
.addTextArea(textArea => {
textArea.setValue(this.plugin.settings.zoteroCleanDirectories).onChange(async value => {
this.plugin.settings.zoteroCleanDirectories = value;
await this.plugin.saveSettings();
});
textArea.inputEl.setAttr('rows', 3);
});
private renderHotkeys(containerEl: HTMLElement): void {
new Setting(containerEl).setName('Custom note hotkeys').setHeading();
containerEl.createEl('p', {
text: "Configure hotkeys to quickly open specific notes in your vault. You can define optional default hotkeys here, and further customize or rebind them within Obsidian's global 'hotkeys' settings.",
@ -313,7 +555,11 @@ export class MathSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Add new hotkey mapping')
.setDesc('Create a new shortcut command to open a specific note.')
.setDesc(
createDescWithDocs('Create a new shortcut command to open a specific note.', [
{ text: 'Learn more', path: 'features/snippets/' }
])
)
.addButton(btn =>
btn.onClick(async () => {
if (!this.plugin.settings.customNoteHotkeys) {
@ -328,7 +574,7 @@ export class MathSettingTab extends PluginSettingTab {
});
await this.plugin.saveSettings();
this.plugin.customNoteManager.registerCommands();
(this as unknown as { display(): void }).display();
this.render();
})
);
@ -367,7 +613,7 @@ export class MathSettingTab extends PluginSettingTab {
hotkeys.splice(index, 1);
await this.plugin.saveSettings();
this.plugin.customNoteManager.registerCommands();
(this as unknown as { display(): void }).display();
this.render();
})();
});
@ -504,4 +750,144 @@ export class MathSettingTab extends PluginSettingTab {
});
});
}
private renderChangelog(containerEl: HTMLElement): void {
new Setting(containerEl).setName(t('settings.header.whatsNew')).setHeading();
const changelogList = containerEl.createDiv('obsitexcore-changelog-view-list');
changelogData.forEach(version => {
const verContainer = changelogList.createDiv('obsitexcore-changelog-version-container');
new Setting(verContainer)
.setName(`Version ${version.version} (${version.date})`)
.setHeading();
version.changes.forEach(change => {
const colonIndex = change.description.indexOf(':');
let title: string;
let desc = change.description;
if (colonIndex !== -1) {
title = change.description.substring(0, colonIndex).trim();
desc = change.description.substring(colonIndex + 1).trim();
} else {
title = change.type.charAt(0).toUpperCase() + change.type.slice(1);
}
let emoji = '✨';
if (change.type === 'new') {
emoji = '🚀';
} else if (change.type === 'fix') {
emoji = '🛠️';
}
const itemEl = verContainer.createDiv(`full-calendar-change-item full-calendar-change-type-${change.type}`);
itemEl.createDiv({
cls: 'full-calendar-change-icon',
text: emoji
});
const contentWrap = itemEl.createDiv('change-content');
contentWrap.createDiv({
cls: 'full-calendar-change-title',
text: title
});
contentWrap.createDiv({
cls: 'full-calendar-change-description',
text: desc
});
});
});
}
private renderFooter(containerEl: HTMLElement): void {
const footerEl = containerEl.createDiv({ cls: 'full-calendar-settings-footer' });
footerEl.createEl('p', {
text: t('settings.footer.question'),
cls: 'full-calendar-settings-footer-text'
});
const linksContainer = footerEl.createDiv({ cls: 'full-calendar-settings-footer-links' });
linksContainer.createEl('a', {
text: t('settings.footer.supportOnKofi'),
attr: {
href: 'https://youfoundjk.github.io/ObsiTeXcore/donation/ko-fi'
},
cls: 'full-calendar-settings-footer-link'
});
linksContainer.createEl('a', {
text: t('settings.footer.suggestFeature'),
attr: {
href: 'https://github.com/YouFoundJK/ObsiTeXcore/discussions'
},
cls: 'full-calendar-settings-footer-link'
});
linksContainer.createEl('a', {
text: t('settings.footer.reportBug'),
attr: {
href: 'https://github.com/YouFoundJK/ObsiTeXcore/issues'
},
cls: 'full-calendar-settings-footer-link'
});
}
private applySearchFilter(containerEl: HTMLElement, query: string): boolean {
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
const settingEls = Array.from(containerEl.querySelectorAll<HTMLElement>('.setting-item'));
let visibleCount = 0;
settingEls.forEach(settingEl => {
const titleEl = settingEl.querySelector<HTMLElement>('.setting-item-name');
const descriptionEl = settingEl.querySelector<HTMLElement>('.setting-item-description');
const title = titleEl?.textContent ?? '';
const description = descriptionEl?.textContent ?? '';
const haystack = `${title} ${description}`.toLowerCase();
const isMatch = tokens.every(token => haystack.includes(token));
setCssProps(settingEl, { display: isMatch ? '' : 'none' });
if (isMatch) {
this.highlightSearchTokens(titleEl, tokens);
this.highlightSearchTokens(descriptionEl, tokens);
visibleCount += 1;
}
});
return visibleCount > 0;
}
private highlightSearchTokens(el: HTMLElement | null, tokens: string[]): void {
if (!el) return;
const rawText = el.textContent ?? '';
if (!rawText || tokens.length === 0) return;
const escapedTokens = tokens
.filter(Boolean)
.map(token => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (escapedTokens.length === 0) return;
const regex = new RegExp(`(${escapedTokens.join('|')})`, 'gi');
const doc = window.activeDocument ?? window.document;
const fragment = doc.createDocumentFragment();
let lastIndex = 0;
for (const match of rawText.matchAll(regex)) {
const matchText = match[0];
const matchIndex = match.index ?? -1;
if (matchIndex < 0) continue;
if (matchIndex > lastIndex) {
fragment.append(rawText.slice(lastIndex, matchIndex));
}
const markEl = doc.createElement('mark');
markEl.textContent = matchText;
fragment.append(markEl);
lastIndex = matchIndex + matchText.length;
}
if (lastIndex < rawText.length) {
fragment.append(rawText.slice(lastIndex));
}
el.empty();
el.append(fragment);
}
}

View file

@ -353,4 +353,188 @@
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
/* Ensure wrapper takes full width */
}
/* ====== Settings Redesign (Tabs, Search, Footer, and Changelog) ====== */
.obsitexcore-settings-shell {
display: flex;
flex-direction: column;
gap: var(--size-4-3);
margin-top: 15px;
}
.obsitexcore-settings-header {
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 10px;
}
.obsitexcore-settings-tabs-row {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 8px;
gap: 15px;
flex-wrap: wrap;
}
.obsitexcore-settings-tabs {
display: flex;
gap: 6px;
}
.obsitexcore-settings-tab {
padding: 6px 12px;
background-color: var(--background-secondary-alt);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-muted);
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: all 0.15s ease;
}
.obsitexcore-settings-tab:hover {
color: var(--text-normal);
background-color: var(--background-secondary);
}
.obsitexcore-settings-tab.is-active {
color: var(--text-on-accent);
background-color: var(--interactive-accent);
border-color: var(--interactive-accent-hover);
}
.obsitexcore-settings-search-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.obsitexcore-settings-search-trigger {
padding: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-s);
}
.obsitexcore-settings-search-input-wrap {
display: flex;
align-items: center;
}
.obsitexcore-settings-search-input {
width: 100%;
padding: 6px 28px 6px 10px;
font-size: 0.9em;
}
.obsitexcore-settings-search-clear {
color: var(--text-muted);
}
.obsitexcore-settings-search-clear:hover {
color: var(--text-normal);
}
.obsitexcore-settings-category-intro {
font-size: 0.95em;
color: var(--text-muted);
margin-bottom: 15px;
}
.obsitexcore-settings-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.obsitexcore-settings-search-section {
margin-bottom: 25px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: 15px;
background-color: var(--background-primary-alt);
}
.obsitexcore-settings-search-empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
}
/* ====== Settings Footer (Exact styles from full-calendar) ====== */
.full-calendar-settings-footer {
margin-top: 1em;
padding: var(--size-4-4);
text-align: center;
background-color: var(--background-secondary);
border-radius: var(--radius-m);
border: 1px solid var(--background-modifier-border);
}
.full-calendar-settings-footer-text {
font-size: var(--font-ui-medium);
color: var(--text-normal);
margin-top: var(--size-8-4);
margin-bottom: var(--size-4-2);
}
.full-calendar-settings-footer-links {
display: flex;
justify-content: center;
gap: var(--size-4-4);
}
.full-calendar-settings-footer-link {
font-size: var(--font-ui-medium);
font-weight: 600;
color: var(--interactive-accent);
text-decoration: none;
transition: color 0.2s ease-in-out;
}
.full-calendar-settings-footer-link:hover {
color: var(--interactive-accent-hover);
}
/* ====== Changelog/What's New Card Styles (Exact styles from full-calendar) ====== */
.full-calendar-change-item {
display: flex;
gap: var(--size-4-3);
padding: var(--size-4-4);
border-radius: var(--radius-m);
margin-bottom: var(--size-4-2);
background-color: var(--background-secondary);
border-left: 3px solid;
text-align: left;
}
.full-calendar-change-type-new {
border-color: var(--color-green);
}
.full-calendar-change-type-improvement {
border-color: var(--color-blue);
}
.full-calendar-change-type-fix {
border-color: var(--color-orange);
}
.full-calendar-change-icon {
font-size: var(--font-ui-large);
}
.full-calendar-change-title {
font-weight: 700;
margin-bottom: var(--size-4-1);
}
.full-calendar-change-description {
font-size: var(--font-ui-small);
color: var(--text-muted);
}

View file

@ -0,0 +1,104 @@
import { App, Modal, ButtonComponent } from 'obsidian';
import { changelogData } from '../../settings/changelogData';
import { t } from '../../i18n/t';
type SettingsManager = {
open: () => void;
openTabById: (id: string) => void;
};
type AppWithSettings = App & { setting: SettingsManager };
export class WhatsNewModal extends Modal {
constructor(
app: App,
private manifestId: string
) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('obsitexcore-whats-new-modal');
// Header
const headerRow = contentEl.createDiv('obsitexcore-whats-new-header-row');
headerRow.createEl('h2', { text: t('modals.whatsNew.title') });
const seeAllButtonWrap = headerRow.createDiv('obsitexcore-whats-new-header-actions');
new ButtonComponent(seeAllButtonWrap)
.setButtonText(t('modals.whatsNew.openSettings'))
.onClick(() => {
this.close();
const settingsManager = (this.app as AppWithSettings).setting;
if (settingsManager && typeof settingsManager.open === 'function') {
settingsManager.open();
settingsManager.openTabById(this.manifestId);
}
});
// Content container
const bodyEl = contentEl.createDiv('obsitexcore-whats-new-body');
const latestVersion = changelogData[0];
const versionHeader = bodyEl.createDiv('obsitexcore-whats-new-version-header');
versionHeader.createEl('h3', {
text: `Version ${latestVersion.version} (${latestVersion.date})`
});
const changeList = bodyEl.createDiv('obsitexcore-whats-new-list');
latestVersion.changes.forEach(change => {
const colonIndex = change.description.indexOf(':');
let title: string;
let desc = change.description;
if (colonIndex !== -1) {
title = change.description.substring(0, colonIndex).trim();
desc = change.description.substring(colonIndex + 1).trim();
} else {
title = change.type.charAt(0).toUpperCase() + change.type.slice(1);
}
let emoji = '✨';
if (change.type === 'new') {
emoji = '🚀';
} else if (change.type === 'fix') {
emoji = '🛠️';
}
const itemEl = changeList.createDiv(`full-calendar-change-item full-calendar-change-type-${change.type}`);
itemEl.createDiv({
cls: 'full-calendar-change-icon',
text: emoji
});
const contentWrap = itemEl.createDiv('change-content');
contentWrap.createDiv({
cls: 'full-calendar-change-title',
text: title
});
contentWrap.createDiv({
cls: 'full-calendar-change-description',
text: desc
});
});
// Donation Footer
const donationFooter = contentEl.createDiv('obsitexcore-whats-new-donation-footer');
donationFooter.createEl('p', {
text: 'ObsiTeXcore is built to bring LaTeX-like equation indexing and referencing workflows to Obsidian. If this plugin provides value to you, please consider supporting the development!',
cls: 'obsitexcore-whats-new-donation-message'
});
const donationActions = donationFooter.createDiv('obsitexcore-whats-new-donation-actions');
new ButtonComponent(donationActions)
.setButtonText(t('modals.whatsNew.supportKofi'))
.setCta()
.onClick(() => {
window.open('https://youfoundjk.github.io/ObsiTeXcore/donation/ko-fi', '_blank');
});
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -125,7 +125,7 @@ describe('MockVault API tests', () => {
}
// create another folder
const folder2 = await vault.createFolder('folder2');
await vault.createFolder('folder2');
// rename folder
const folder1 = vault.getAbstractFileByPath('folder1');

View file

@ -13,6 +13,7 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"lib": ["DOM", "ES5", "ES6", "ES7", "ES2021.String", "DOM.Iterable", "es2022"]
},
"include": ["**/*.d.ts", "**/*.ts"],