From 28ebf6a4a86b2df44c2df9d27a8651b72c82712e Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 24 Feb 2026 22:00:12 -0500 Subject: [PATCH] pad readme, make hotkeys optional --- README.md | 15 + src/main.ts | 871 +++++++++++++++++++++++++++--------------------- src/settings.ts | 283 +++++++++++++++- 3 files changed, 788 insertions(+), 381 deletions(-) diff --git a/README.md b/README.md index d1c630b..4fe5cb5 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,20 @@ Quick file management commands. | Duplicate File | Create a copy of the current file in the same folder | | New Adjacent File | Create a new file in the same folder as the current file | +### Sentence Navigation + +Navigate and manipulate text at the sentence level. Perfect for prose editing and working with paragraph-style content. + +| Command | Description | +|---------|-------------| +| Delete to start of sentence | Remove all text from cursor to the beginning of the current sentence | +| Delete to end of sentence | Remove all text from cursor to the end of the current sentence | +| Select to start of sentence | Select text from cursor to the beginning of the current sentence | +| Select to end of sentence | Select text from cursor to the end of the current sentence | +| Move to start of current sentence | Jump the cursor to the beginning of the current sentence | +| Move to start of next sentence | Jump the cursor to the beginning of the next sentence | +| Select current sentence | Select the entire sentence where the cursor is located | + ### Utility Commands | Command | Description | @@ -171,6 +185,7 @@ This plugin merges and refactors functionality from: - **obsidian-tweaks** - Better formatting, directional copy/move, file operations - **obsidian-editor-shortcuts** - Line operations, case transformation, navigation, multi-cursor support - **heading-toggler** - Heading toggle with formatting strip +- **obsidian-sentence-navigator** - Sentence-level navigation and manipulation ## License diff --git a/src/main.ts b/src/main.ts index 7a0fbb9..08cb4e3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,13 @@ -import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, Modal, MarkdownFileInfo } from 'obsidian'; - -// ============================================================ -// Settings Interface -// ============================================================ - -interface BindThemSettings { - debug: boolean; -} - -const DEFAULT_SETTINGS: BindThemSettings = { - debug: false -}; +import { App, Editor, MarkdownView, MarkdownFileInfo, Notice, Plugin } from 'obsidian'; +import { + BindThemSettings, + BindThemSettingTab, + DEFAULT_SETTINGS, + DEFAULT_SENTENCE_REGEX, + COMMANDS, + COMMAND_CATEGORIES, + createDefaultEnabledCommands +} from './settings'; // ============================================================ // Utility Types and Functions @@ -32,10 +29,7 @@ interface EditorRange { } function getMainSelection(editor: Editor): EditorSelection { - return { - anchor: editor.getCursor('anchor'), - head: editor.getCursor('head') - }; + return { anchor: editor.getCursor('anchor'), head: editor.getCursor('head') }; } function selectionToRange(selection: EditorSelection): EditorRange { @@ -48,27 +42,7 @@ function selectionToRange(selection: EditorSelection): EditorRange { function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection { const range = selectionToRange(selection); - const toLength = editor.getLine(range.to.line).length; - return { - anchor: { line: range.from.line, ch: 0 }, - head: { line: range.to.line, ch: toLength } - }; -} - -function getSelectionBoundaries(selection: EditorSelection): EditorRange & { hasTrailingNewline: boolean } { - let { anchor: from, head: to } = selection; - if (from.line > to.line || (from.line === to.line && from.ch > to.ch)) { - [from, to] = [to, from]; - } - return { from, to, hasTrailingNewline: to.line > from.line && to.ch === 0 }; -} - -function getLineStartPos(line: number): EditorPosition { - return { line, ch: 0 }; -} - -function getLineEndPos(line: number, editor: Editor): EditorPosition { - return { line, ch: editor.getLine(line).length }; + 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 { @@ -81,78 +55,211 @@ function isLetterOrDigit(char: string): boolean { } function wordRangeAtPos(pos: EditorPosition, lineContent: string): EditorSelection { - let start = pos.ch; - let end = pos.ch; + 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_WIDOW_EXPAND_CHARS = '#$@'; - static POST_WIDOW_EXPAND_CHARS = '!?::'; - - private expandRangeByWords(editor: Editor, range: EditorRange, symbolStart: string, symbolEnd: string): EditorRange { - const wordAtFrom = editor.wordAt(range.from); - const wordAtTo = editor.wordAt(range.to); - let from = wordAtFrom ? wordAtFrom.from : range.from; - let to = wordAtTo ? wordAtTo.to : range.to; - - // Expand left - while (from.ch > 0) { - const newFrom = { line: from.line, ch: from.ch - 1 }; - const preChar = editor.getRange(newFrom, from); - if (symbolStart.includes(preChar) || !BetterFormatting.PRE_WIDOW_EXPAND_CHARS.includes(preChar)) break; - from = newFrom; - } - - // Expand right - while (to.ch < editor.getLine(to.line).length) { - const newTo = { line: to.line, ch: to.ch + 1 }; - const postChar = editor.getRange(to, newTo); - if (symbolEnd.includes(postChar) || !BetterFormatting.POST_WIDOW_EXPAND_CHARS.includes(postChar)) break; - to = newTo; - } - - // Check for existing wrapping symbols - if (from.ch >= symbolStart.length) { - const newFrom = { line: from.line, ch: from.ch - symbolStart.length }; - if (editor.getRange(newFrom, from) === symbolStart) from = newFrom; - } - const newTo = { line: to.line, ch: to.ch + symbolEnd.length }; - if (editor.getRange(to, newTo) === symbolEnd) to = newTo; - - return { from, to }; - } + static PRE_CHARS = '#$@'; + static POST_CHARS = '!?::'; toggleWrapper(editor: Editor, symbolStart: string, symbolEnd?: string): void { - if (symbolEnd === undefined) symbolEnd = symbolStart; + 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; - const selections = editor.listSelections(); - const mainSelection = getMainSelection(editor); - const mainRange = this.expandRangeByWords(editor, selectionToRange(mainSelection), symbolStart, symbolEnd); - const text = editor.getRange(mainRange.from, mainRange.to); - const isWrapped = text.startsWith(symbolStart) && text.endsWith(symbolEnd); - - const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = []; - - if (isWrapped) { - changes.push( - { from: mainRange.from, to: { line: mainRange.from.line, ch: mainRange.from.ch + symbolStart.length }, text: '' }, - { from: { line: mainRange.to.line, ch: mainRange.to.ch - symbolEnd.length }, to: mainRange.to, text: '' } - ); - } else { - changes.push( - { from: mainRange.from, text: symbolStart }, - { from: mainRange.to, text: symbolEnd } - ); + 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; } - editor.transaction({ changes }); + 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 }); + } } } @@ -163,144 +270,160 @@ class BetterFormatting { enum Direction { Up = 'Up', Down = 'Down', Left = 'Left', Right = 'Right' } class DirectionalCopy { - directionalCopy(editor: Editor, direction: Direction): void { - const selections = editor.listSelections(); + copy(editor: Editor, direction: Direction): void { + const sels = editor.listSelections(); const vertical = direction === Direction.Up || direction === Direction.Down; - const processedSelections = vertical ? selections.map(s => selectionToLine(editor, s)) : selections; - const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = []; - - for (const selection of processedSelections) { - const range = selectionToRange(selection); + 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); - switch (direction) { - case Direction.Up: changes.push({ from: range.from, text: content + '\n' }); break; - case Direction.Down: changes.push({ from: range.to, text: '\n' + content }); break; - case Direction.Left: changes.push({ from: range.from, text: content }); break; - case Direction.Right: changes.push({ from: range.to, text: content }); break; - } + 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); } - editor.transaction({ changes }); } } class DirectionalMove { - directionalMove(editor: Editor, direction: Direction): void { - const selections = editor.listSelections(); - const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = []; - - for (const selection of selections) { - const range = selectionToRange(selection); + 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 delFrom = { line: range.from.line, ch: range.from.ch - 1 }; - const char = editor.getRange(delFrom, range.from); - changes.push({ from: delFrom, to: range.from, text: '' }); - changes.push({ from: range.to, text: char }); + 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 delTo = { line: range.to.line, ch: range.to.ch + 1 }; - const char = editor.getRange(range.to, delTo); - changes.push({ from: range.to, to: delTo, text: '' }); - changes.push({ from: range.from, text: char }); + 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); } } - editor.transaction({ changes }); } } // ============================================================ -// Toggle Heading (merged from obsidian-tweaks and heading-toggler) +// Toggle Heading (from obsidian-tweaks and heading-toggler) // ============================================================ class ToggleHeading { - private static HEADING_REGEX = /^(#*)( *)(.*)/; - - toggleHeading(editor: Editor, heading: number): void { - const selections = editor.listSelections(); + toggle(editor: Editor, heading: number): void { const mainRange = selectionToRange(getMainSelection(editor)); - const mainText = editor.getLine(mainRange.from.line); - const mainMatch = mainText.match(ToggleHeading.HEADING_REGEX); + const mainMatch = editor.getLine(mainRange.from.line).match(/^(#*)( *)/); const mainHeading = mainMatch ? mainMatch[1].length : 0; - const targetHeading = heading === mainHeading ? 0 : heading; + const target = heading === mainHeading ? 0 : heading; - const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = []; - const linesSet = new Set(); - for (const sel of selections) { + const lines = new Set(); + for (const sel of editor.listSelections()) { const range = selectionToRange(sel); - for (let line = range.from.line; line <= range.to.line; line++) linesSet.add(line); + for (let l = range.from.line; l <= range.to.line; l++) lines.add(l); } - for (const line of Array.from(linesSet).sort((a, b) => a - b)) { + for (const line of Array.from(lines).sort((a, b) => a - b)) { const text = editor.getLine(line); - if (text === '') continue; - const match = text.match(ToggleHeading.HEADING_REGEX); + if (!text) continue; + const match = text.match(/^(#*)( *)/); if (!match) continue; - const from = { line, ch: 0 }; - const to = { line, ch: match[1].length + match[2].length }; - changes.push({ from, to, text: targetHeading === 0 ? '' : '#'.repeat(targetHeading) + ' ' }); + editor.replaceRange(target === 0 ? '' : '#'.repeat(target) + ' ', { line, ch: 0 }, { line, ch: match[1].length + match[2].length }); } - editor.transaction({ changes }); } - toggleHeadingWithStrip(editor: Editor, level: number): void { + toggleWithStrip(editor: Editor, level: number): void { const cursor = editor.getCursor(); const line = editor.getLine(cursor.line); - const headingRegex = /^(#+)\s(.*)/; - const match = line.match(headingRegex); - - let content = line; - let currentLevel = 0; - if (match) { - currentLevel = match[1].length; - content = match[2]; - } - + 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'); - const newText = currentLevel === level ? content : '#'.repeat(level) + ' ' + content; - editor.setLine(cursor.line, newText); + editor.setLine(cursor.line, currentLevel === level ? content : '#'.repeat(level) + ' ' + content); } } // ============================================================ -// Editor Shortcuts (from obsidian-editor-shortcuts) +// Case Transformation // ============================================================ -enum CaseType { Upper = 'upper', Lower = 'lower', Title = 'title', Next = 'next' } +enum CaseType { Upper, Lower, Title, Next } const LOWERCASE_ARTICLES = ['the', 'a', 'an']; function toTitleCase(text: string): string { - return text.split(/(\s+)/).map((word, i, all) => { - if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(word.toLowerCase())) return word.toLowerCase(); - return word.charAt(0).toUpperCase() + word.substring(1).toLowerCase(); - }).join(''); + 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 extends 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) { - super(app); + 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'); } - onOpen(): void { - const { contentEl } = this; - contentEl.createEl('h2', { text: 'Go to line' }); - const input = contentEl.createEl('input', { attr: { type: 'number', min: '1', max: String(this.lineCount), placeholder: `Enter line number (1-${this.lineCount})` } }); - const button = contentEl.createEl('button', { text: 'Go' }); - button.onclick = () => { - const line = parseInt(input.value) - 1; - if (line >= 0 && line < this.lineCount) { this.onSubmit(line); this.close(); } + open(): void { + const overlay = document.createElement('div'); + overlay.className = 'modal-bg'; + overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;'; + + this.modalEl.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--background-primary); padding: 20px; border-radius: 8px; z-index: 1001; max-width: 300px;'; + + const title = document.createElement('h2'); + title.textContent = 'Go to line'; + title.style.cssText = 'margin-top: 0;'; + this.modalEl.appendChild(title); + + this.inputEl.type = 'number'; + this.inputEl.min = '1'; + this.inputEl.max = String(this.lineCount); + this.inputEl.style.cssText = 'width: 100%; margin-bottom: 10px; padding: 8px;'; + this.modalEl.appendChild(this.inputEl); + + const btn = document.createElement('button'); + btn.textContent = 'Go'; + btn.style.cssText = 'padding: 8px 16px;'; + btn.onclick = () => { + const n = parseInt(this.inputEl.value) - 1; + if (n >= 0 && n < this.lineCount) { + this.onSubmit(n); + this.close(); + } }; - input.onkeydown = (e) => { if (e.key === 'Enter') button.click(); }; - input.focus(); + 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(); } - onClose(): void { this.contentEl.empty(); } + private overlay: HTMLElement | null = null; + + close(): void { + if (this.overlay) { + this.overlay.remove(); + this.overlay = null; + } + } } // ============================================================ @@ -313,163 +436,186 @@ export default class BindThemPlugin extends Plugin { private directionalCopy = new DirectionalCopy(); private directionalMove = new DirectionalMove(); private toggleHeading = new ToggleHeading(); + private sentenceNavigator!: SentenceNavigator; async onload(): Promise { await this.loadSettings(); + this.sentenceNavigator = new SentenceNavigator(this.settings.sentenceRegexSource); - // Better Formatting Commands - const formattingCommands: [string, string, string | undefined][] = [ - ['bold-underscore', '__', '__'], ['bold-asterisk', '**', '**'], - ['italics-underscore', '_', '_'], ['italics-asterisk', '*', '*'], - ['code', '`', '`'], ['comment', '%%', '%%'], ['highlight', '==', '=='], - ['strikethrough', '~~', '~~'], ['math-inline', '$', '$'], ['math-block', '$$', '$$'] - ]; - for (const [id, start, end] of formattingCommands) { - this.addCommand({ - id: `toggle-${id}`, - name: `Toggle ${id.replace('-', ' ')}`, - editorCallback: (editor) => this.betterFormatting.toggleWrapper(editor, start, end) - }); - } - - // Directional Copy Commands - for (const dir of [Direction.Up, Direction.Down, Direction.Left, Direction.Right]) { - this.addCommand({ - id: `copy-${dir.toLowerCase()}`, - name: `Copy ${dir}`, - editorCallback: (editor) => this.directionalCopy.directionalCopy(editor, dir) - }); - } - - // Directional Move Commands - for (const dir of [Direction.Left, Direction.Right]) { - this.addCommand({ - id: `move-${dir.toLowerCase()}`, - name: `Move ${dir}`, - editorCallback: (editor) => this.directionalMove.directionalMove(editor, dir) - }); - } - - // Toggle Heading Commands - for (let i = 1; i <= 6; i++) { - this.addCommand({ - id: `toggle-heading-${i}`, - name: `Toggle heading ${i}`, - editorCallback: (editor) => this.toggleHeading.toggleHeading(editor, i) - }); - this.addCommand({ - id: `toggle-heading-strip-${i}`, - name: `Toggle heading ${i} (strip formatting)`, - editorCallback: (editor) => this.toggleHeading.toggleHeadingWithStrip(editor, i) - }); - } - - // Editor Shortcuts - this.addCommand({ id: 'insert-line-above', name: 'Insert line above', editorCallback: (e) => this.insertLine(e, 'above') }); - this.addCommand({ id: 'insert-line-below', name: 'Insert line below', editorCallback: (e) => this.insertLine(e, 'below') }); - this.addCommand({ id: 'delete-line', name: 'Delete line', editorCallback: (e) => this.deleteLine(e) }); - this.addCommand({ id: 'delete-to-start-of-line', name: 'Delete to start of line', editorCallback: (e) => this.deleteToBoundary(e, 'start') }); - this.addCommand({ id: 'delete-to-end-of-line', name: 'Delete to end of line', editorCallback: (e) => this.deleteToBoundary(e, 'end') }); - this.addCommand({ id: 'join-lines', name: 'Join lines', editorCallback: (e) => this.joinLines(e) }); - this.addCommand({ id: 'duplicate-line', name: 'Duplicate line', editorCallback: (e) => this.copyLine(e, 'down') }); - this.addCommand({ id: 'copy-line-up', name: 'Copy line up', editorCallback: (e) => this.copyLine(e, 'up') }); - this.addCommand({ id: 'copy-line-down', name: 'Copy line down', editorCallback: (e) => this.copyLine(e, 'down') }); - this.addCommand({ id: 'select-word', name: 'Select word', editorCallback: (e) => this.selectWord(e) }); - this.addCommand({ id: 'select-line', name: 'Select line', editorCallback: (e) => this.selectLine(e) }); - - // Case Transformation - this.addCommand({ id: 'transform-uppercase', name: 'Transform to uppercase', editorCallback: (e) => this.transformCase(e, CaseType.Upper) }); - this.addCommand({ id: 'transform-lowercase', name: 'Transform to lowercase', editorCallback: (e) => this.transformCase(e, CaseType.Lower) }); - this.addCommand({ id: 'transform-titlecase', name: 'Transform to title case', editorCallback: (e) => this.transformCase(e, CaseType.Title) }); - this.addCommand({ id: 'toggle-case', name: 'Toggle case', editorCallback: (e) => this.transformCase(e, CaseType.Next) }); - - // Navigation - this.addCommand({ id: 'go-to-line-start', name: 'Go to line start', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: 0 }) }); - this.addCommand({ id: 'go-to-line-end', name: 'Go to line end', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: e.getLine(e.getCursor().line).length }) }); - this.addCommand({ id: 'go-to-first-line', name: 'Go to first line', editorCallback: (e) => e.setCursor({ line: 0, ch: 0 }) }); - this.addCommand({ id: 'go-to-last-line', name: 'Go to last line', editorCallback: (e) => e.setCursor({ line: e.lineCount() - 1, ch: 0 }) }); - this.addCommand({ id: 'go-to-line-number', name: 'Go to line number', editorCallback: (e) => new GoToLineModal(this.app, e.lineCount(), (line) => e.setCursor({ line, ch: 0 })).open() }); - this.addCommand({ id: 'go-to-next-heading', name: 'Go to next heading', editorCallback: (e) => this.goToHeading(e, 'next') }); - this.addCommand({ id: 'go-to-prev-heading', name: 'Go to previous heading', editorCallback: (e) => this.goToHeading(e, 'prev') }); - - // Cursor Movement - this.addCommand({ id: 'move-cursor-up', name: 'Move cursor up', editorCallback: (e) => e.exec('goUp') }); - this.addCommand({ id: 'move-cursor-down', name: 'Move cursor down', editorCallback: (e) => e.exec('goDown') }); - this.addCommand({ id: 'move-cursor-left', name: 'Move cursor left', editorCallback: (e) => e.exec('goLeft') }); - this.addCommand({ id: 'move-cursor-right', name: 'Move cursor right', editorCallback: (e) => e.exec('goRight') }); - this.addCommand({ id: 'go-to-prev-word', name: 'Go to previous word', editorCallback: (e) => e.exec('goWordLeft') }); - this.addCommand({ id: 'go-to-next-word', name: 'Go to next word', editorCallback: (e) => e.exec('goWordRight') }); - this.addCommand({ id: 'insert-cursor-above', name: 'Insert cursor above', editorCallback: (e) => this.insertCursor(e, -1) }); - this.addCommand({ id: 'insert-cursor-below', name: 'Insert cursor below', editorCallback: (e) => this.insertCursor(e, 1) }); - - // File Operations - this.addCommand({ id: 'duplicate-file', name: 'Duplicate file', editorCallback: (e, v) => this.duplicateFile(e, v as MarkdownView) }); - this.addCommand({ id: 'new-adjacent-file', name: 'New adjacent file', editorCallback: (e, v) => this.newAdjacentFile(e, v as MarkdownView) }); - - // Settings - this.addCommand({ id: 'toggle-line-numbers', name: 'Toggle line numbers', callback: () => { const val = this.app.vault.getConfig('showLineNumber'); (this.app.vault as unknown as { setConfig: (k: string, v: unknown) => void }).setConfig('showLineNumber', !val); } }); - this.addCommand({ id: 'undo', name: 'Undo', editorCallback: (e) => e.undo() }); - this.addCommand({ id: 'redo', name: 'Redo', editorCallback: (e) => e.redo() }); + // Register commands conditionally based on settings + this.registerCommands(); this.addSettingTab(new BindThemSettingTab(this.app, this)); } - // Editor Helper Methods + /** + * Check if a command is enabled in settings + */ + 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; + }): void { + if (this.isCommandEnabled(options.id)) { + this.addCommand({ + id: options.id, + name: options.name, + icon: options.icon, + editorCallback: options.editorCallback, + 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, '$$', '$$') }); + + // 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) }); + + // 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) }); + + // 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) }); + + // 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) }); + + // 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) }); + + // 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') }); + + // 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) }); + + // 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() }); + } + private insertLine(editor: Editor, where: 'above' | 'below'): void { const cursor = editor.getCursor(); - const line = editor.getLine(cursor.line); - const indent = getLeadingWhitespace(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: line.length }); - editor.setCursor({ line: cursor.line + 1, ch: indent.length }); - } + 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(); - const from = { line: cursor.line, ch: 0 }; - const to = { line: cursor.line + 1, ch: 0 }; - editor.replaceRange('', from, cursor.line === editor.lineCount() - 1 ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : to); + 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 }); } - private deleteToBoundary(editor: Editor, boundary: 'start' | 'end'): void { + private deleteToLineBoundary(editor: Editor, boundary: 'start' | 'end'): void { const cursor = editor.getCursor(); - if (boundary === 'start') { - editor.replaceRange('', { line: cursor.line, ch: 0 }, cursor); - } else { - editor.replaceRange('', cursor, { line: cursor.line, ch: editor.getLine(cursor.line).length }); - } + 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 currentLine = editor.getLine(cursor.line); - const nextLine = editor.getLine(cursor.line + 1).replace(/^\s*[-+*>\d.]+\s*/, ''); - const separator = currentLine.endsWith(' ') || nextLine.startsWith(' ') ? '' : ' '; - editor.replaceRange(separator + nextLine, { line: cursor.line, ch: currentLine.length }, { line: cursor.line + 1, ch: editor.getLine(cursor.line + 1).length }); - editor.setCursor({ line: cursor.line, ch: currentLine.length + separator.length }); + 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 }); - } + 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 line = editor.getLine(cursor.line); - const wordRange = wordRangeAtPos(cursor, line); - editor.setSelection(wordRange.anchor, wordRange.head); + const range = wordRangeAtPos(cursor, editor.getLine(cursor.line)); + editor.setSelection(range.anchor, range.head); } private selectLine(editor: Editor): void { @@ -478,93 +624,74 @@ export default class BindThemPlugin extends Plugin { } private transformCase(editor: Editor, caseType: CaseType): void { - const selection = editor.getSelection(); + const sel = editor.getSelection(); const cursor = editor.getCursor(); - let text = selection || editor.getRange(...Object.values(wordRangeAtPos(cursor, editor.getLine(cursor.line))) as [EditorPosition, EditorPosition]); - - 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 (selection) editor.replaceSelection(result); - else { const range = wordRangeAtPos(cursor, editor.getLine(cursor.line)); editor.replaceRange(result, range.anchor, range.head); } + 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); } } private insertCursor(editor: Editor, offset: number): void { - const selections = editor.listSelections(); - const newSelections: EditorSelection[] = []; - for (const sel of selections) { + const newSels: EditorSelection[] = []; + for (const sel of editor.listSelections()) { const newLine = sel.head.line + offset; - if (newLine >= 0 && newLine < editor.lineCount()) { - newSelections.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) } }); - } + 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([...selections, ...newSelections]); + editor.setSelections([...editor.listSelections(), ...newSels]); } private goToHeading(editor: Editor, direction: 'next' | 'prev'): void { const file = this.app.workspace.getActiveFile(); - if (!file) return; - const cache = this.app.metadataCache.getFileCache(file); + const cache = file ? this.app.metadataCache.getFileCache(file) : null; if (!cache?.headings?.length) return; - const cursorLine = editor.getCursor().line; - let targetLine = direction === 'next' ? editor.lineCount() - 1 : 0; - + let target = direction === 'next' ? editor.lineCount() - 1 : 0; for (const h of cache.headings) { - const hLine = h.position.end.line; - if (direction === 'next' && hLine > cursorLine && hLine < targetLine) targetLine = hLine; - if (direction === 'prev' && hLine < cursorLine && hLine > targetLine) targetLine = hLine; + const l = h.position.end.line; + if (direction === 'next' && l > cursorLine && l < target) target = l; + if (direction === 'prev' && l < cursorLine && l > target) target = l; } + 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 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)); } + } + + async loadSettings(): Promise { + const savedData = await this.loadData() as Partial | null; - editor.setCursor({ line: targetLine, ch: 0 }); - editor.scrollIntoView({ from: { line: targetLine, ch: 0 }, to: { line: targetLine, ch: 0 } }); + // Start with default settings + const defaultCommands = createDefaultEnabledCommands(); + + // Merge with saved data + 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); } - private async duplicateFile(editor: Editor, view: MarkdownView): Promise { - const file = this.app.workspace.getActiveFile(); - if (!file) return; - const newPath = (file.parent?.path ?? '') + '/' + file.basename + ' (copy).' + file.extension; - try { - const newFile = await this.app.vault.copy(file, newPath); - await view.leaf.openFile(newFile); - } catch (e) { new Notice(String(e)); } - } - - private async newAdjacentFile(editor: Editor, view: MarkdownView): Promise { - const file = this.app.workspace.getActiveFile(); - if (!file) return; - const newPath = (file.parent?.path ?? '') + '/Untitled.md'; - try { - const newFile = await this.app.vault.create(newPath, ''); - await view.leaf.openFile(newFile); - } catch (e) { new Notice(String(e)); } - } - - async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } - async saveSettings(): Promise { await this.saveData(this.settings); } -} - -// ============================================================ -// Settings Tab -// ============================================================ - -class BindThemSettingTab extends PluginSettingTab { - private plugin: BindThemPlugin; - - constructor(app: App, plugin: BindThemPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - new Setting(containerEl).setName('Debug mode').setDesc('Enable debug logging').addToggle(t => t.setValue(this.plugin.settings.debug).onChange(async v => { this.plugin.settings.debug = v; await this.plugin.saveSettings(); })); - new Setting(containerEl).setName('About').setDesc('BindThem combines features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler.').setHeading(); + async saveSettings(): Promise { + await this.saveData(this.settings); + if (this.sentenceNavigator) this.sentenceNavigator.updateRegex(this.settings.sentenceRegexSource); } } \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts index 56f9412..2a8b063 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,14 +1,164 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; import BindThemPlugin from './main'; -export interface BindThemSettings { - debug: boolean; +// ============================================================ +// Command Definitions - All commands organized by category +// ============================================================ + +export interface CommandDefinition { + id: string; + name: string; + description?: string; } -export const DEFAULT_SETTINGS: BindThemSettings = { - debug: false +export const COMMAND_CATEGORIES = { + betterFormatting: 'Better Formatting', + directionalCopyMove: 'Directional Copy/Move', + toggleHeading: 'Toggle Heading', + sentenceNavigator: 'Sentence Navigator', + lineOperations: 'Line Operations', + caseTransformation: 'Case Transformation', + navigation: 'Navigation', + cursorMovement: 'Cursor Movement', + fileOperations: 'File Operations', + utility: 'Utility' +} as const; + +export const COMMANDS: Record = { + betterFormatting: [ + { id: 'toggle-bold-underscore', name: 'Toggle bold underscore' }, + { id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk' }, + { id: 'toggle-italics-underscore', name: 'Toggle italics underscore' }, + { id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk' }, + { id: 'toggle-code', name: 'Toggle code' }, + { id: 'toggle-comment', name: 'Toggle comment' }, + { id: 'toggle-highlight', name: 'Toggle highlight' }, + { id: 'toggle-strikethrough', name: 'Toggle strikethrough' }, + { id: 'toggle-math-inline', name: 'Toggle math inline' }, + { id: 'toggle-math-block', name: 'Toggle math block' } + ], + directionalCopyMove: [ + { id: 'copy-up', name: 'Copy up' }, + { id: 'copy-down', name: 'Copy down' }, + { id: 'copy-left', name: 'Copy left' }, + { id: 'copy-right', name: 'Copy right' }, + { id: 'move-left', name: 'Move left' }, + { id: 'move-right', name: 'Move right' } + ], + toggleHeading: [ + { id: 'toggle-heading-1', name: 'Toggle heading 1' }, + { id: 'toggle-heading-2', name: 'Toggle heading 2' }, + { id: 'toggle-heading-3', name: 'Toggle heading 3' }, + { id: 'toggle-heading-4', name: 'Toggle heading 4' }, + { id: 'toggle-heading-5', name: 'Toggle heading 5' }, + { id: 'toggle-heading-6', name: 'Toggle heading 6' }, + { id: 'toggle-heading-strip-1', name: 'Toggle heading 1 (strip formatting)' }, + { id: 'toggle-heading-strip-2', name: 'Toggle heading 2 (strip formatting)' }, + { id: 'toggle-heading-strip-3', name: 'Toggle heading 3 (strip formatting)' }, + { id: 'toggle-heading-strip-4', name: 'Toggle heading 4 (strip formatting)' }, + { id: 'toggle-heading-strip-5', name: 'Toggle heading 5 (strip formatting)' }, + { id: 'toggle-heading-strip-6', name: 'Toggle heading 6 (strip formatting)' } + ], + sentenceNavigator: [ + { id: 'delete-to-start-of-sentence', name: 'Delete to start of sentence' }, + { id: 'delete-to-end-of-sentence', name: 'Delete to end of sentence' }, + { id: 'select-to-start-of-sentence', name: 'Select to start of sentence' }, + { id: 'select-to-end-of-sentence', name: 'Select to end of sentence' }, + { id: 'move-to-start-of-current-sentence', name: 'Move to start of current sentence' }, + { id: 'move-to-start-of-next-sentence', name: 'Move to start of next sentence' }, + { id: 'select-sentence', name: 'Select current sentence' } + ], + lineOperations: [ + { id: 'insert-line-above', name: 'Insert line above' }, + { id: 'insert-line-below', name: 'Insert line below' }, + { id: 'delete-line', name: 'Delete line' }, + { id: 'delete-to-start-of-line', name: 'Delete to start of line' }, + { id: 'delete-to-end-of-line', name: 'Delete to end of line' }, + { id: 'join-lines', name: 'Join lines' }, + { id: 'duplicate-line', name: 'Duplicate line' }, + { id: 'copy-line-up', name: 'Copy line up' }, + { id: 'copy-line-down', name: 'Copy line down' }, + { id: 'select-word', name: 'Select word' }, + { id: 'select-line', name: 'Select line' } + ], + caseTransformation: [ + { id: 'transform-uppercase', name: 'Transform to uppercase' }, + { id: 'transform-lowercase', name: 'Transform to lowercase' }, + { id: 'transform-titlecase', name: 'Transform to title case' }, + { id: 'toggle-case', name: 'Toggle case' } + ], + navigation: [ + { id: 'go-to-line-start', name: 'Go to line start' }, + { id: 'go-to-line-end', name: 'Go to line end' }, + { id: 'go-to-first-line', name: 'Go to first line' }, + { id: 'go-to-last-line', name: 'Go to last line' }, + { id: 'go-to-line-number', name: 'Go to line number' }, + { id: 'go-to-next-heading', name: 'Go to next heading' }, + { id: 'go-to-prev-heading', name: 'Go to previous heading' } + ], + cursorMovement: [ + { id: 'move-cursor-up', name: 'Move cursor up' }, + { id: 'move-cursor-down', name: 'Move cursor down' }, + { id: 'move-cursor-left', name: 'Move cursor left' }, + { id: 'move-cursor-right', name: 'Move cursor right' }, + { id: 'go-to-prev-word', name: 'Go to previous word' }, + { id: 'go-to-next-word', name: 'Go to next word' }, + { id: 'insert-cursor-above', name: 'Insert cursor above' }, + { id: 'insert-cursor-below', name: 'Insert cursor below' } + ], + fileOperations: [ + { id: 'duplicate-file', name: 'Duplicate file' }, + { id: 'new-adjacent-file', name: 'New adjacent file' } + ], + utility: [ + { id: 'toggle-line-numbers', name: 'Toggle line numbers' }, + { id: 'undo', name: 'Undo' }, + { id: 'redo', name: 'Redo' } + ] +}; + +// Get all command IDs as an array +export function getAllCommandIds(): string[] { + const ids: string[] = []; + for (const commands of Object.values(COMMANDS)) { + for (const cmd of commands) { + ids.push(cmd.id); + } + } + return ids; } +// Create default enabled settings (all enabled by default) +export function createDefaultEnabledCommands(): Record { + const enabled: Record = {}; + for (const cmdId of getAllCommandIds()) { + enabled[cmdId] = true; + } + return enabled; +} + +// ============================================================ +// Settings Interface +// ============================================================ + +export interface BindThemSettings { + debug: boolean; + sentenceRegexSource: string; + enabledCommands: Record; +} + +export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'; + +export const DEFAULT_SETTINGS: BindThemSettings = { + debug: false, + sentenceRegexSource: DEFAULT_SENTENCE_REGEX, + enabledCommands: createDefaultEnabledCommands() +}; + +// ============================================================ +// Settings Tab +// ============================================================ + export class BindThemSettingTab extends PluginSettingTab { plugin: BindThemPlugin; @@ -21,6 +171,27 @@ export class BindThemSettingTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); + // Hotkeys link at the top + containerEl.createEl('a', { + text: 'Configure keyboard shortcuts for BindThem', + cls: 'bindthem-hotkeys-link', + attr: { + href: '#', + style: 'display: inline-block; margin-bottom: 1em; color: var(--text-accent); cursor: pointer;' + } + }, (el) => { + el.addEventListener('click', (e) => { + e.preventDefault(); + // @ts-ignore - openHotkeys is not in the type definitions + const hotkeysTab = this.app.setting.openTabById('hotkeys'); + if (hotkeysTab) { + // @ts-ignore - searchComponent is not in the type definitions + hotkeysTab.searchComponent.setValue('BindThem'); + } + }); + }); + + // General settings new Setting(containerEl) .setName('Debug mode') .setDesc('Enable debug logging to console') @@ -32,12 +203,106 @@ export class BindThemSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('About') - .setDesc('BindThem combines features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler into a single plugin.') - .setHeading(); + .setName('Sentence regex') + .setDesc('Regular expression used to match sentences') + .addText(text => text + .setValue(this.plugin.settings.sentenceRegexSource) + .onChange(async (value) => { + this.plugin.settings.sentenceRegexSource = value; + await this.plugin.saveSettings(); + })); new Setting(containerEl) - .setName('Included features') - .setDesc('• Better Formatting - Smart toggle for bold, italic, code, highlight, etc.\n• Directional Copy/Move - Copy or move text in any direction\n• Toggle Headings (H1-H6) - Including strip formatting option\n• Editor Shortcuts - Insert/delete lines, join lines, case transform\n• Navigation - Go to line, headings, word navigation\n• Multi-cursor support - Insert cursor above/below\n• File operations - Duplicate file, create adjacent file'); + .setName('Reset sentence regex') + .addButton(button => button + .setButtonText('Reset') + .onClick(async () => { + this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX; + await this.plugin.saveSettings(); + this.display(); + })); + + // Commands section header + containerEl.createEl('h2', { text: 'Command Availability' }); + containerEl.createEl('p', { + text: 'Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.', + cls: 'bindthem-settings-desc' + }); + + // Category toggle buttons + new Setting(containerEl) + .setName('Enable/Disable All') + .setDesc('Toggle all commands on or off') + .addButton(button => button + .setButtonText('Enable All') + .onClick(async () => { + for (const cmdId of getAllCommandIds()) { + this.plugin.settings.enabledCommands[cmdId] = true; + } + await this.plugin.saveSettings(); + this.display(); + })) + .addButton(button => button + .setButtonText('Disable All') + .setWarning() + .onClick(async () => { + for (const cmdId of getAllCommandIds()) { + this.plugin.settings.enabledCommands[cmdId] = false; + } + await this.plugin.saveSettings(); + this.display(); + })); + + // Render each category + for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) { + this.renderCategory(containerEl, categoryKey, categoryName); + } + + // About section + new Setting(containerEl) + .setName('About') + .setDesc('BindThem combines features from obsidian-tweaks, obsidian-editor-shortcuts, heading-toggler, and obsidian-sentence-navigator.') + .setHeading(); + } + + private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void { + const commands = COMMANDS[categoryKey as keyof typeof COMMANDS]; + if (!commands || commands.length === 0) return; + + // Category header with toggle all + const categorySetting = new Setting(containerEl) + .setName(categoryName) + .setHeading(); + + // Check if all commands in this category are enabled + const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false); + const someEnabled = commands.some(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false); + + // Add toggle all button for this category + categorySetting.addToggle(toggle => { + toggle + .setValue(allEnabled) + .setTooltip(someEnabled && !allEnabled ? 'Some commands disabled - click to enable all' : 'Toggle all commands in category') + .onChange(async (value) => { + for (const cmd of commands) { + this.plugin.settings.enabledCommands[cmd.id] = value; + } + await this.plugin.saveSettings(); + this.display(); + }); + }); + + // Render individual commands + for (const cmd of commands) { + new Setting(containerEl) + .setName(cmd.name) + .setDesc(cmd.description || `Command ID: ${cmd.id}`) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enabledCommands[cmd.id] !== false) + .onChange(async (value) => { + this.plugin.settings.enabledCommands[cmd.id] = value; + await this.plugin.saveSettings(); + })); + } } } \ No newline at end of file