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.
This commit is contained in:
@@ -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.
|
||||||
+6
-2
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+39
-14
@@ -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,7 +923,6 @@ 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',
|
||||||
]
|
]
|
||||||
@@ -915,19 +934,25 @@ export default class BindThemPlugin extends Plugin {
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const target = evt.target as HTMLElement | null
|
||||||
|
if (!target?.closest('.cm-content')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!getIsProgrammaticSelectionChange()) {
|
if (!getIsProgrammaticSelectionChange()) {
|
||||||
setIsManualSelection(true)
|
setIsManualSelection(true)
|
||||||
}
|
}
|
||||||
setIsProgrammaticSelectionChange(false)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user