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.
This commit is contained in:
2026-09-07 12:36:08 -04:00
parent fcbf1492b7
commit 254fcc1b41
5 changed files with 128 additions and 34 deletions
+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> {
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 "<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 = {
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
}
/**
+53 -28
View File
@@ -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)
}
// ============================================================