From 254fcc1b41a55d841396e293181a1aad7a9309d6 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 7 Sep 2026 12:36:08 -0400 Subject: [PATCH] fix: heading-regex tag corruption, live command toggles, adjacent-file collision, stale selection listeners - 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. --- CHANGELOG.md | 50 +++++++++++++++++++++++++++ src/Constants.ts | 8 +++-- src/FileHelper.ts | 19 +++++++++-- src/ToggleHeading.ts | 4 +-- src/main.ts | 81 +++++++++++++++++++++++++++++--------------- 5 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dadf71d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# 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/). + +## [Unreleased] + +### 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. diff --git a/src/Constants.ts b/src/Constants.ts index 0772ffd..991b446 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -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 = /^(#*)( *)(.*)/ \ No newline at end of file +export const HEADING_REGEX = /^(#{1,6}(?=[ \t]|$))?( *)(.*)/ \ No newline at end of file diff --git a/src/FileHelper.ts b/src/FileHelper.ts index 0bfe17c..fdcf66b 100644 --- a/src/FileHelper.ts +++ b/src/FileHelper.ts @@ -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 { const activeFile = this.app.workspace.getActiveFile() @@ -51,7 +53,7 @@ export class FileHelper { } const parentPath = activeFile.parent?.path ?? '' - const newFilePath = parentPath + '/' + 'Untitled.md' + const newFilePath = this.getAvailablePath(parentPath, 'Untitled', 'md') try { const newFile = await this.app.vault.create(newFilePath, '') @@ -62,4 +64,17 @@ export class FileHelper { new Notice(String(e)) } } + + /** + * Find the first unused ".md" / " 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 + } } \ No newline at end of file diff --git a/src/ToggleHeading.ts b/src/ToggleHeading.ts index ac8916f..f78b0de 100644 --- a/src/ToggleHeading.ts +++ b/src/ToggleHeading.ts @@ -42,7 +42,7 @@ export class ToggleHeading { } const to: EditorPosition = { line: line, - ch: matches[1].length + matches[2].length, + ch: (matches[1]?.length ?? 0) + matches[2].length, } const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' ' @@ -61,7 +61,7 @@ export class ToggleHeading { const text = editor.getLine(line) const matches = HEADING_REGEX.exec(text)! - return matches[1].length as Heading + return (matches[1]?.length ?? 0) as Heading } /** diff --git a/src/main.ts b/src/main.ts index f89ca47..864fe34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -98,13 +98,33 @@ export default class BindThemPlugin extends Plugin { editorCallback?: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => void; callback?: () => 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({ - id: options.id, - name: options.name, - icon: options.icon, - editorCallback: options.editorCallback, - callback: options.callback, + id, + name, + icon, + editorCheckCallback: (checking, editor, view) => { + 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 */ private registerSelectionChangeListeners(): void { - this.app.workspace.onLayoutReady(() => { - const MODIFIER_KEYS = [ - 'Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Fn', - ] + const MODIFIER_KEYS = [ + 'Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Fn', + ] - const handleSelectionChange = (evt: Event) => { - if ( - evt instanceof KeyboardEvent && - MODIFIER_KEYS.includes(evt.key) - ) { - return - } - if (!getIsProgrammaticSelectionChange()) { - setIsManualSelection(true) - } - setIsProgrammaticSelectionChange(false) + const handleSelectionChange = (evt: Event) => { + if ( + evt instanceof KeyboardEvent && + MODIFIER_KEYS.includes(evt.key) + ) { + return } + 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) - document.querySelectorAll('.cm-content').forEach((el) => { - this.registerDomEvent(el as HTMLElement, 'keydown', handleSelectionChange) - this.registerDomEvent(el as HTMLElement, 'click', handleSelectionChange) - this.registerDomEvent(el as HTMLElement, 'dblclick', handleSelectionChange) - }) - }) + // Delegate from `document` in the capture phase instead of + // enumerating `.cm-content` elements once at layout-ready: + // editors opened in new panes/splits/tabs after startup get + // their own `.cm-content` element and would otherwise never + // 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) } // ============================================================