diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 85f1a96..76b34db 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -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; + } +} diff --git a/src/i18n/en.json b/src/i18n/en.json new file mode 100644 index 0000000..aee7098 --- /dev/null +++ b/src/i18n/en.json @@ -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" + } + } +} diff --git a/src/i18n/t.ts b/src/i18n/t.ts new file mode 100644 index 0000000..0a1fe5a --- /dev/null +++ b/src/i18n/t.ts @@ -0,0 +1,23 @@ +import en from './en.json'; + +export function t(key: string, vars?: Record): 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)[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; +} diff --git a/src/main.ts b/src/main.ts index c030e18..e255aba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 diff --git a/src/settings/changelogData.ts b/src/settings/changelogData.ts new file mode 100644 index 0000000..caad42f --- /dev/null +++ b/src/settings/changelogData.ts @@ -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.' + } + ] + } +]; diff --git a/src/settings/docsLinks.ts b/src/settings/docsLinks.ts new file mode 100644 index 0000000..0eaed40 --- /dev/null +++ b/src/settings/docsLinks.ts @@ -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; +} diff --git a/src/settings/linkTextFragments.ts b/src/settings/linkTextFragments.ts new file mode 100644 index 0000000..8383965 --- /dev/null +++ b/src/settings/linkTextFragments.ts @@ -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 })); +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a21c140..06f3571 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -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 = { + currentVersion: null, // Numbering numberOnlyReferencedEquations: true, eqNumberPrefix: '', diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 448e81d..72713a4 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -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('.setting-item')); + + let visibleCount = 0; + settingEls.forEach(settingEl => { + const titleEl = settingEl.querySelector('.setting-item-name'); + const descriptionEl = settingEl.querySelector('.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); + } } diff --git a/src/styles/main.css b/src/styles/main.css index 8bb274e..edb352d 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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); } \ No newline at end of file diff --git a/src/ui/modals/WhatsNewModal.ts b/src/ui/modals/WhatsNewModal.ts new file mode 100644 index 0000000..87e5c7b --- /dev/null +++ b/src/ui/modals/WhatsNewModal.ts @@ -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(); + } +} diff --git a/test_helpers/MockVault.test.ts b/test_helpers/MockVault.test.ts index 463679e..e76e777 100644 --- a/test_helpers/MockVault.test.ts +++ b/test_helpers/MockVault.test.ts @@ -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'); diff --git a/tsconfig.json b/tsconfig.json index eb4b2e3..4885896 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],