3 Commits

Author SHA1 Message Date
olivier b8461c3eb0 chore: bump to v1.1.0
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled
2026-09-07 17:27:56 -04:00
olivier b031cd7e04 feat: visually group settings by category with collapsible cards
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled
- 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.
2026-09-07 14:45:24 -04:00
olivier 254fcc1b41 fix: heading-regex tag corruption, live command toggles, adjacent-file collision, stale selection listeners
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled
- HEADING_REGEX now requires a space/EOL after '#' so tag lines
  (#project ...) are no longer misdetected as headings and corrupted.
- Command enable/disable settings now take effect immediately via
  checkCallback/editorCheckCallback instead of requiring a reload.
- New Adjacent File auto-increments (Untitled.md, Untitled 1.md, ...)
  instead of throwing once Untitled.md already exists.
- Selection-change tracking is delegated from document instead of a
  one-time .cm-content scan, so editors opened after startup are covered.

See CHANGELOG.md for details.
2026-09-07 12:36:08 -04:00
10 changed files with 323 additions and 93 deletions
+60
View File
@@ -0,0 +1,60 @@
# Changelog
All notable changes to this project are documented in this file.
Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.1.0] - 2026-09-07
### Added
- **Settings page now visually groups commands by category.** Each category
(Better Formatting, Line Operations, etc.) renders as a collapsible card with
a bordered/shaded header and an "N/M enabled" badge, instead of a flat list
separated only by bold text headings. The sentence-matching regex setting
moved into the Sentence Navigator category card, since it configures that
category's commands. Toggling a setting no longer collapses open categories.
(`src/settings.ts`, `styles.css`)
### Fixed
- **Heading toggle corrupted lines starting with a tag (`#tag ...`).** `HEADING_REGEX`
(`src/Constants.ts`) matched any run of leading `#` characters as a heading marker,
even without a following space. Obsidian tags like `#project meeting notes` were
therefore misread as an existing H1, so running *Toggle Heading - H1* on such a
line stripped the `#` instead of adding a heading (and running any other heading
level inserted a stray heading marker before the tag). The regex now requires the
`#` run to be followed by whitespace or end-of-line before it counts as a heading,
matching the ATX heading rule already used by *Toggle Heading (strip formatting)*.
(`src/Constants.ts`, `src/ToggleHeading.ts`)
- **Command enable/disable toggles in Settings required an Obsidian restart.**
`registerCommands()` only ran once in `onload()`, checking `enabledCommands` at
registration time. Flipping a toggle in the settings tab saved the new state but
had no effect on the already-registered commands until the plugin was reloaded,
contradicting the setting's own description ("Disabled commands will not appear
in the command palette"). Commands are now always registered and gated through
`checkCallback`/`editorCheckCallback`, so palette visibility and hotkeys respond
immediately to settings changes. Command IDs are unchanged, so existing hotkey
bindings are unaffected. (`src/main.ts`)
- **"New Adjacent File" failed after the first use in a folder.** The command
always targeted a literal `Untitled.md`; `vault.create()` throws if that path
already exists (very likely, since it's also Obsidian's own default new-note
name), surfacing a raw error `Notice` on every subsequent use. It now finds the
next available `Untitled N.md` name in the folder, mirroring Obsidian's built-in
new-note behavior. (`src/FileHelper.ts`)
- **Selection-tracking listeners went stale for editors opened after startup.**
`registerSelectionChangeListeners()` attached `keydown`/`click`/`dblclick`
listeners to every `.cm-content` element found via a single `querySelectorAll`
at layout-ready. Editors created afterward (new panes, splits, tabs) got their
own `.cm-content` element that was never instrumented, silently degrading the
manual-vs-programmatic selection heuristic used by *Select Next/Previous
Occurrence*. Listeners are now delegated from `document` in the capture phase,
so any editor present now or opened later is covered. (`src/main.ts`)
### Chore
- Reinstalled `node_modules` to pull the `@esbuild/win32-x64` binary; the checked
in lockfile/install had resolved only `@esbuild/linux-x64`, so `npm run build`
and `npm run dev` failed outright on this machine.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "bindthem", "id": "bindthem",
"name": "BindThem", "name": "BindThem",
"version": "1.0.0", "version": "1.1.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Enhanced editor shortcuts and formatting tools. Combines smart text wrapping (bold, italic, code, etc.), directional copy/move, heading toggles (H1-H6), line operations, case transformation, multi-cursor support, and file utilities into one unified plugin.", "description": "Enhanced editor shortcuts and formatting tools. Combines smart text wrapping (bold, italic, code, etc.), directional copy/move, heading toggles (H1-H6), line operations, case transformation, multi-cursor support, and file utilities into one unified plugin.",
"author": "Olivier Legendre", "author": "Olivier Legendre",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bindthem", "name": "bindthem",
"version": "1.0.0", "version": "1.1.0",
"description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.", "description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
+6 -2
View File
@@ -37,6 +37,10 @@ export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/
// ============================================================ // ============================================================
/** /**
* Regex for matching heading markers at the start of a line * Regex for matching heading markers at the start of a line.
* A valid ATX heading marker is 1-6 `#` characters immediately followed
* by whitespace or end of line. This lookahead excludes tag-like
* prefixes such as `#project` (no space) from being misdetected as
* headings — matches[1] is undefined when there is no valid marker.
*/ */
export const HEADING_REGEX = /^(#*)( *)(.*)/ export const HEADING_REGEX = /^(#{1,6}(?=[ \t]|$))?( *)(.*)/
+17 -2
View File
@@ -41,7 +41,9 @@ export class FileHelper {
} }
/** /**
* Create a new file in the same directory as the current file * Create a new file in the same directory as the current file.
* If "Untitled.md" already exists, finds the next available
* "Untitled N.md" name (mirrors Obsidian's own new-note behavior).
*/ */
public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> { public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
const activeFile = this.app.workspace.getActiveFile() const activeFile = this.app.workspace.getActiveFile()
@@ -51,7 +53,7 @@ export class FileHelper {
} }
const parentPath = activeFile.parent?.path ?? '' const parentPath = activeFile.parent?.path ?? ''
const newFilePath = parentPath + '/' + 'Untitled.md' const newFilePath = this.getAvailablePath(parentPath, 'Untitled', 'md')
try { try {
const newFile = await this.app.vault.create(newFilePath, '') const newFile = await this.app.vault.create(newFilePath, '')
@@ -62,4 +64,17 @@ export class FileHelper {
new Notice(String(e)) new Notice(String(e))
} }
} }
/**
* Find the first unused "<basename>.md" / "<basename> N.md" path in a folder
*/
private getAvailablePath(parentPath: string, basename: string, extension: string): string {
let candidate = `${parentPath}/${basename}.${extension}`
let n = 1
while (this.app.vault.getAbstractFileByPath(candidate) !== null) {
candidate = `${parentPath}/${basename} ${n}.${extension}`
n++
}
return candidate
}
} }
+2 -2
View File
@@ -42,7 +42,7 @@ export class ToggleHeading {
} }
const to: EditorPosition = { const to: EditorPosition = {
line: line, line: line,
ch: matches[1].length + matches[2].length, ch: (matches[1]?.length ?? 0) + matches[2].length,
} }
const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' ' const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' '
@@ -61,7 +61,7 @@ export class ToggleHeading {
const text = editor.getLine(line) const text = editor.getLine(line)
const matches = HEADING_REGEX.exec(text)! const matches = HEADING_REGEX.exec(text)!
return matches[1].length as Heading return (matches[1]?.length ?? 0) as Heading
} }
/** /**
+53 -28
View File
@@ -98,13 +98,33 @@ export default class BindThemPlugin extends Plugin {
editorCallback?: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => void; editorCallback?: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => void;
callback?: () => void; callback?: () => void;
}): void { }): void {
if (this.isCommandEnabled(options.id)) { const { id, name, icon, editorCallback, callback } = options;
// Commands are always registered; enabled state is enforced via
// checkCallback so toggling a command in settings takes effect
// immediately (hides it from the palette and disables its hotkey)
// without requiring a plugin reload.
if (editorCallback) {
this.addCommand({ this.addCommand({
id: options.id, id,
name: options.name, name,
icon: options.icon, icon,
editorCallback: options.editorCallback, editorCheckCallback: (checking, editor, view) => {
callback: options.callback, if (!this.isCommandEnabled(id)) return false;
if (!checking) editorCallback(editor, view);
return true;
},
});
} else if (callback) {
this.addCommand({
id,
name,
icon,
checkCallback: (checking) => {
if (!this.isCommandEnabled(id)) return false;
if (!checking) callback();
return true;
},
}); });
} }
} }
@@ -903,31 +923,36 @@ export default class BindThemPlugin extends Plugin {
* - Programmatic changes → within-word matching * - Programmatic changes → within-word matching
*/ */
private registerSelectionChangeListeners(): void { private registerSelectionChangeListeners(): void {
this.app.workspace.onLayoutReady(() => { const MODIFIER_KEYS = [
const MODIFIER_KEYS = [ 'Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Fn',
'Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Fn', ]
]
const handleSelectionChange = (evt: Event) => { const handleSelectionChange = (evt: Event) => {
if ( if (
evt instanceof KeyboardEvent && evt instanceof KeyboardEvent &&
MODIFIER_KEYS.includes(evt.key) MODIFIER_KEYS.includes(evt.key)
) { ) {
return return
}
if (!getIsProgrammaticSelectionChange()) {
setIsManualSelection(true)
}
setIsProgrammaticSelectionChange(false)
} }
const target = evt.target as HTMLElement | null
if (!target?.closest('.cm-content')) {
return
}
if (!getIsProgrammaticSelectionChange()) {
setIsManualSelection(true)
}
setIsProgrammaticSelectionChange(false)
}
// Observe CodeMirror 6 editors (new Obsidian editor) // Delegate from `document` in the capture phase instead of
document.querySelectorAll('.cm-content').forEach((el) => { // enumerating `.cm-content` elements once at layout-ready:
this.registerDomEvent(el as HTMLElement, 'keydown', handleSelectionChange) // editors opened in new panes/splits/tabs after startup get
this.registerDomEvent(el as HTMLElement, 'click', handleSelectionChange) // their own `.cm-content` element and would otherwise never
this.registerDomEvent(el as HTMLElement, 'dblclick', handleSelectionChange) // be instrumented. Capture phase guarantees we observe the
}) // event even if CodeMirror stops propagation during bubbling.
}) this.registerDomEvent(document, 'keydown', handleSelectionChange, true)
this.registerDomEvent(document, 'click', handleSelectionChange, true)
this.registerDomEvent(document, 'dblclick', handleSelectionChange, true)
} }
// ============================================================ // ============================================================
+109 -48
View File
@@ -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
View File
@@ -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;
}
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"1.0.0": "0.15.0" "1.0.0": "0.15.0",
"1.1.0": "0.15.0"
} }