diff --git a/src/BetterFormatting.ts b/src/BetterFormatting.ts new file mode 100644 index 0000000..779c3c7 --- /dev/null +++ b/src/BetterFormatting.ts @@ -0,0 +1,297 @@ +import { + App, + Editor, + EditorChange, + EditorPosition, + EditorRange, + EditorRangeOrCaret, + EditorSelection, + EditorTransaction, + MarkdownView, +} from 'obsidian' +import BindThemPlugin from './main' +import { getMainSelection, selectionToRange } from './Utils' +import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants' + +/** + * BetterFormatting provides enhanced markdown formatting toggling + * that intelligently expands selections to word boundaries and + * handles widow characters. + */ +export class BetterFormatting { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Expand a range by words, including intelligent handling of widow characters + */ + private expandRangeByWords( + editor: Editor, + range: EditorRange, + symbolStart: string, + symbolEnd: string + ): EditorRange { + // Expand to word boundaries + let from = (editor.wordAt(range.from) || range).from + let to = (editor.wordAt(range.to) || range).to + + // Include leading 'widows' (characters that should be included before the word) + while (true) { + const newFrom: EditorPosition = { + line: from.line, + ch: from.ch - 1, + } + + const newTo: EditorPosition = { + line: to.line, + ch: to.ch + 1, + } + + const preChar = editor.getRange(newFrom, from) + const postChar = editor.getRange(to, newTo) + + // Don't do this if widows form a matched pair + if (preChar === postChar) { + break + } + + if (symbolStart.endsWith(preChar)) { + break + } + + if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) { + break + } + + from = newFrom + } + + // Include trailing 'widows' + while (true) { + const newFrom: EditorPosition = { + line: from.line, + ch: from.ch - 1, + } + + const newTo: EditorPosition = { + line: to.line, + ch: to.ch + 1, + } + + const preChar = editor.getRange(newFrom, from) + const postChar = editor.getRange(to, newTo) + + // Don't do this if widows form a matched pair + if (preChar === postChar) { + break + } + + if (symbolEnd.startsWith(postChar)) { + break + } + + if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) { + break + } + + to = newTo + } + + // Include leading symbolStart + const newFrom = { line: from.line, ch: from.ch - symbolStart.length } + const preString = editor.getRange(newFrom, from) + if (preString === symbolStart) { + from = newFrom + } + + // Include following symbolEnd + const newTo = { line: to.line, ch: to.ch + symbolEnd.length } + const postString = editor.getRange(to, newTo) + if (postString === symbolEnd) { + to = newTo + } + + return { + from: from, + to: to, + } + } + + /** + * Check if text in the given range is already wrapped with the specified symbols + */ + private isWrapped( + editor: Editor, + range: EditorRange, + symbolStart: string, + symbolEnd: string + ): boolean { + const text = editor.getRange(range.from, range.to) + return text.startsWith(symbolStart) && text.endsWith(symbolEnd) + } + + /** + * Create changes to wrap text with symbols + */ + private wrap( + editor: Editor, + textRange: EditorRange, + selection: EditorSelection, + symbolStart: string, + symbolEnd: string + ): [Array, EditorSelection] { + const changes: Array = [ + { + from: textRange.from, + text: symbolStart, + }, + { + from: textRange.to, + text: symbolEnd, + }, + ] + + const newSelection: EditorSelection = { + anchor: { ...selection.anchor }, + head: { ...selection.head }, + } + newSelection.anchor.ch += symbolStart.length + newSelection.head.ch += symbolEnd.length + + return [changes, newSelection] + } + + /** + * Create changes to unwrap text from symbols + */ + private unwrap( + editor: Editor, + textRange: EditorRange, + selection: EditorSelection, + symbolStart: string, + symbolEnd: string + ): [Array, EditorSelection] { + const changes: Array = [ + { + from: textRange.from, + to: { + line: textRange.from.line, + ch: textRange.from.ch + symbolStart.length, + }, + text: '', + }, + { + from: { + line: textRange.to.line, + ch: textRange.to.ch - symbolEnd.length, + }, + to: textRange.to, + text: '', + }, + ] + + const newSelection: EditorSelection = { + anchor: { ...selection.anchor }, + head: { ...selection.head }, + } + newSelection.anchor.ch -= symbolStart.length + newSelection.head.ch -= symbolStart.length + + return [changes, newSelection] + } + + /** + * Set the wrap state for a selection + */ + private setSelectionWrapState( + editor: Editor, + selection: EditorSelection, + wrapState: boolean, + symbolStart: string, + symbolEnd: string + ): [Array, EditorSelection] { + const initialRange = selectionToRange(selection) + const textRange: EditorRange = this.expandRangeByWords( + editor, + initialRange, + symbolStart, + symbolEnd + ) + + // Check if already wrapped + const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd) + if (alreadyWrapped === wrapState) { + return [[], selection] + } + + // Wrap or unwrap + if (wrapState) { + return this.wrap(editor, textRange, selection, symbolStart, symbolEnd) + } + + return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd) + } + + /** + * Toggle wrapper symbols around the current selection(s) + * + * Principle: Toggling twice == no-op + */ + public toggleWrapper( + editor: Editor, + view: MarkdownView, + symbolStart: string, + symbolEnd?: string + ): void { + if (symbolEnd === undefined) { + symbolEnd = symbolStart + } + + const selections = editor.listSelections() + const mainSelection = getMainSelection(editor) + + // Get wrapped state of main selection + const mainRange: EditorRange = this.expandRangeByWords( + editor, + selectionToRange(mainSelection), + symbolStart, + symbolEnd + ) + + // Check if already wrapped + const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd) + const targetWrapState = !isWrapped + + // Process all selections + const newChanges: Array = [] + const newSelectionRanges: Array = [] + + for (const selection of selections) { + const [changes, newSelection] = this.setSelectionWrapState( + editor, + selection, + targetWrapState, + symbolStart, + symbolEnd + ) + + newChanges.push(...changes) + newSelectionRanges.push(selectionToRange(newSelection)) + } + + const transaction: EditorTransaction = { + changes: newChanges, + selections: newSelectionRanges, + } + + let origin = targetWrapState ? '+' : '-' + origin += 'BetterFormatting_' + symbolStart + symbolEnd + + editor.transaction(transaction, origin) + } +} \ No newline at end of file diff --git a/src/Constants.ts b/src/Constants.ts new file mode 100644 index 0000000..23c3d46 --- /dev/null +++ b/src/Constants.ts @@ -0,0 +1,42 @@ +// ============================================================ +// Debug Constants +// ============================================================ + +export const DEBUG_HEAD = '[BindThem] ' + +// ============================================================ +// Better Formatting Constants +// ============================================================ + +/** + * Characters to expand selection before wrapper symbols + */ +export const PRE_WIDOW_EXPAND_CHARS = '#$@' + +/** + * Characters to expand selection after wrapper symbols + */ +export const POST_WIDOW_EXPAND_CHARS = '!?:' + +// ============================================================ +// Sentence Navigator Constants +// ============================================================ + +/** + * Default regex for matching sentences + */ +export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)' + +/** + * Regex for matching list item prefixes + */ +export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/ + +// ============================================================ +// Heading Constants +// ============================================================ + +/** + * Regex for matching heading markers at the start of a line + */ +export const HEADING_REGEX = /^(#*)( *)(.*)/ \ No newline at end of file diff --git a/src/DirectionalCopy.ts b/src/DirectionalCopy.ts new file mode 100644 index 0000000..3aaff6e --- /dev/null +++ b/src/DirectionalCopy.ts @@ -0,0 +1,91 @@ +import { App, Editor, EditorChange, EditorTransaction, MarkdownView } from 'obsidian' +import BindThemPlugin from './main' +import { Direction } from './Entities' +import { selectionToLine, selectionToRange } from './Utils' + +/** + * DirectionalCopy provides commands to copy content in different directions + */ +export class DirectionalCopy { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Copy the current selection(s) in the specified direction + */ + public directionalCopy( + editor: Editor, + view: MarkdownView, + direction: Direction + ): void { + const selections = editor.listSelections() + + const vertical: boolean = direction === Direction.Up || direction === Direction.Down + + // If vertical we want to work with whole lines + if (vertical) { + selections.forEach((selection, idx, arr) => { + arr[idx] = selectionToLine(editor, selection) + }) + } + + const changes: Array = [] + for (const selection of selections) { + const range = selectionToRange(selection) + const content = editor.getRange(range.from, range.to) + + let change: EditorChange + switch (direction) { + case Direction.Up: { + change = { + from: range.from, + to: range.from, + text: content + '\n', + } + break + } + + case Direction.Down: { + change = { + from: range.to, + to: range.to, + text: '\n' + content, + } + break + } + + case Direction.Left: { + change = { + from: range.from, + to: range.from, + text: content, + } + break + } + + case Direction.Right: { + change = { + from: range.to, + to: range.to, + text: content, + } + break + } + } + + changes.push(change) + } + + const transaction: EditorTransaction = { + changes: changes, + } + + const origin = 'DirectionalCopy_' + String(direction) + editor.transaction(transaction, origin) + } +} \ No newline at end of file diff --git a/src/DirectionalMove.ts b/src/DirectionalMove.ts new file mode 100644 index 0000000..38f43f2 --- /dev/null +++ b/src/DirectionalMove.ts @@ -0,0 +1,82 @@ +import { App, Editor, EditorChange, EditorTransaction, MarkdownView } from 'obsidian' +import BindThemPlugin from './main' +import { Direction } from './Entities' +import { selectionToRange } from './Utils' + +/** + * DirectionalMove provides commands to move selection content left or right + */ +export class DirectionalMove { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Move the current selection(s) in the specified direction (left or right) + */ + public directionalMove( + editor: Editor, + view: MarkdownView, + direction: Direction.Left | Direction.Right + ): void { + const selections = editor.listSelections() + + const changes: Array = [] + for (const selection of selections) { + const range = selectionToRange(selection) + + let additionChange: EditorChange + let deletionChange: EditorChange + switch (direction) { + case Direction.Left: { + deletionChange = { + from: { + line: range.from.line, + ch: range.from.ch - 1, + }, + to: range.from, + text: '', + } + + additionChange = { + from: range.to, + to: range.to, + text: editor.getRange(deletionChange.from, deletionChange.to!), + } + break + } + + case Direction.Right: { + deletionChange = { + from: range.to, + to: { + line: range.to.line, + ch: range.to.ch + 1, + }, + text: '', + } + + additionChange = { + from: range.from, + to: range.from, + text: editor.getRange(deletionChange.from, deletionChange.to!), + } + break + } + } + + changes.push(deletionChange, additionChange) + } + + const transaction: EditorTransaction = { + changes: changes, + } + + const origin = 'DirectionalMove_' + String(direction) + editor.transaction(transaction, origin) + } +} \ No newline at end of file diff --git a/src/Entities.ts b/src/Entities.ts new file mode 100644 index 0000000..25b8110 --- /dev/null +++ b/src/Entities.ts @@ -0,0 +1,43 @@ +import { EditorPosition, EditorRange, EditorSelection } from 'obsidian' + +// ============================================================ +// Direction Enum +// ============================================================ + +export enum Direction { + Up = 'Up', + Down = 'Down', + Left = 'Left', + Right = 'Right', +} + +// ============================================================ +// Heading Enum +// ============================================================ + +export enum Heading { + NORMAL = 0, + H1 = 1, + H2 = 2, + H3 = 3, + H4 = 4, + H5 = 5, + H6 = 6, +} + +// ============================================================ +// Case Type Enum +// ============================================================ + +export enum CaseType { + Upper, + Lower, + Title, + Next, +} + +// ============================================================ +// Editor Types (re-exported from Obsidian for convenience) +// ============================================================ + +export type { EditorPosition, EditorRange, EditorSelection } \ No newline at end of file diff --git a/src/FileHelper.ts b/src/FileHelper.ts new file mode 100644 index 0000000..4b0219a --- /dev/null +++ b/src/FileHelper.ts @@ -0,0 +1,61 @@ +import { App, Editor, MarkdownView, Notice } from 'obsidian' +import BindThemPlugin from './main' + +/** + * FileHelper provides commands for file operations + */ +export class FileHelper { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Duplicate the current file + */ + public async duplicateFile(editor: Editor, view: MarkdownView): Promise { + const activeFile = this.app.workspace.getActiveFile() + + if (activeFile === null) { + return + } + + const selections = editor.listSelections() + const parentPath = activeFile.parent?.path ?? '' + const newFilePath = + parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension + + try { + const newFile = await this.app.vault.copy(activeFile, newFilePath) + await view.leaf.openFile(newFile) + + editor.setSelections(selections) + } catch (e) { + new Notice(String(e)) + } + } + + /** + * Create a new file in the same directory as the current file + */ + public async newAdjacentFile(editor: Editor, view: MarkdownView): Promise { + const activeFile = this.app.workspace.getActiveFile() + + if (activeFile === null) { + return + } + + const parentPath = activeFile.parent?.path ?? '' + const newFilePath = parentPath + '/' + 'Untitled.md' + + try { + const newFile = await this.app.vault.create(newFilePath, '') + await view.leaf.openFile(newFile) + } catch (e) { + new Notice(String(e)) + } + } +} \ No newline at end of file diff --git a/src/SelectionHelper.ts b/src/SelectionHelper.ts new file mode 100644 index 0000000..616eec3 --- /dev/null +++ b/src/SelectionHelper.ts @@ -0,0 +1,63 @@ +import { App, Editor, EditorRange, EditorTransaction, MarkdownView } from 'obsidian' +import BindThemPlugin from './main' +import { selectionToLine, selectionToRange } from './Utils' + +/** + * SelectionHelper provides commands for manipulating text selections + */ +export class SelectionHelper { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Select the current line(s) + */ + public selectLine(editor: Editor, view: MarkdownView): void { + const selections = editor.listSelections() + + const newSelectionRanges: Array = [] + for (const selection of selections) { + const newSelection = selectionToLine(editor, selection) + newSelectionRanges.push(selectionToRange(newSelection)) + } + + const transaction: EditorTransaction = { + selections: newSelectionRanges, + } + + editor.transaction(transaction, 'SelectionHelper_Line') + } + + /** + * Select the current word(s) + */ + public selectWord(editor: Editor, view: MarkdownView): void { + const selections = editor.listSelections() + + const newSelections: Array = [] + for (const selection of selections) { + const range = selectionToRange(selection) + + const wordStart = editor.wordAt(range.from)?.from ?? range.from + const wordEnd = editor.wordAt(range.to)?.to ?? range.from + + const newSelection: EditorRange = { + from: { line: wordStart.line, ch: wordStart.ch }, + to: { line: wordEnd.line, ch: wordEnd.ch }, + } + + newSelections.push(newSelection) + } + + const transaction: EditorTransaction = { + selections: newSelections, + } + + editor.transaction(transaction, 'SelectionHelper_Word') + } +} \ No newline at end of file diff --git a/src/SentenceNavigator.ts b/src/SentenceNavigator.ts new file mode 100644 index 0000000..c59bb0e --- /dev/null +++ b/src/SentenceNavigator.ts @@ -0,0 +1,276 @@ +import { App, Editor, EditorPosition, MarkdownView } from 'obsidian' +import BindThemPlugin from './main' +import { LIST_CHARACTER_REGEX } from './Constants' + +/** + * SentenceNavigator provides commands for navigating and manipulating sentences + * Based on obsidian-sentence-navigator functionality + */ +export class SentenceNavigator { + public app: App + private plugin: BindThemPlugin + + private sentenceRegex: RegExp + + constructor(app: App, plugin: BindThemPlugin, regexSource: string) { + this.app = app + this.plugin = plugin + this.sentenceRegex = new RegExp(regexSource, 'gm') + } + + /** + * Update the sentence regex pattern + */ + public updateRegex(regexSource: string): void { + this.sentenceRegex = new RegExp(regexSource, 'gm') + } + + /** + * Get cursor position and current paragraph text + */ + private getCursorAndParagraphText(editor: Editor): { + cursorPosition: EditorPosition + paragraphText: string + } { + const cursorPosition = editor.getCursor() + return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) } + } + + /** + * Iterate over all sentences in a paragraph + */ + private forEachSentence( + paragraphText: string, + callback: (sentence: RegExpMatchArray) => boolean | void + ): void { + for (const sentence of paragraphText.matchAll(this.sentenceRegex)) { + if (callback(sentence)) break + } + } + + /** + * Set cursor at the next non-space character + */ + private setCursorAtNextWordCharacter( + editor: Editor, + cursorPosition: EditorPosition, + paragraphText: string, + direction: 'start' | 'end' + ): void { + let ch = cursorPosition.ch + if (direction === 'start') { + while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch-- + } else { + while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++ + } + editor.setCursor({ ch, line: cursorPosition.line }) + } + + /** + * Get the previous non-empty line number + */ + private getPrevNonEmptyLine(editor: Editor, currentLine: number): number { + let prevLine = currentLine - 1 + while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine-- + return prevLine + } + + /** + * Get the next non-empty line number + */ + private getNextNonEmptyLine(editor: Editor, currentLine: number): number { + let nextLine = currentLine + 1 + while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++ + return nextLine + } + + /** + * Check if cursor is at the start of a list item + */ + private isAtStartOfListItem( + cursorPosition: EditorPosition, + paragraphText: string + ): boolean { + return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch)) + } + + /** + * Delete text from cursor to sentence boundary + */ + public deleteToBoundary(editor: Editor, view: MarkdownView, boundary: 'start' | 'end'): void { + let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) + const originalCursorPosition = cursorPosition + + if ( + paragraphText.charAt(cursorPosition.ch) === ' ' || + paragraphText.charAt(cursorPosition.ch - 1) === ' ' + ) { + this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary) + ;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)) + } + + this.forEachSentence(paragraphText, (sentence) => { + const idx = sentence.index ?? 0 + if ( + cursorPosition.ch >= idx && + cursorPosition.ch <= idx + sentence[0].length + ) { + if (boundary === 'start') { + const newText = + paragraphText.substring(0, idx) + + paragraphText.substring(originalCursorPosition.ch) + const cutLength = paragraphText.length - newText.length + editor.replaceRange( + newText, + { line: cursorPosition.line, ch: 0 }, + { line: cursorPosition.line, ch: paragraphText.length } + ) + editor.setCursor({ + line: cursorPosition.line, + ch: originalCursorPosition.ch - cutLength, + }) + } else { + const remainingLength = idx + sentence[0].length - cursorPosition.ch + const newText = + paragraphText.substring(0, originalCursorPosition.ch) + + paragraphText.substring(cursorPosition.ch + remainingLength) + editor.replaceRange( + newText, + { line: cursorPosition.line, ch: 0 }, + { line: cursorPosition.line, ch: paragraphText.length } + ) + editor.setCursor(originalCursorPosition) + } + return true + } + }) + } + + /** + * Select text from cursor to sentence boundary + */ + public selectToBoundary(editor: Editor, view: MarkdownView, boundary: 'start' | 'end'): void { + const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) + this.forEachSentence(paragraphText, (sentence) => { + const idx = sentence.index ?? 0 + if ( + cursorPosition.ch >= idx && + cursorPosition.ch <= idx + sentence[0].length + ) { + if ( + editor.getSelection().length > 0 && + (cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length) + ) { + return true + } + if (boundary === 'start') { + const precedingLength = cursorPosition.ch - idx + editor.setSelection(cursorPosition, { + line: cursorPosition.line, + ch: cursorPosition.ch - precedingLength, + }) + } else { + editor.setSelection(cursorPosition, { + line: cursorPosition.line, + ch: idx + sentence[0].length, + }) + } + return true + } + }) + } + + /** + * Move cursor to the start of the current sentence + */ + public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView): void { + let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) + + if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) { + const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line) + if (prevLine >= 0) { + editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length }) + } + return + } + + this.forEachSentence(paragraphText, (sentence) => { + const idx = sentence.index ?? 0 + while ( + cursorPosition.ch > 0 && + paragraphText.charAt(cursorPosition.ch - 1) === ' ' + ) { + editor.setCursor({ + line: cursorPosition.line, + ch: --cursorPosition.ch, + }) + } + if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) { + editor.setCursor({ line: cursorPosition.line, ch: idx }) + } + }) + } + + /** + * Move cursor to the start of the next sentence + */ + public moveToStartOfNextSentence(editor: Editor, view: MarkdownView): void { + let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) + + if (cursorPosition.ch === paragraphText.length) { + const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line) + if (nextLine < editor.lineCount()) { + editor.setCursor({ line: nextLine, ch: 0 }) + } + return + } + + this.forEachSentence(paragraphText, (sentence) => { + const idx = sentence.index ?? 0 + while ( + cursorPosition.ch < paragraphText.length && + paragraphText.charAt(cursorPosition.ch) === ' ' + ) { + editor.setCursor({ + line: cursorPosition.line, + ch: ++cursorPosition.ch, + }) + } + if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) { + const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length } + this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end') + if (endPos.ch >= paragraphText.length) { + const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line) + if (nextLine < editor.lineCount()) { + editor.setCursor({ line: nextLine, ch: 0 }) + } + } + } + }) + } + + /** + * Select the current sentence + */ + public selectSentence(editor: Editor, view: MarkdownView): void { + const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) + let offset = 0 + let text = paragraphText + const matches = paragraphText.match(/^(\d+\.|[-*+]) /) + if (matches) { + offset = matches[0].length + text = paragraphText.slice(offset) + } + + this.forEachSentence(text, (sentence) => { + const idx = sentence.index ?? 0 + if (cursorPosition.ch <= offset + idx + sentence[0].length) { + editor.setSelection( + { line: cursorPosition.line, ch: offset + idx }, + { line: cursorPosition.line, ch: offset + idx + sentence[0].length } + ) + return true + } + }) + } +} \ No newline at end of file diff --git a/src/ToggleHeading.ts b/src/ToggleHeading.ts new file mode 100644 index 0000000..d128c09 --- /dev/null +++ b/src/ToggleHeading.ts @@ -0,0 +1,139 @@ +import { + App, + Editor, + EditorChange, + EditorPosition, + EditorTransaction, + MarkdownView, +} from 'obsidian' +import BindThemPlugin from './main' +import { Heading } from './Entities' +import { getMainSelection, selectionToRange } from './Utils' +import { HEADING_REGEX } from './Constants' + +/** + * ToggleHeading provides commands to toggle markdown heading levels + */ +export class ToggleHeading { + public app: App + private plugin: BindThemPlugin + + constructor(app: App, plugin: BindThemPlugin) { + this.app = app + this.plugin = plugin + } + + /** + * Create a change to set the heading level for a specific line + */ + private setHeading( + editor: Editor, + heading: Heading, + line: number + ): EditorChange { + const headingStr = '#'.repeat(heading) + const text = editor.getLine(line) + const matches = HEADING_REGEX.exec(text)! + + const from: EditorPosition = { + line: line, + ch: 0, + } + const to: EditorPosition = { + line: line, + ch: matches[1].length + matches[2].length, + } + + const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' ' + + return { + from: from, + to: to, + text: replacementStr, + } + } + + /** + * Get the heading level of a specific line + */ + private getHeadingOfLine(editor: Editor, line: number): Heading { + const text = editor.getLine(line) + const matches = HEADING_REGEX.exec(text)! + + return matches[1].length as Heading + } + + /** + * Toggle heading for the current selection(s) + * If the line already has the specified heading, remove it (set to NORMAL) + */ + public toggleHeading( + editor: Editor, + view: MarkdownView, + heading: Heading + ): void { + const selections = editor.listSelections() + const mainSelection = getMainSelection(editor) + const mainRange = selectionToRange(mainSelection) + + const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line) + const targetHeading = heading === mainHeading ? Heading.NORMAL : heading + + const changes: Array = [] + + // Collect unique lines from all selections + const linesToSet: Set = new Set() + for (const selection of selections) { + const range = selectionToRange(selection) + for (let line = range.from.line; line <= range.to.line; line++) { + linesToSet.add(line) + } + } + + // Create changes for each line + for (const line of linesToSet) { + if (editor.getLine(line) === '') { + continue + } + changes.push(this.setHeading(editor, targetHeading, line)) + } + + const transaction: EditorTransaction = { + changes: changes, + } + + editor.transaction(transaction, 'ToggleHeading' + heading.toString()) + } + + /** + * Toggle heading with stripping of inline formatting + * This removes bold/italic formatting when toggling headings + */ + public toggleHeadingWithStrip( + editor: Editor, + view: MarkdownView, + level: number + ): void { + const cursor = editor.getCursor() + const line = editor.getLine(cursor.line) + const match = line.match(/^(#+)\s(.*)/) + + let content = line + let currentLevel = 0 + + if (match) { + currentLevel = match[1].length + content = match[2] + } + + // Strip bold and italic formatting + content = content + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)(.*?)\1/g, '$2') + + editor.setLine( + cursor.line, + currentLevel === level ? content : '#'.repeat(level) + ' ' + content + ) + } +} \ No newline at end of file diff --git a/src/Utils.ts b/src/Utils.ts new file mode 100644 index 0000000..365f485 --- /dev/null +++ b/src/Utils.ts @@ -0,0 +1,94 @@ +import { Editor, EditorRange, EditorSelection } from 'obsidian' + +// ============================================================ +// Selection Utilities +// ============================================================ + +/** + * Get the main (primary) selection from the editor + */ +export function getMainSelection(editor: Editor): EditorSelection { + return { + anchor: editor.getCursor('anchor'), + head: editor.getCursor('head'), + } +} + +/** + * Convert a selection to a range (from/to with sorted positions) + */ +export function selectionToRange(selection: EditorSelection): EditorRange { + const sortedPositions = [selection.anchor, selection.head].sort((a, b) => { + if (a.line !== b.line) return a.line - b.line + return a.ch - b.ch + }) + return { + from: sortedPositions[0], + to: sortedPositions[1], + } +} + +/** + * Convert a selection to encompass full lines + */ +export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection { + const range = selectionToRange(selection) + return { + anchor: { line: range.from.line, ch: 0 }, + head: { line: range.to.line, ch: editor.getLine(range.to.line).length }, + } +} + +// ============================================================ +// Text Utilities +// ============================================================ + +/** + * Get leading whitespace (indentation) from a line + */ +export function getLeadingWhitespace(lineContent: string): string { + const indentation = lineContent.match(/^\s+/) + return indentation ? indentation[0] : '' +} + +/** + * Check if a character is a letter or digit + */ +export function isLetterOrDigit(char: string): boolean { + return /\p{L}\p{M}*/u.test(char) || /\d/.test(char) +} + +/** + * Get the word range at a given position + */ +export function wordRangeAtPos( + pos: { line: number; ch: number }, + lineContent: string +): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } { + let start = pos.ch + let end = pos.ch + while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start-- + while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++ + return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } } +} + +// ============================================================ +// Case Transformation Utilities +// ============================================================ + +const LOWERCASE_ARTICLES = ['the', 'a', 'an'] + +/** + * Convert text to title case + */ +export function toTitleCase(text: string): string { + return text + .split(/(\s+)/) + .map((w, i, all) => { + if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) { + return w.toLowerCase() + } + return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() + }) + .join(' ') +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index f485204..3b12562 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,466 +1,93 @@ -import { App, Editor, MarkdownView, MarkdownFileInfo, Notice, Plugin } from 'obsidian'; -import { - BindThemSettings, - BindThemSettingTab, - DEFAULT_SETTINGS, - createDefaultEnabledCommands -} from './settings'; - -// ============================================================ -// Utility Types and Functions -// ============================================================ - -interface EditorPosition { - line: number; - ch: number; -} - -interface EditorSelection { - anchor: EditorPosition; - head: EditorPosition; -} - -interface EditorRange { - from: EditorPosition; - to: EditorPosition; -} - -function getMainSelection(editor: Editor): EditorSelection { - return { anchor: editor.getCursor('anchor'), head: editor.getCursor('head') }; -} - -function selectionToRange(selection: EditorSelection): EditorRange { - const sortedPositions = [selection.anchor, selection.head].sort((a, b) => { - if (a.line !== b.line) return a.line - b.line; - return a.ch - b.ch; - }); - return { from: sortedPositions[0], to: sortedPositions[1] }; -} - -function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection { - const range = selectionToRange(selection); - return { anchor: { line: range.from.line, ch: 0 }, head: { line: range.to.line, ch: editor.getLine(range.to.line).length } }; -} - -function getLeadingWhitespace(lineContent: string): string { - const indentation = lineContent.match(/^\s+/); - return indentation ? indentation[0] : ''; -} - -function isLetterOrDigit(char: string): boolean { - return /\p{L}\p{M}*/u.test(char) || /\d/.test(char); -} - -function wordRangeAtPos(pos: EditorPosition, lineContent: string): EditorSelection { - let start = pos.ch, end = pos.ch; - while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start--; - while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++; - return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } }; -} - -// ============================================================ -// Sentence Navigator (from obsidian-sentence-navigator) -// ============================================================ - -const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/; - -class SentenceNavigator { - private sentenceRegex: RegExp; - - constructor(regexSource: string) { - this.sentenceRegex = new RegExp(regexSource, 'gm'); - } - - updateRegex(regexSource: string): void { - this.sentenceRegex = new RegExp(regexSource, 'gm'); - } - - private getCursorAndParagraphText(editor: Editor): { cursorPosition: EditorPosition; paragraphText: string } { - const cursorPosition = editor.getCursor(); - return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) }; - } - - private forEachSentence(paragraphText: string, callback: (sentence: RegExpMatchArray) => boolean | void): void { - for (const sentence of paragraphText.matchAll(this.sentenceRegex)) { - if (callback(sentence)) break; - } - } - - private setCursorAtNextWordCharacter(editor: Editor, cursorPosition: EditorPosition, paragraphText: string, direction: 'start' | 'end'): void { - let ch = cursorPosition.ch; - if (direction === 'start') { while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch--; } - else { while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++; } - editor.setCursor({ ch, line: cursorPosition.line }); - } - - private getPrevNonEmptyLine(editor: Editor, currentLine: number): number { - let prevLine = currentLine - 1; - while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine--; - return prevLine; - } - - private getNextNonEmptyLine(editor: Editor, currentLine: number): number { - let nextLine = currentLine + 1; - while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++; - return nextLine; - } - - private isAtStartOfListItem(cursorPosition: EditorPosition, paragraphText: string): boolean { - return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch)); - } - - deleteToBoundary(editor: Editor, boundary: 'start' | 'end'): void { - let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor); - const originalCursorPosition = cursorPosition; - - if (paragraphText.charAt(cursorPosition.ch) === ' ' || paragraphText.charAt(cursorPosition.ch - 1) === ' ') { - this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary); - ({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)); - } - - this.forEachSentence(paragraphText, (sentence) => { - const idx = sentence.index ?? 0; - if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) { - if (boundary === 'start') { - const newText = paragraphText.substring(0, idx) + paragraphText.substring(originalCursorPosition.ch); - const cutLength = paragraphText.length - newText.length; - editor.replaceRange(newText, { line: cursorPosition.line, ch: 0 }, { line: cursorPosition.line, ch: paragraphText.length }); - editor.setCursor({ line: cursorPosition.line, ch: originalCursorPosition.ch - cutLength }); - } else { - const remainingLength = idx + sentence[0].length - cursorPosition.ch; - const newText = paragraphText.substring(0, originalCursorPosition.ch) + paragraphText.substring(cursorPosition.ch + remainingLength); - editor.replaceRange(newText, { line: cursorPosition.line, ch: 0 }, { line: cursorPosition.line, ch: paragraphText.length }); - editor.setCursor(originalCursorPosition); - } - return true; - } - }); - } - - selectToBoundary(editor: Editor, boundary: 'start' | 'end'): void { - const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor); - this.forEachSentence(paragraphText, (sentence) => { - const idx = sentence.index ?? 0; - if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) { - if (editor.getSelection().length > 0 && (cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length)) return true; - if (boundary === 'start') { - const precedingLength = cursorPosition.ch - idx; - editor.setSelection(cursorPosition, { line: cursorPosition.line, ch: cursorPosition.ch - precedingLength }); - } else { - editor.setSelection(cursorPosition, { line: cursorPosition.line, ch: idx + sentence[0].length }); - } - return true; - } - }); - } - - moveToStartOfCurrentSentence(editor: Editor): void { - let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor); - if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) { - const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line); - if (prevLine >= 0) editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length }); - return; - } - this.forEachSentence(paragraphText, (sentence) => { - const idx = sentence.index ?? 0; - while (cursorPosition.ch > 0 && paragraphText.charAt(cursorPosition.ch - 1) === ' ') { - editor.setCursor({ line: cursorPosition.line, ch: --cursorPosition.ch }); - } - if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) { - editor.setCursor({ line: cursorPosition.line, ch: idx }); - } - }); - } - - moveToStartOfNextSentence(editor: Editor): void { - let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor); - if (cursorPosition.ch === paragraphText.length) { - const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line); - if (nextLine < editor.lineCount()) editor.setCursor({ line: nextLine, ch: 0 }); - return; - } - this.forEachSentence(paragraphText, (sentence) => { - const idx = sentence.index ?? 0; - while (cursorPosition.ch < paragraphText.length && paragraphText.charAt(cursorPosition.ch) === ' ') { - editor.setCursor({ line: cursorPosition.line, ch: ++cursorPosition.ch }); - } - if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) { - const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length }; - this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end'); - if (endPos.ch >= paragraphText.length) { - const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line); - if (nextLine < editor.lineCount()) editor.setCursor({ line: nextLine, ch: 0 }); - } - } - }); - } - - selectSentence(editor: Editor): void { - const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor); - let offset = 0; - let text = paragraphText; - const matches = paragraphText.match(/^(\d+\.|[-*+]) /); - if (matches) { offset = matches[0].length; text = paragraphText.slice(offset); } - this.forEachSentence(text, (sentence) => { - const idx = sentence.index ?? 0; - if (cursorPosition.ch <= offset + idx + sentence[0].length) { - editor.setSelection({ line: cursorPosition.line, ch: offset + idx }, { line: cursorPosition.line, ch: offset + idx + sentence[0].length }); - return true; - } - }); - } -} - -// ============================================================ -// Better Formatting (from obsidian-tweaks) -// ============================================================ - -class BetterFormatting { - static PRE_CHARS = '#$@'; - static POST_CHARS = '!?::'; - - toggleWrapper(editor: Editor, symbolStart: string, symbolEnd?: string): void { - symbolEnd = symbolEnd ?? symbolStart; - const sel = getMainSelection(editor); - const wordAtFrom = editor.wordAt(sel.anchor); - const wordAtTo = editor.wordAt(sel.head); - let from = wordAtFrom ? wordAtFrom.from : sel.anchor; - let to = wordAtTo ? wordAtTo.to : sel.head; - - while (from.ch > 0) { - const prev = { line: from.line, ch: from.ch - 1 }; - const char = editor.getRange(prev, from); - if (symbolStart.includes(char) || !BetterFormatting.PRE_CHARS.includes(char)) break; - from = prev; - } - while (to.ch < editor.getLine(to.line).length) { - const next = { line: to.line, ch: to.ch + 1 }; - const char = editor.getRange(to, next); - if (symbolEnd.includes(char) || !BetterFormatting.POST_CHARS.includes(char)) break; - to = next; - } - - if (from.ch >= symbolStart.length) { - const prev = { line: from.line, ch: from.ch - symbolStart.length }; - if (editor.getRange(prev, from) === symbolStart) from = prev; - } - const next = { line: to.line, ch: to.ch + symbolEnd.length }; - if (editor.getRange(to, next) === symbolEnd) to = next; - - const text = editor.getRange(from, to); - const isWrapped = text.startsWith(symbolStart) && text.endsWith(symbolEnd); - - if (isWrapped) { - editor.replaceRange('', from, { line: from.line, ch: from.ch + symbolStart.length }); - editor.replaceRange('', { line: to.line, ch: to.ch - symbolEnd.length }, to); - } else { - editor.replaceRange(symbolStart, from); - editor.replaceRange(symbolEnd, { line: to.line, ch: to.ch + symbolStart.length }); - } - } -} - -// ============================================================ -// Directional Copy & Move (from obsidian-tweaks) -// ============================================================ - -enum Direction { Up = 'Up', Down = 'Down', Left = 'Left', Right = 'Right' } - -class DirectionalCopy { - copy(editor: Editor, direction: Direction): void { - const sels = editor.listSelections(); - const vertical = direction === Direction.Up || direction === Direction.Down; - const processed = vertical ? sels.map(s => selectionToLine(editor, s)) : sels; - for (const sel of processed) { - const range = selectionToRange(sel); - const content = editor.getRange(range.from, range.to); - if (direction === Direction.Up) editor.replaceRange(content + '\n', range.from); - else if (direction === Direction.Down) editor.replaceRange('\n' + content, range.to); - else if (direction === Direction.Left) editor.replaceRange(content, range.from); - else editor.replaceRange(content, range.to); - } - } -} - -class DirectionalMove { - move(editor: Editor, direction: Direction): void { - for (const sel of editor.listSelections()) { - const range = selectionToRange(sel); - if (direction === Direction.Left && range.from.ch > 0) { - const from = { line: range.from.line, ch: range.from.ch - 1 }; - const char = editor.getRange(from, range.from); - editor.replaceRange('', from, range.from); - editor.replaceRange(char, range.to); - } else if (direction === Direction.Right) { - const to = { line: range.to.line, ch: range.to.ch + 1 }; - const char = editor.getRange(range.to, to); - editor.replaceRange('', range.to, to); - editor.replaceRange(char, range.from); - } - } - } -} - -// ============================================================ -// Toggle Heading (from obsidian-tweaks and heading-toggler) -// ============================================================ - -class ToggleHeading { - toggle(editor: Editor, heading: number): void { - const mainRange = selectionToRange(getMainSelection(editor)); - const mainMatch = editor.getLine(mainRange.from.line).match(/^(#*)( *)/); - const mainHeading = mainMatch ? mainMatch[1].length : 0; - const target = heading === mainHeading ? 0 : heading; - - const lines = new Set(); - for (const sel of editor.listSelections()) { - const range = selectionToRange(sel); - for (let l = range.from.line; l <= range.to.line; l++) lines.add(l); - } - - for (const line of Array.from(lines).sort((a, b) => a - b)) { - const text = editor.getLine(line); - if (!text) continue; - const match = text.match(/^(#*)( *)/); - if (!match) continue; - editor.replaceRange(target === 0 ? '' : '#'.repeat(target) + ' ', { line, ch: 0 }, { line, ch: match[1].length + match[2].length }); - } - } - - toggleWithStrip(editor: Editor, level: number): void { - const cursor = editor.getCursor(); - const line = editor.getLine(cursor.line); - const match = line.match(/^(#+)\s(.*)/); - let content = line, currentLevel = 0; - if (match) { currentLevel = match[1].length; content = match[2]; } - content = content.replace(/(\*\*|__)(.*?)\1/g, '$2').replace(/(\*|_)(.*?)\1/g, '$2'); - editor.setLine(cursor.line, currentLevel === level ? content : '#'.repeat(level) + ' ' + content); - } -} - -// ============================================================ -// Case Transformation -// ============================================================ - -enum CaseType { Upper, Lower, Title, Next } -const LOWERCASE_ARTICLES = ['the', 'a', 'an']; - -function toTitleCase(text: string): string { - return text.split(/(\s+)/).map((w, i, all) => { - if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) return w.toLowerCase(); - return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); - }).join(' '); -} - -// ============================================================ -// Go To Line Modal -// ============================================================ - -class GoToLineModal { - private modalEl: HTMLElement; - private inputEl: HTMLInputElement; - private onSubmit: (line: number) => void; - private lineCount: number; - private app: App; - - constructor(app: App, lineCount: number, onSubmit: (line: number) => void) { - this.app = app; - this.lineCount = lineCount; - this.onSubmit = onSubmit; - this.modalEl = document.createElement('div'); - this.modalEl.className = 'modal-container bindthem-modal'; - this.inputEl = document.createElement('input'); - } - - open(): void { - const overlay = document.createElement('div'); - overlay.className = 'modal-bg'; - overlay.setCssStyles({ position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', background: 'rgba(0,0,0,0.5)', zIndex: '1000' }); - - this.modalEl.setCssStyles({ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--background-primary)', padding: '20px', borderRadius: '8px', zIndex: '1001', maxWidth: '300px' }); - - const title = document.createElement('h2'); - title.textContent = 'Go to line'; - title.setCssStyles({ marginTop: '0' }); - this.modalEl.appendChild(title); - - this.inputEl.type = 'number'; - this.inputEl.min = '1'; - this.inputEl.max = String(this.lineCount); - this.inputEl.setCssStyles({ width: '100%', marginBottom: '10px', padding: '8px' }); - this.modalEl.appendChild(this.inputEl); - - const btn = document.createElement('button'); - btn.textContent = 'Go'; - btn.setCssStyles({ padding: '8px 16px' }); - btn.onclick = () => { - const n = parseInt(this.inputEl.value) - 1; - if (n >= 0 && n < this.lineCount) { - this.onSubmit(n); - this.close(); - } - }; - this.modalEl.appendChild(btn); - - this.inputEl.onkeydown = (e) => { - if (e.key === 'Enter') btn.click(); - if (e.key === 'Escape') this.close(); - }; - - overlay.appendChild(this.modalEl); - document.body.appendChild(overlay); - this.overlay = overlay; - this.inputEl.focus(); - } - - private overlay: HTMLElement | null = null; - - close(): void { - if (this.overlay) { - this.overlay.remove(); - this.overlay = null; - } - } -} +import { App, Editor, MarkdownView, MarkdownFileInfo, Notice, Plugin } from 'obsidian' +import { + BindThemSettings, + BindThemSettingTab, + DEFAULT_SETTINGS, + createDefaultEnabledCommands, +} from './settings' +import { BetterFormatting } from './BetterFormatting' +import { DirectionalCopy } from './DirectionalCopy' +import { DirectionalMove } from './DirectionalMove' +import { ToggleHeading } from './ToggleHeading' +import { SelectionHelper } from './SelectionHelper' +import { FileHelper } from './FileHelper' +import { SentenceNavigator } from './SentenceNavigator' +import { Direction, Heading, CaseType } from './Entities' +import { DEBUG_HEAD, DEFAULT_SENTENCE_REGEX } from './Constants' +import { + getLeadingWhitespace, + wordRangeAtPos, + toTitleCase, + selectionToRange, +} from './Utils' // ============================================================ // Main Plugin Class // ============================================================ export default class BindThemPlugin extends Plugin { - settings!: BindThemSettings; - private betterFormatting = new BetterFormatting(); - private directionalCopy = new DirectionalCopy(); - private directionalMove = new DirectionalMove(); - private toggleHeading = new ToggleHeading(); - private sentenceNavigator!: SentenceNavigator; + public settings!: BindThemSettings + + private betterFormatting!: BetterFormatting + private directionalCopy!: DirectionalCopy + private directionalMove!: DirectionalMove + private toggleHeading!: ToggleHeading + private selectionHelper!: SelectionHelper + private fileHelper!: FileHelper + private sentenceNavigator!: SentenceNavigator async onload(): Promise { - await this.loadSettings(); - this.sentenceNavigator = new SentenceNavigator(this.settings.sentenceRegexSource); + await this.loadSettings() + this.debug('Loading BindThem...') - // Register commands conditionally based on settings - this.registerCommands(); + // Initialize feature classes + this.betterFormatting = new BetterFormatting(this.app, this) + this.directionalCopy = new DirectionalCopy(this.app, this) + this.directionalMove = new DirectionalMove(this.app, this) + this.toggleHeading = new ToggleHeading(this.app, this) + this.selectionHelper = new SelectionHelper(this.app, this) + this.fileHelper = new FileHelper(this.app, this) + this.sentenceNavigator = new SentenceNavigator( + this.app, + this, + this.settings.sentenceRegexSource + ) - this.addSettingTab(new BindThemSettingTab(this.app, this)); + // Register commands + this.registerCommands() + + this.addSettingTab(new BindThemSettingTab(this.app, this)) + } + + onunload(): void { + console.log('Unloading BindThem...') + } + + /** + * Debug logging + */ + public debug(str: string): void { + if (this.settings.debug) { + console.log(DEBUG_HEAD + str) + } } /** * Check if a command is enabled in settings */ - isCommandEnabled(commandId: string): boolean { - return this.settings.enabledCommands[commandId] !== false; + public isCommandEnabled(commandId: string): boolean { + return this.settings.enabledCommands[commandId] !== false } /** * Register a command only if it's enabled in settings */ - addConditionalCommand(options: { - id: string; - name: string; - icon?: string; - editorCallback?: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => void; - callback?: () => void; + private addConditionalCommand(options: { + id: string + name: string + icon?: string + editorCallback?: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => void + callback?: () => void }): void { if (this.isCommandEnabled(options.id)) { this.addCommand({ @@ -468,301 +95,938 @@ export default class BindThemPlugin extends Plugin { name: options.name, icon: options.icon, editorCallback: options.editorCallback, - callback: options.callback - }); + callback: options.callback, + }) } } /** * Register all commands based on current settings */ - registerCommands(): void { - // Better Formatting - this.addConditionalCommand({ id: 'toggle-bold-underscore', name: 'Toggle bold underscore', icon: 'bold', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '__', '__') }); - this.addConditionalCommand({ id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk', icon: 'bold', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '**', '**') }); - this.addConditionalCommand({ id: 'toggle-italics-underscore', name: 'Toggle italics underscore', icon: 'italic', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '_', '_') }); - this.addConditionalCommand({ id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk', icon: 'italic', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '*', '*') }); - this.addConditionalCommand({ id: 'toggle-code', name: 'Toggle code', icon: 'code', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '`', '`') }); - this.addConditionalCommand({ id: 'toggle-comment', name: 'Toggle comment', icon: 'message-square', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '%%', '%%') }); - this.addConditionalCommand({ id: 'toggle-highlight', name: 'Toggle highlight', icon: 'highlighter', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '==', '==') }); - this.addConditionalCommand({ id: 'toggle-strikethrough', name: 'Toggle strikethrough', icon: 'strikethrough', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '~~', '~~') }); - this.addConditionalCommand({ id: 'toggle-math-inline', name: 'Toggle math inline', icon: 'function-square', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '$', '$') }); - this.addConditionalCommand({ id: 'toggle-math-block', name: 'Toggle math block', icon: 'square-function', editorCallback: (e) => this.betterFormatting.toggleWrapper(e, '$$', '$$') }); + private registerCommands(): void { + // ============================================================ + // SelectionHelper Commands + // ============================================================ + this.addConditionalCommand({ + id: 'select-line', + name: 'Select Current Line(s)', + icon: 'text-select', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.selectionHelper.selectLine(editor, view) + }, + }) + this.addConditionalCommand({ + id: 'select-word', + name: 'Select Current Word(s)', + icon: 'text-cursor', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.selectionHelper.selectWord(editor, view) + }, + }) - // Directional Copy/Move - this.addConditionalCommand({ id: 'copy-up', name: 'Copy up', icon: 'copy', editorCallback: (e) => this.directionalCopy.copy(e, Direction.Up) }); - this.addConditionalCommand({ id: 'copy-down', name: 'Copy down', icon: 'copy', editorCallback: (e) => this.directionalCopy.copy(e, Direction.Down) }); - this.addConditionalCommand({ id: 'copy-left', name: 'Copy left', icon: 'copy', editorCallback: (e) => this.directionalCopy.copy(e, Direction.Left) }); - this.addConditionalCommand({ id: 'copy-right', name: 'Copy right', icon: 'copy', editorCallback: (e) => this.directionalCopy.copy(e, Direction.Right) }); - this.addConditionalCommand({ id: 'move-left', name: 'Move left', icon: 'move-left', editorCallback: (e) => this.directionalMove.move(e, Direction.Left) }); - this.addConditionalCommand({ id: 'move-right', name: 'Move right', icon: 'move-right', editorCallback: (e) => this.directionalMove.move(e, Direction.Right) }); + // ============================================================ + // Better Formatting Commands + // ============================================================ + this.addConditionalCommand({ + id: 'toggle-bold-underscore', + name: 'Better Toggle Bold (underscores)', + icon: 'bold', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '__', '__') + }, + }) + this.addConditionalCommand({ + id: 'toggle-bold-asterisk', + name: 'Better Toggle Bold (asterisks)', + icon: 'bold', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '**', '**') + }, + }) + this.addConditionalCommand({ + id: 'toggle-italics-underscore', + name: 'Better Toggle Italics (underscore)', + icon: 'italic', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '_', '_') + }, + }) + this.addConditionalCommand({ + id: 'toggle-italics-asterisk', + name: 'Better Toggle Italics (asterisk)', + icon: 'italic', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '*', '*') + }, + }) + this.addConditionalCommand({ + id: 'toggle-code', + name: 'Better Toggle Code', + icon: 'code', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '`', '`') + }, + }) + this.addConditionalCommand({ + id: 'toggle-comment', + name: 'Better Toggle Comment', + icon: 'message-square', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '%%', '%%') + }, + }) + this.addConditionalCommand({ + id: 'toggle-highlight', + name: 'Better Toggle Highlight', + icon: 'highlighter', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '==', '==') + }, + }) + this.addConditionalCommand({ + id: 'toggle-strikethrough', + name: 'Better Toggle Strikethrough', + icon: 'strikethrough', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '~~', '~~') + }, + }) + this.addConditionalCommand({ + id: 'toggle-math-inline', + name: 'Better Toggle Math (Inline)', + icon: 'function-square', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '$', '$') + }, + }) + this.addConditionalCommand({ + id: 'toggle-math-block', + name: 'Better Toggle Math (Block)', + icon: 'square-function', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.betterFormatting.toggleWrapper(editor, view, '$$', '$$') + }, + }) - // Toggle Heading - this.addConditionalCommand({ id: 'toggle-heading-1', name: 'Toggle heading 1', icon: 'heading-1', editorCallback: (e) => this.toggleHeading.toggle(e, 1) }); - this.addConditionalCommand({ id: 'toggle-heading-2', name: 'Toggle heading 2', icon: 'heading-2', editorCallback: (e) => this.toggleHeading.toggle(e, 2) }); - this.addConditionalCommand({ id: 'toggle-heading-3', name: 'Toggle heading 3', icon: 'heading-3', editorCallback: (e) => this.toggleHeading.toggle(e, 3) }); - this.addConditionalCommand({ id: 'toggle-heading-4', name: 'Toggle heading 4', icon: 'heading-4', editorCallback: (e) => this.toggleHeading.toggle(e, 4) }); - this.addConditionalCommand({ id: 'toggle-heading-5', name: 'Toggle heading 5', icon: 'heading-5', editorCallback: (e) => this.toggleHeading.toggle(e, 5) }); - this.addConditionalCommand({ id: 'toggle-heading-6', name: 'Toggle heading 6', icon: 'heading-6', editorCallback: (e) => this.toggleHeading.toggle(e, 6) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-1', name: 'Toggle heading 1 (strip formatting)', icon: 'heading-1', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 1) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-2', name: 'Toggle heading 2 (strip formatting)', icon: 'heading-2', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 2) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-3', name: 'Toggle heading 3 (strip formatting)', icon: 'heading-3', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 3) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-4', name: 'Toggle heading 4 (strip formatting)', icon: 'heading-4', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 4) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-5', name: 'Toggle heading 5 (strip formatting)', icon: 'heading-5', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 5) }); - this.addConditionalCommand({ id: 'toggle-heading-strip-6', name: 'Toggle heading 6 (strip formatting)', icon: 'heading-6', editorCallback: (e) => this.toggleHeading.toggleWithStrip(e, 6) }); + // ============================================================ + // Directional Copy Commands + // ============================================================ + this.addConditionalCommand({ + id: 'copy-up', + name: 'Copy Current Line(s) Up', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalCopy.directionalCopy(editor, view, Direction.Up) + }, + }) + this.addConditionalCommand({ + id: 'copy-down', + name: 'Copy Current Line(s) Down', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalCopy.directionalCopy(editor, view, Direction.Down) + }, + }) + this.addConditionalCommand({ + id: 'copy-left', + name: 'Copy Current Selection(s) Left', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalCopy.directionalCopy(editor, view, Direction.Left) + }, + }) + this.addConditionalCommand({ + id: 'copy-right', + name: 'Copy Current Selections(s) Right', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalCopy.directionalCopy(editor, view, Direction.Right) + }, + }) - // Sentence Navigator - this.addConditionalCommand({ id: 'delete-to-start-of-sentence', name: 'Delete to start of sentence', icon: 'delete', editorCallback: (e) => this.sentenceNavigator.deleteToBoundary(e, 'start') }); - this.addConditionalCommand({ id: 'delete-to-end-of-sentence', name: 'Delete to end of sentence', icon: 'delete', editorCallback: (e) => this.sentenceNavigator.deleteToBoundary(e, 'end') }); - this.addConditionalCommand({ id: 'select-to-start-of-sentence', name: 'Select to start of sentence', icon: 'text-select', editorCallback: (e) => this.sentenceNavigator.selectToBoundary(e, 'start') }); - this.addConditionalCommand({ id: 'select-to-end-of-sentence', name: 'Select to end of sentence', icon: 'text-select', editorCallback: (e) => this.sentenceNavigator.selectToBoundary(e, 'end') }); - this.addConditionalCommand({ id: 'move-to-start-of-current-sentence', name: 'Move to start of current sentence', icon: 'arrow-up-left', editorCallback: (e) => this.sentenceNavigator.moveToStartOfCurrentSentence(e) }); - this.addConditionalCommand({ id: 'move-to-start-of-next-sentence', name: 'Move to start of next sentence', icon: 'arrow-down-right', editorCallback: (e) => this.sentenceNavigator.moveToStartOfNextSentence(e) }); - this.addConditionalCommand({ id: 'select-sentence', name: 'Select current sentence', icon: 'text-quote', editorCallback: (e) => this.sentenceNavigator.selectSentence(e) }); + // ============================================================ + // Directional Move Commands + // ============================================================ + this.addConditionalCommand({ + id: 'move-left', + name: 'Move Selection Left', + icon: 'move-left', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalMove.directionalMove(editor, view, Direction.Left) + }, + }) + this.addConditionalCommand({ + id: 'move-right', + name: 'Move Selection Right', + icon: 'move-right', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.directionalMove.directionalMove(editor, view, Direction.Right) + }, + }) + // ============================================================ + // Toggle Heading Commands + // ============================================================ + this.addConditionalCommand({ + id: 'toggle-heading-1', + name: 'Toggle Heading - H1', + icon: 'heading-1', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H1) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-2', + name: 'Toggle Heading - H2', + icon: 'heading-2', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H2) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-3', + name: 'Toggle Heading - H3', + icon: 'heading-3', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H3) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-4', + name: 'Toggle Heading - H4', + icon: 'heading-4', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H4) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-5', + name: 'Toggle Heading - H5', + icon: 'heading-5', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H5) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-6', + name: 'Toggle Heading - H6', + icon: 'heading-6', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeading(editor, view, Heading.H6) + }, + }) + // Heading with strip formatting + this.addConditionalCommand({ + id: 'toggle-heading-strip-1', + name: 'Toggle Heading - H1 (strip formatting)', + icon: 'heading-1', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 1) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-strip-2', + name: 'Toggle Heading - H2 (strip formatting)', + icon: 'heading-2', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 2) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-strip-3', + name: 'Toggle Heading - H3 (strip formatting)', + icon: 'heading-3', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 3) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-strip-4', + name: 'Toggle Heading - H4 (strip formatting)', + icon: 'heading-4', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 4) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-strip-5', + name: 'Toggle Heading - H5 (strip formatting)', + icon: 'heading-5', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 5) + }, + }) + this.addConditionalCommand({ + id: 'toggle-heading-strip-6', + name: 'Toggle Heading - H6 (strip formatting)', + icon: 'heading-6', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.toggleHeading.toggleHeadingWithStrip(editor, view, 6) + }, + }) + + // ============================================================ + // Sentence Navigator Commands + // ============================================================ + this.addConditionalCommand({ + id: 'delete-to-start-of-sentence', + name: 'Delete to Start of Sentence', + icon: 'delete', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.deleteToBoundary(editor, view, 'start') + }, + }) + this.addConditionalCommand({ + id: 'delete-to-end-of-sentence', + name: 'Delete to End of Sentence', + icon: 'delete', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.deleteToBoundary(editor, view, 'end') + }, + }) + this.addConditionalCommand({ + id: 'select-to-start-of-sentence', + name: 'Select to Start of Sentence', + icon: 'text-select', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.selectToBoundary(editor, view, 'start') + }, + }) + this.addConditionalCommand({ + id: 'select-to-end-of-sentence', + name: 'Select to End of Sentence', + icon: 'text-select', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.selectToBoundary(editor, view, 'end') + }, + }) + this.addConditionalCommand({ + id: 'move-to-start-of-current-sentence', + name: 'Move to Start of Current Sentence', + icon: 'arrow-up-left', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.moveToStartOfCurrentSentence(editor, view) + }, + }) + this.addConditionalCommand({ + id: 'move-to-start-of-next-sentence', + name: 'Move to Start of Next Sentence', + icon: 'arrow-down-right', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.moveToStartOfNextSentence(editor, view) + }, + }) + this.addConditionalCommand({ + id: 'select-sentence', + name: 'Select Current Sentence', + icon: 'text-quote', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.sentenceNavigator.selectSentence(editor, view) + }, + }) + + // ============================================================ + // File Helper Commands + // ============================================================ + this.addConditionalCommand({ + id: 'duplicate-file', + name: 'Duplicate File', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + void this.fileHelper.duplicateFile(editor, view) + }, + }) + this.addConditionalCommand({ + id: 'new-adjacent-file', + name: 'New Adjacent File', + icon: 'file-plus', + editorCallback: (editor: Editor, view: MarkdownView) => { + void this.fileHelper.newAdjacentFile(editor, view) + }, + }) + + // ============================================================ // Line Operations - this.addConditionalCommand({ id: 'insert-line-above', name: 'Insert line above', icon: 'plus', editorCallback: (e) => this.insertLine(e, 'above') }); - this.addConditionalCommand({ id: 'insert-line-below', name: 'Insert line below', icon: 'plus', editorCallback: (e) => this.insertLine(e, 'below') }); - this.addConditionalCommand({ id: 'delete-line', name: 'Delete line', icon: 'trash', editorCallback: (e) => this.deleteLine(e) }); - this.addConditionalCommand({ id: 'delete-to-start-of-line', name: 'Delete to start of line', icon: 'delete', editorCallback: (e) => this.deleteToLineBoundary(e, 'start') }); - this.addConditionalCommand({ id: 'delete-to-end-of-line', name: 'Delete to end of line', icon: 'delete', editorCallback: (e) => this.deleteToLineBoundary(e, 'end') }); - this.addConditionalCommand({ id: 'join-lines', name: 'Join lines', icon: 'merge', editorCallback: (e) => this.joinLines(e) }); - this.addConditionalCommand({ id: 'duplicate-line', name: 'Duplicate line', icon: 'copy', editorCallback: (e) => this.copyLine(e, 'down') }); - this.addConditionalCommand({ id: 'copy-line-up', name: 'Copy line up', icon: 'copy', editorCallback: (e) => this.copyLine(e, 'up') }); - this.addConditionalCommand({ id: 'copy-line-down', name: 'Copy line down', icon: 'copy', editorCallback: (e) => this.copyLine(e, 'down') }); - this.addConditionalCommand({ id: 'select-word', name: 'Select word', icon: 'text-cursor', editorCallback: (e) => this.selectWord(e) }); - this.addConditionalCommand({ id: 'select-line', name: 'Select line', icon: 'text-select', editorCallback: (e) => this.selectLine(e) }); + // ============================================================ + this.addConditionalCommand({ + id: 'insert-line-above', + name: 'Insert Line Above', + icon: 'plus', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.insertLine(editor, view, 'above') + }, + }) + this.addConditionalCommand({ + id: 'insert-line-below', + name: 'Insert Line Below', + icon: 'plus', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.insertLine(editor, view, 'below') + }, + }) + this.addConditionalCommand({ + id: 'delete-line', + name: 'Delete Line', + icon: 'trash', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.deleteLine(editor) + }, + }) + this.addConditionalCommand({ + id: 'delete-to-start-of-line', + name: 'Delete to Start of Line', + icon: 'delete', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.deleteToLineBoundary(editor, 'start') + }, + }) + this.addConditionalCommand({ + id: 'delete-to-end-of-line', + name: 'Delete to End of Line', + icon: 'delete', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.deleteToLineBoundary(editor, 'end') + }, + }) + this.addConditionalCommand({ + id: 'join-lines', + name: 'Join Lines', + icon: 'merge', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.joinLines(editor) + }, + }) + this.addConditionalCommand({ + id: 'duplicate-line', + name: 'Duplicate Line', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.copyLine(editor, 'down') + }, + }) + this.addConditionalCommand({ + id: 'copy-line-up', + name: 'Copy Line Up', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.copyLine(editor, 'up') + }, + }) + this.addConditionalCommand({ + id: 'copy-line-down', + name: 'Copy Line Down', + icon: 'copy', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.copyLine(editor, 'down') + }, + }) + // ============================================================ // Case Transformation - this.addConditionalCommand({ id: 'transform-uppercase', name: 'Transform to uppercase', icon: 'arrow-up', editorCallback: (e) => this.transformCase(e, CaseType.Upper) }); - this.addConditionalCommand({ id: 'transform-lowercase', name: 'Transform to lowercase', icon: 'arrow-down', editorCallback: (e) => this.transformCase(e, CaseType.Lower) }); - this.addConditionalCommand({ id: 'transform-titlecase', name: 'Transform to title case', icon: 'heading', editorCallback: (e) => this.transformCase(e, CaseType.Title) }); - this.addConditionalCommand({ id: 'toggle-case', name: 'Toggle case', icon: 'case-sensitive', editorCallback: (e) => this.transformCase(e, CaseType.Next) }); + // ============================================================ + this.addConditionalCommand({ + id: 'transform-uppercase', + name: 'Transform to Uppercase', + icon: 'arrow-up', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.transformCase(editor, CaseType.Upper) + }, + }) + this.addConditionalCommand({ + id: 'transform-lowercase', + name: 'Transform to Lowercase', + icon: 'arrow-down', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.transformCase(editor, CaseType.Lower) + }, + }) + this.addConditionalCommand({ + id: 'transform-titlecase', + name: 'Transform to Title Case', + icon: 'heading', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.transformCase(editor, CaseType.Title) + }, + }) + this.addConditionalCommand({ + id: 'toggle-case', + name: 'Toggle Case', + icon: 'case-sensitive', + editorCallback: (editor: Editor, view: MarkdownView) => { + this.transformCase(editor, CaseType.Next) + }, + }) - // Navigation - this.addConditionalCommand({ id: 'go-to-line-start', name: 'Go to line start', icon: 'arrow-left', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: 0 }) }); - this.addConditionalCommand({ id: 'go-to-line-end', name: 'Go to line end', icon: 'arrow-right', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: e.getLine(e.getCursor().line).length }) }); - this.addConditionalCommand({ id: 'go-to-first-line', name: 'Go to first line', icon: 'arrow-up-from-line', editorCallback: (e) => e.setCursor({ line: 0, ch: 0 }) }); - this.addConditionalCommand({ id: 'go-to-last-line', name: 'Go to last line', icon: 'arrow-down-to-line', editorCallback: (e) => e.setCursor({ line: e.lineCount() - 1, ch: 0 }) }); - this.addConditionalCommand({ id: 'go-to-line-number', name: 'Go to line number', icon: 'text', editorCallback: (e) => new GoToLineModal(this.app, e.lineCount(), (l) => e.setCursor({ line: l, ch: 0 })).open() }); - this.addConditionalCommand({ id: 'go-to-next-heading', name: 'Go to next heading', icon: 'arrow-down', editorCallback: (e) => this.goToHeading(e, 'next') }); - this.addConditionalCommand({ id: 'go-to-prev-heading', name: 'Go to previous heading', icon: 'arrow-up', editorCallback: (e) => this.goToHeading(e, 'prev') }); + // ============================================================ + // Navigation Commands + // ============================================================ + this.addConditionalCommand({ + id: 'go-to-line-start', + name: 'Go to Line Start', + icon: 'arrow-left', + editorCallback: (editor: Editor) => { + editor.setCursor({ line: editor.getCursor().line, ch: 0 }) + }, + }) + this.addConditionalCommand({ + id: 'go-to-line-end', + name: 'Go to Line End', + icon: 'arrow-right', + editorCallback: (editor: Editor) => { + editor.setCursor({ + line: editor.getCursor().line, + ch: editor.getLine(editor.getCursor().line).length, + }) + }, + }) + this.addConditionalCommand({ + id: 'go-to-first-line', + name: 'Go to First Line', + icon: 'arrow-up-from-line', + editorCallback: (editor: Editor) => { + editor.setCursor({ line: 0, ch: 0 }) + }, + }) + this.addConditionalCommand({ + id: 'go-to-last-line', + name: 'Go to Last Line', + icon: 'arrow-down-to-line', + editorCallback: (editor: Editor) => { + editor.setCursor({ line: editor.lineCount() - 1, ch: 0 }) + }, + }) + this.addConditionalCommand({ + id: 'go-to-line-number', + name: 'Go to Line Number', + icon: 'text', + editorCallback: (editor: Editor) => { + this.showGoToLineModal(editor) + }, + }) + this.addConditionalCommand({ + id: 'go-to-next-heading', + name: 'Go to Next Heading', + icon: 'arrow-down', + editorCallback: (editor: Editor) => { + this.goToHeading(editor, 'next') + }, + }) + this.addConditionalCommand({ + id: 'go-to-prev-heading', + name: 'Go to Previous Heading', + icon: 'arrow-up', + editorCallback: (editor: Editor) => { + this.goToHeading(editor, 'prev') + }, + }) - // Cursor Movement - this.addConditionalCommand({ id: 'move-cursor-up', name: 'Move cursor up', icon: 'arrow-up', editorCallback: (e) => e.exec('goUp') }); - this.addConditionalCommand({ id: 'move-cursor-down', name: 'Move cursor down', icon: 'arrow-down', editorCallback: (e) => e.exec('goDown') }); - this.addConditionalCommand({ id: 'move-cursor-left', name: 'Move cursor left', icon: 'arrow-left', editorCallback: (e) => e.exec('goLeft') }); - this.addConditionalCommand({ id: 'move-cursor-right', name: 'Move cursor right', icon: 'arrow-right', editorCallback: (e) => e.exec('goRight') }); - this.addConditionalCommand({ id: 'go-to-prev-word', name: 'Go to previous word', icon: 'arrow-left', editorCallback: (e) => e.exec('goWordLeft') }); - this.addConditionalCommand({ id: 'go-to-next-word', name: 'Go to next word', icon: 'arrow-right', editorCallback: (e) => e.exec('goWordRight') }); - this.addConditionalCommand({ id: 'insert-cursor-above', name: 'Insert cursor above', icon: 'plus-circle', editorCallback: (e) => this.insertCursor(e, -1) }); - this.addConditionalCommand({ id: 'insert-cursor-below', name: 'Insert cursor below', icon: 'plus-circle', editorCallback: (e) => this.insertCursor(e, 1) }); - this.addConditionalCommand({ id: 'select-next-occurrence', name: 'Select next occurrence', icon: 'text-cursor', editorCallback: (e) => this.selectOccurrence(e, 'next') }); - this.addConditionalCommand({ id: 'select-prev-occurrence', name: 'Select previous occurrence', icon: 'text-cursor', editorCallback: (e) => this.selectOccurrence(e, 'prev') }); + // ============================================================ + // Cursor Movement Commands + // ============================================================ + this.addConditionalCommand({ + id: 'move-cursor-up', + name: 'Move Cursor Up', + icon: 'arrow-up', + editorCallback: (editor: Editor) => { + editor.exec('goUp') + }, + }) + this.addConditionalCommand({ + id: 'move-cursor-down', + name: 'Move Cursor Down', + icon: 'arrow-down', + editorCallback: (editor: Editor) => { + editor.exec('goDown') + }, + }) + this.addConditionalCommand({ + id: 'move-cursor-left', + name: 'Move Cursor Left', + icon: 'arrow-left', + editorCallback: (editor: Editor) => { + editor.exec('goLeft') + }, + }) + this.addConditionalCommand({ + id: 'move-cursor-right', + name: 'Move Cursor Right', + icon: 'arrow-right', + editorCallback: (editor: Editor) => { + editor.exec('goRight') + }, + }) + this.addConditionalCommand({ + id: 'go-to-prev-word', + name: 'Go to Previous Word', + icon: 'arrow-left', + editorCallback: (editor: Editor) => { + editor.exec('goWordLeft') + }, + }) + this.addConditionalCommand({ + id: 'go-to-next-word', + name: 'Go to Next Word', + icon: 'arrow-right', + editorCallback: (editor: Editor) => { + editor.exec('goWordRight') + }, + }) + this.addConditionalCommand({ + id: 'insert-cursor-above', + name: 'Insert Cursor Above', + icon: 'plus-circle', + editorCallback: (editor: Editor) => { + this.insertCursor(editor, -1) + }, + }) + this.addConditionalCommand({ + id: 'insert-cursor-below', + name: 'Insert Cursor Below', + icon: 'plus-circle', + editorCallback: (editor: Editor) => { + this.insertCursor(editor, 1) + }, + }) + this.addConditionalCommand({ + id: 'select-next-occurrence', + name: 'Select Next Occurrence', + icon: 'text-cursor', + editorCallback: (editor: Editor) => { + this.selectOccurrence(editor, 'next') + }, + }) + this.addConditionalCommand({ + id: 'select-prev-occurrence', + name: 'Select Previous Occurrence', + icon: 'text-cursor', + editorCallback: (editor: Editor) => { + this.selectOccurrence(editor, 'prev') + }, + }) - // File Operations - this.addConditionalCommand({ id: 'duplicate-file', name: 'Duplicate file', icon: 'copy', editorCallback: (e, v) => { if (v instanceof MarkdownView) void this.duplicateFile(v); } }); - this.addConditionalCommand({ id: 'new-adjacent-file', name: 'New adjacent file', icon: 'file-plus', editorCallback: (e, v) => { if (v instanceof MarkdownView) void this.newAdjacentFile(v); } }); - - // Utility - this.addConditionalCommand({ - id: 'toggle-line-numbers', - name: 'Toggle line numbers', - icon: 'list', - callback: () => { - const vaultWithConfig = this.app.vault as unknown as { getConfig: (k: string) => unknown; setConfig: (k: string, v: unknown) => void }; - const v = vaultWithConfig.getConfig('showLineNumber'); - vaultWithConfig.setConfig('showLineNumber', !v); - } - }); - this.addConditionalCommand({ id: 'undo', name: 'Undo', icon: 'undo', editorCallback: (e) => e.undo() }); - this.addConditionalCommand({ id: 'redo', name: 'Redo', icon: 'redo', editorCallback: (e) => e.redo() }); + // ============================================================ + // Utility Commands + // ============================================================ + this.addConditionalCommand({ + id: 'toggle-line-numbers', + name: 'Toggle Line Numbers', + icon: 'list', + callback: () => { + const vaultWithConfig = this.app.vault as unknown as { + getConfig: (k: string) => unknown + setConfig: (k: string, v: unknown) => void + } + const currentValue = vaultWithConfig.getConfig('showLineNumber') + vaultWithConfig.setConfig('showLineNumber', !currentValue) + }, + }) + this.addConditionalCommand({ + id: 'undo', + name: 'Undo', + icon: 'undo', + editorCallback: (editor: Editor) => { + editor.undo() + }, + }) + this.addConditionalCommand({ + id: 'redo', + name: 'Redo', + icon: 'redo', + editorCallback: (editor: Editor) => { + editor.redo() + }, + }) } - private insertLine(editor: Editor, where: 'above' | 'below'): void { - const cursor = editor.getCursor(); - const indent = getLeadingWhitespace(editor.getLine(cursor.line)); - if (where === 'above') { editor.replaceRange(indent + '\n', { line: cursor.line, ch: 0 }); editor.setCursor({ line: cursor.line, ch: indent.length }); } - else { editor.replaceRange('\n' + indent, { line: cursor.line, ch: editor.getLine(cursor.line).length }); editor.setCursor({ line: cursor.line + 1, ch: indent.length }); } + // ============================================================ + // Private Helper Methods + // ============================================================ + + private insertLine( + editor: Editor, + view: MarkdownView, + where: 'above' | 'below' + ): void { + const cursor = editor.getCursor() + const indent = getLeadingWhitespace(editor.getLine(cursor.line)) + if (where === 'above') { + editor.replaceRange(indent + '\n', { line: cursor.line, ch: 0 }) + editor.setCursor({ line: cursor.line, ch: indent.length }) + } else { + editor.replaceRange( + '\n' + indent, + { line: cursor.line, ch: editor.getLine(cursor.line).length } + ) + editor.setCursor({ line: cursor.line + 1, ch: indent.length }) + } } private deleteLine(editor: Editor): void { - const cursor = editor.getCursor(); - editor.replaceRange('', { line: cursor.line, ch: 0 }, cursor.line === editor.lineCount() - 1 ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : { line: cursor.line + 1, ch: 0 }); + const cursor = editor.getCursor() + const isLastLine = cursor.line === editor.lineCount() - 1 + editor.replaceRange( + '', + { line: cursor.line, ch: 0 }, + isLastLine + ? { line: cursor.line, ch: editor.getLine(cursor.line).length } + : { line: cursor.line + 1, ch: 0 } + ) } private deleteToLineBoundary(editor: Editor, boundary: 'start' | 'end'): void { - const cursor = editor.getCursor(); - editor.replaceRange('', boundary === 'start' ? { line: cursor.line, ch: 0 } : cursor, boundary === 'start' ? cursor : { line: cursor.line, ch: editor.getLine(cursor.line).length }); + const cursor = editor.getCursor() + editor.replaceRange( + '', + boundary === 'start' ? { line: cursor.line, ch: 0 } : cursor, + boundary === 'start' + ? cursor + : { line: cursor.line, ch: editor.getLine(cursor.line).length } + ) } private joinLines(editor: Editor): void { - const cursor = editor.getCursor(); - if (cursor.line >= editor.lineCount() - 1) return; - const curr = editor.getLine(cursor.line); - const next = editor.getLine(cursor.line + 1).replace(/^\s*[-+*>\d.]+\s*/, ''); - editor.replaceRange((curr.endsWith(' ') || next.startsWith(' ') ? '' : ' ') + next, { line: cursor.line, ch: curr.length }, { line: cursor.line + 1, ch: editor.getLine(cursor.line + 1).length }); + const cursor = editor.getCursor() + if (cursor.line >= editor.lineCount() - 1) return + const curr = editor.getLine(cursor.line) + const next = editor.getLine(cursor.line + 1).replace(/^\s*[-+*>\d.]+\s*/, '') + editor.replaceRange( + (curr.endsWith(' ') || next.startsWith(' ') ? '' : ' ') + next, + { line: cursor.line, ch: curr.length }, + { line: cursor.line + 1, ch: editor.getLine(cursor.line + 1).length } + ) } private copyLine(editor: Editor, direction: 'up' | 'down'): void { - const cursor = editor.getCursor(); - const line = editor.getLine(cursor.line); - if (direction === 'up') editor.replaceRange(line + '\n', { line: cursor.line, ch: 0 }); - else { editor.replaceRange('\n' + line, { line: cursor.line, ch: line.length }); editor.setCursor({ line: cursor.line + 1, ch: cursor.ch }); } - } - - private selectWord(editor: Editor): void { - const cursor = editor.getCursor(); - const range = wordRangeAtPos(cursor, editor.getLine(cursor.line)); - editor.setSelection(range.anchor, range.head); - } - - private selectLine(editor: Editor): void { - const cursor = editor.getCursor(); - editor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: editor.getLine(cursor.line).length }); + const cursor = editor.getCursor() + const line = editor.getLine(cursor.line) + if (direction === 'up') { + editor.replaceRange(line + '\n', { line: cursor.line, ch: 0 }) + } else { + editor.replaceRange('\n' + line, { + line: cursor.line, + ch: line.length, + }) + editor.setCursor({ line: cursor.line + 1, ch: cursor.ch }) + } } private transformCase(editor: Editor, caseType: CaseType): void { - const sel = editor.getSelection(); - const cursor = editor.getCursor(); - const text = sel || editor.getRange(...[wordRangeAtPos(cursor, editor.getLine(cursor.line)).anchor, wordRangeAtPos(cursor, editor.getLine(cursor.line)).head] as [EditorPosition, EditorPosition]); - const result = caseType === CaseType.Upper ? text.toUpperCase() : caseType === CaseType.Lower ? text.toLowerCase() : caseType === CaseType.Title ? toTitleCase(text) : text === text.toUpperCase() ? text.toLowerCase() : text === text.toLowerCase() ? toTitleCase(text) : text.toUpperCase(); - if (sel) editor.replaceSelection(result); - else { const r = wordRangeAtPos(cursor, editor.getLine(cursor.line)); editor.replaceRange(result, r.anchor, r.head); } + const sel = editor.getSelection() + const cursor = editor.getCursor() + const lineContent = editor.getLine(cursor.line) + const wordRange = wordRangeAtPos(cursor, lineContent) + + const text = sel || editor.getRange(wordRange.anchor, wordRange.head) + let result: string + + switch (caseType) { + case CaseType.Upper: + result = text.toUpperCase() + break + case CaseType.Lower: + result = text.toLowerCase() + break + case CaseType.Title: + result = toTitleCase(text) + break + case CaseType.Next: + result = + text === text.toUpperCase() + ? text.toLowerCase() + : text === text.toLowerCase() + ? toTitleCase(text) + : text.toUpperCase() + break + } + + if (sel) { + editor.replaceSelection(result) + } else { + editor.replaceRange(result, wordRange.anchor, wordRange.head) + } } private insertCursor(editor: Editor, offset: number): void { - const newSels: EditorSelection[] = []; + const newSels: Array<{ anchor: { line: number; ch: number }; head: { line: number; ch: number } }> = [] for (const sel of editor.listSelections()) { - const newLine = sel.head.line + offset; - if (newLine >= 0 && newLine < editor.lineCount()) newSels.push({ anchor: { line: sel.anchor.line + offset, ch: Math.min(sel.anchor.ch, editor.getLine(newLine).length) }, head: { line: newLine, ch: Math.min(sel.head.ch, editor.getLine(newLine).length) } }); + const newLine = sel.head.line + offset + if (newLine >= 0 && newLine < editor.lineCount()) { + newSels.push({ + anchor: { + line: sel.anchor.line + offset, + ch: Math.min(sel.anchor.ch, editor.getLine(newLine).length), + }, + head: { + line: newLine, + ch: Math.min(sel.head.ch, editor.getLine(newLine).length), + }, + }) + } } - editor.setSelections([...editor.listSelections(), ...newSels]); + editor.setSelections([...editor.listSelections(), ...newSels]) } private selectOccurrence(editor: Editor, direction: 'next' | 'prev'): void { - const selections = editor.listSelections(); - if (selections.length === 0) return; + const selections = editor.listSelections() + if (selections.length === 0) return - // Get the selected text from the last selection (or first for prev) - const lastSelection = direction === 'next' ? selections[selections.length - 1] : selections[0]; - const range = selectionToRange(lastSelection); - const selectedText = editor.getRange(range.from, range.to); + const lastSelection = direction === 'next' ? selections[selections.length - 1] : selections[0] + const range = selectionToRange(lastSelection) + const selectedText = editor.getRange(range.from, range.to) - // If no text is selected, select the word under cursor first if (!selectedText) { - this.selectWord(editor); - return; + this.selectionHelper.selectWord(editor, null as unknown as MarkdownView) + return } - // Get the entire document content - const fullText = editor.getValue(); - - // Find all occurrences of the selected text - const occurrences: { from: number; to: number }[] = []; - let searchPos = 0; + const fullText = editor.getValue() + const occurrences: { from: number; to: number }[] = [] + let searchPos = 0 while (true) { - const idx = fullText.indexOf(selectedText, searchPos); - if (idx === -1) break; - occurrences.push({ from: idx, to: idx + selectedText.length }); - searchPos = idx + 1; + const idx = fullText.indexOf(selectedText, searchPos) + if (idx === -1) break + occurrences.push({ from: idx, to: idx + selectedText.length }) + searchPos = idx + 1 } - if (occurrences.length <= 1) return; // No other occurrences + if (occurrences.length <= 1) return + + const currentOffset = editor.posToOffset(range.from) + let targetOccurrence: { from: number; to: number } | null = null - // Convert current selection to offset - const currentOffset = editor.posToOffset(range.from); - - // Find the next/previous occurrence - let targetOccurrence: { from: number; to: number } | null = null; - if (direction === 'next') { - // Find first occurrence after current position for (const occ of occurrences) { if (occ.from > currentOffset) { - targetOccurrence = occ; - break; + targetOccurrence = occ + break } } - // Wrap around to first occurrence if not found if (!targetOccurrence) { - targetOccurrence = occurrences[0]; + targetOccurrence = occurrences[0] } } else { - // Find first occurrence before current position for (let i = occurrences.length - 1; i >= 0; i--) { if (occurrences[i].from < currentOffset) { - targetOccurrence = occurrences[i]; - break; + targetOccurrence = occurrences[i] + break } } - // Wrap around to last occurrence if not found if (!targetOccurrence) { - targetOccurrence = occurrences[occurrences.length - 1]; + targetOccurrence = occurrences[occurrences.length - 1] } } if (targetOccurrence) { - const from = editor.offsetToPos(targetOccurrence.from); - const to = editor.offsetToPos(targetOccurrence.to); - - // Add new selection to existing selections - const newSelections = [...selections, { anchor: from, head: to }]; - editor.setSelections(newSelections); + const from = editor.offsetToPos(targetOccurrence.from) + const to = editor.offsetToPos(targetOccurrence.to) + const newSelections = [...selections, { anchor: from, head: to }] + editor.setSelections(newSelections) } } private goToHeading(editor: Editor, direction: 'next' | 'prev'): void { - const file = this.app.workspace.getActiveFile(); - const cache = file ? this.app.metadataCache.getFileCache(file) : null; - if (!cache?.headings?.length) return; - const cursorLine = editor.getCursor().line; - let target = direction === 'next' ? editor.lineCount() - 1 : 0; + const file = this.app.workspace.getActiveFile() + const cache = file ? this.app.metadataCache.getFileCache(file) : null + if (!cache?.headings?.length) return + + const cursorLine = editor.getCursor().line + let target = direction === 'next' ? editor.lineCount() - 1 : 0 + for (const h of cache.headings) { - const l = h.position.end.line; - if (direction === 'next' && l > cursorLine && l < target) target = l; - if (direction === 'prev' && l < cursorLine && l > target) target = l; + const line = h.position.end.line + if (direction === 'next' && line > cursorLine && line < target) target = line + if (direction === 'prev' && line < cursorLine && line > target) target = line } - editor.setCursor({ line: target, ch: 0 }); - editor.scrollIntoView({ from: { line: target, ch: 0 }, to: { line: target, ch: 0 } }); + + editor.setCursor({ line: target, ch: 0 }) + editor.scrollIntoView({ from: { line: target, ch: 0 }, to: { line: target, ch: 0 } }) } - private async duplicateFile(view: MarkdownView): Promise { - const file = this.app.workspace.getActiveFile(); - if (!file) return; - try { const newFile = await this.app.vault.copy(file, (file.parent?.path ?? '') + '/' + file.basename + ' (copy).' + file.extension); await view.leaf.openFile(newFile); } - catch (e) { new Notice(String(e)); } + private showGoToLineModal(editor: Editor): void { + const lineCount = editor.lineCount() + + // Create modal elements + const overlay = document.createElement('div') + overlay.className = 'modal-bg' + overlay.setCssStyles({ + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + background: 'rgba(0,0,0,0.5)', + zIndex: '1000', + }) + + const modalEl = document.createElement('div') + modalEl.className = 'modal-container bindthem-modal' + modalEl.setCssStyles({ + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + background: 'var(--background-primary)', + padding: '20px', + borderRadius: '8px', + zIndex: '1001', + maxWidth: '300px', + }) + + const title = document.createElement('h2') + title.textContent = 'Go to line' + title.setCssStyles({ marginTop: '0' }) + modalEl.appendChild(title) + + const inputEl = document.createElement('input') + inputEl.type = 'number' + inputEl.min = '1' + inputEl.max = String(lineCount) + inputEl.setCssStyles({ width: '100%', marginBottom: '10px', padding: '8px' }) + modalEl.appendChild(inputEl) + + const btn = document.createElement('button') + btn.textContent = 'Go' + btn.setCssStyles({ padding: '8px 16px' }) + btn.onclick = () => { + const n = parseInt(inputEl.value) - 1 + if (n >= 0 && n < lineCount) { + editor.setCursor({ line: n, ch: 0 }) + overlay.remove() + } + } + modalEl.appendChild(btn) + + inputEl.onkeydown = (e) => { + if (e.key === 'Enter') btn.click() + if (e.key === 'Escape') overlay.remove() + } + + overlay.appendChild(modalEl) + document.body.appendChild(overlay) + inputEl.focus() } - private async newAdjacentFile(view: MarkdownView): Promise { - const file = this.app.workspace.getActiveFile(); - if (!file) return; - try { const newFile = await this.app.vault.create((file.parent?.path ?? '') + '/Untitled.md', ''); await view.leaf.openFile(newFile); } - catch (e) { new Notice(String(e)); } - } + // ============================================================ + // Settings + // ============================================================ async loadSettings(): Promise { - const savedData = await this.loadData() as Partial | null; - - // Start with default settings - const defaultCommands = createDefaultEnabledCommands(); - - // Merge with saved data + const savedData = (await this.loadData()) as Partial | null + + const defaultCommands = createDefaultEnabledCommands() + this.settings = { ...DEFAULT_SETTINGS, ...savedData, - // Merge enabled commands specifically to handle new commands added in updates enabledCommands: { ...defaultCommands, - ...savedData?.enabledCommands - } - }; - - if (this.sentenceNavigator) this.sentenceNavigator.updateRegex(this.settings.sentenceRegexSource); + ...savedData?.enabledCommands, + }, + } } async saveSettings(): Promise { - await this.saveData(this.settings); - if (this.sentenceNavigator) this.sentenceNavigator.updateRegex(this.settings.sentenceRegexSource); + await this.saveData(this.settings) + this.sentenceNavigator.updateRegex(this.settings.sentenceRegexSource) } } \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts index 6617a51..597eda6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,6 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; import BindThemPlugin from './main'; +import { DEFAULT_SENTENCE_REGEX } from './Constants'; // ============================================================ // Command Definitions - All commands organized by category @@ -149,8 +150,6 @@ export interface BindThemSettings { enabledCommands: Record; } -export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'; - export const DEFAULT_SETTINGS: BindThemSettings = { debug: false, sentenceRegexSource: DEFAULT_SENTENCE_REGEX,