feat: visually group settings by category with collapsible cards
- Each command category (Better Formatting, Line Operations, etc.) now renders as a collapsible <details> card with a bordered/shaded header showing an 'N/M enabled' badge, instead of a flat list separated only by bold text headings. - Sentence regex + reset moved into the Sentence Navigator category card (it configures that category's commands, so it belongs there instead of the general section). - Bulk actions (global enable/disable all, per-category toggle-all, regex reset) now patch the DOM in place instead of calling the full display() re-render, so open/closed category state is never lost when a setting changes. - Removed the 'General' settings heading (obsidianmd/settings-tab/no-problematic-settings-headings lint rule flags it). - styles.css adds .bindthem-category-* rules for the card borders, header shading, collapse caret, and setting-row separators.
This commit is contained in:
+109
-48
@@ -1,4 +1,4 @@
|
|||||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
import { App, PluginSettingTab, Setting, TextComponent } from 'obsidian';
|
||||||
import BindThemPlugin from './main';
|
import BindThemPlugin from './main';
|
||||||
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
||||||
|
|
||||||
@@ -164,6 +164,13 @@ export const DEFAULT_SETTINGS: BindThemSettings = {
|
|||||||
export class BindThemSettingTab extends PluginSettingTab {
|
export class BindThemSettingTab extends PluginSettingTab {
|
||||||
plugin: BindThemPlugin;
|
plugin: BindThemPlugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh functions for each rendered category body. Bulk actions call
|
||||||
|
* these directly instead of re-running `display()`, so toggling a
|
||||||
|
* setting never collapses categories the user has open.
|
||||||
|
*/
|
||||||
|
private categoryRefreshers: Array<() => void> = [];
|
||||||
|
|
||||||
constructor(app: App, plugin: BindThemPlugin) {
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
@@ -172,6 +179,7 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
display(): void {
|
display(): void {
|
||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
this.categoryRefreshers = [];
|
||||||
|
|
||||||
// Hotkeys link at the top
|
// Hotkeys link at the top
|
||||||
containerEl.createEl('a', {
|
containerEl.createEl('a', {
|
||||||
@@ -184,14 +192,10 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
}, (el) => {
|
}, (el) => {
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
|
this.openHotkeysSearch();
|
||||||
if (hotkeysTab) {
|
|
||||||
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// General settings
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Debug mode')
|
.setName('Debug mode')
|
||||||
.setDesc('Enable debug logging to console')
|
.setDesc('Enable debug logging to console')
|
||||||
@@ -202,36 +206,16 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName('Sentence regex')
|
|
||||||
.setDesc('Regular expression used to match sentences')
|
|
||||||
.addText(text => text
|
|
||||||
.setValue(this.plugin.settings.sentenceRegexSource)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.sentenceRegexSource = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
}));
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName('Reset sentence regex')
|
|
||||||
.addButton(button => button
|
|
||||||
.setButtonText('Reset')
|
|
||||||
.onClick(async () => {
|
|
||||||
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
this.display();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Commands section header
|
// Commands section header
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Command availability')
|
.setName('Command availability')
|
||||||
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
|
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette. Select a category below to see its commands.')
|
||||||
.setHeading();
|
.setHeading();
|
||||||
|
|
||||||
// Category toggle buttons
|
// Global toggle-all buttons
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Enable/disable all')
|
.setName('Enable/disable all')
|
||||||
.setDesc('Toggle all commands on or off')
|
.setDesc('Toggle every command in every category on or off')
|
||||||
.addButton(button => button
|
.addButton(button => button
|
||||||
.setButtonText('Enable all')
|
.setButtonText('Enable all')
|
||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
@@ -239,7 +223,7 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
this.plugin.settings.enabledCommands[cmdId] = true;
|
this.plugin.settings.enabledCommands[cmdId] = true;
|
||||||
}
|
}
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.display();
|
this.refreshAllCategories();
|
||||||
}))
|
}))
|
||||||
.addButton(button => button
|
.addButton(button => button
|
||||||
.setButtonText('Disable all')
|
.setButtonText('Disable all')
|
||||||
@@ -249,12 +233,13 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
this.plugin.settings.enabledCommands[cmdId] = false;
|
this.plugin.settings.enabledCommands[cmdId] = false;
|
||||||
}
|
}
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.display();
|
this.refreshAllCategories();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Render each category
|
// Render each category as a collapsible, visually grouped card
|
||||||
|
const categoryListEl = containerEl.createDiv({ cls: 'bindthem-category-list' });
|
||||||
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
|
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
|
||||||
this.renderCategory(containerEl, categoryKey, categoryName);
|
this.renderCategory(categoryListEl, categoryKey, categoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// About section
|
// About section
|
||||||
@@ -265,35 +250,78 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
.setHeading();
|
.setHeading();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Hotkeys settings tab and pre-fills its search with "BindThem".
|
||||||
|
* `app.setting` (the settings modal controller) isn't part of Obsidian's
|
||||||
|
* public API types, so the shape is narrowed once here rather than cast
|
||||||
|
* inline at each property access.
|
||||||
|
*/
|
||||||
|
private openHotkeysSearch(): void {
|
||||||
|
const appWithSettingModal = this.app as unknown as {
|
||||||
|
setting: {
|
||||||
|
openTabById: (id: string) => { searchComponent: { setValue: (v: string) => void } } | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const hotkeysTab = appWithSettingModal.setting.openTabById('hotkeys');
|
||||||
|
if (hotkeysTab) {
|
||||||
|
hotkeysTab.searchComponent.setValue('BindThem');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshAllCategories(): void {
|
||||||
|
for (const refresh of this.categoryRefreshers) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void {
|
private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void {
|
||||||
const commands = COMMANDS[categoryKey as keyof typeof COMMANDS];
|
const commands = COMMANDS[categoryKey as keyof typeof COMMANDS];
|
||||||
if (!commands || commands.length === 0) return;
|
if (!commands || commands.length === 0) return;
|
||||||
|
|
||||||
// Category header with toggle all
|
const details = containerEl.createEl('details', { cls: 'bindthem-category' });
|
||||||
const categorySetting = new Setting(containerEl)
|
const summary = details.createEl('summary', { cls: 'bindthem-category-summary' });
|
||||||
.setName(categoryName)
|
summary.createSpan({ text: categoryName, cls: 'bindthem-category-name' });
|
||||||
.setHeading();
|
const badge = summary.createSpan({ cls: 'bindthem-category-count' });
|
||||||
|
|
||||||
// Check if all commands in this category are enabled
|
const content = details.createDiv({ cls: 'bindthem-category-content' });
|
||||||
const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false);
|
|
||||||
|
|
||||||
// Add toggle all button for this category
|
const refresh = () => this.renderCategoryBody(content, badge, categoryKey, commands);
|
||||||
categorySetting.addToggle(toggle => {
|
refresh();
|
||||||
toggle
|
this.categoryRefreshers.push(refresh);
|
||||||
.setValue(allEnabled)
|
}
|
||||||
|
|
||||||
|
private renderCategoryBody(
|
||||||
|
content: HTMLElement,
|
||||||
|
badge: HTMLElement,
|
||||||
|
categoryKey: string,
|
||||||
|
commands: CommandDefinition[]
|
||||||
|
): void {
|
||||||
|
content.empty();
|
||||||
|
|
||||||
|
const enabledCount = commands.filter(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false).length;
|
||||||
|
badge.setText(`${enabledCount}/${commands.length} enabled`);
|
||||||
|
|
||||||
|
// Sentence regex configures the Sentence Navigator commands, so it
|
||||||
|
// lives inside that category's card instead of the general section.
|
||||||
|
if (categoryKey === 'sentenceNavigator') {
|
||||||
|
this.renderSentenceRegexSetting(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Setting(content)
|
||||||
|
.setName('Enable all in this category')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(enabledCount === commands.length)
|
||||||
.setTooltip('Toggle all commands in this category')
|
.setTooltip('Toggle all commands in this category')
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||||
}
|
}
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.display();
|
this.renderCategoryBody(content, badge, categoryKey, commands);
|
||||||
});
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
// Render individual commands
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
new Setting(containerEl)
|
new Setting(content)
|
||||||
.setName(cmd.name)
|
.setName(cmd.name)
|
||||||
.setDesc(cmd.description || `Command ID: ${cmd.id}`)
|
.setDesc(cmd.description || `Command ID: ${cmd.id}`)
|
||||||
.addToggle(toggle => toggle
|
.addToggle(toggle => toggle
|
||||||
@@ -301,7 +329,40 @@ export class BindThemSettingTab extends PluginSettingTab {
|
|||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
|
const newCount = commands.filter(c => this.plugin.settings.enabledCommands[c.id] !== false).length;
|
||||||
|
badge.setText(`${newCount}/${commands.length} enabled`);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the sentence-matching regex setting and its reset button.
|
||||||
|
* Updates the text field directly on reset instead of re-rendering.
|
||||||
|
*/
|
||||||
|
private renderSentenceRegexSetting(containerEl: HTMLElement): void {
|
||||||
|
let textComponent: TextComponent | undefined;
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Sentence regex')
|
||||||
|
.setDesc('Regular expression used to match sentences')
|
||||||
|
.addText(text => {
|
||||||
|
textComponent = text;
|
||||||
|
text
|
||||||
|
.setValue(this.plugin.settings.sentenceRegexSource)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.sentenceRegexSource = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Reset sentence regex')
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Reset')
|
||||||
|
.onClick(async () => {
|
||||||
|
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
textComponent?.setValue(DEFAULT_SENTENCE_REGEX);
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+66
-2
@@ -3,6 +3,70 @@
|
|||||||
This CSS file will be included with your plugin, and
|
This CSS file will be included with your plugin, and
|
||||||
available in the app when your plugin is enabled.
|
available in the app when your plugin is enabled.
|
||||||
|
|
||||||
If your plugin does not need CSS, delete this file.
|
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Settings tab — command category grouping
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.bindthem-category-list {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75em;
|
||||||
|
padding: 0.65em 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category > summary::before {
|
||||||
|
content: '▸';
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category-count {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category-content {
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category-content .setting-item {
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindthem-category-content .setting-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user