create better structure inspired by ObsidianTweaks'
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

This commit is contained in:
2026-02-26 15:19:52 -05:00
parent 580f2d8c2b
commit 7022dadd83
12 changed files with 2105 additions and 654 deletions

297
src/BetterFormatting.ts Normal file
View File

@@ -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<EditorChange>, EditorSelection] {
const changes: Array<EditorChange> = [
{
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<EditorChange>, EditorSelection] {
const changes: Array<EditorChange> = [
{
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<EditorChange>, 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<EditorChange> = []
const newSelectionRanges: Array<EditorRangeOrCaret> = []
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)
}
}

42
src/Constants.ts Normal file
View File

@@ -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 = /^(#*)( *)(.*)/

91
src/DirectionalCopy.ts Normal file
View File

@@ -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<EditorChange> = []
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)
}
}

82
src/DirectionalMove.ts Normal file
View File

@@ -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<EditorChange> = []
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)
}
}

43
src/Entities.ts Normal file
View File

@@ -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 }

61
src/FileHelper.ts Normal file
View File

@@ -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<void> {
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<void> {
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))
}
}
}

63
src/SelectionHelper.ts Normal file
View File

@@ -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<EditorRange> = []
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<EditorRange> = []
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')
}
}

276
src/SentenceNavigator.ts Normal file
View File

@@ -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
}
})
}
}

139
src/ToggleHeading.ts Normal file
View File

@@ -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<EditorChange> = []
// Collect unique lines from all selections
const linesToSet: Set<number> = 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
)
}
}

94
src/Utils.ts Normal file
View File

@@ -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(' ')
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { App, PluginSettingTab, Setting } from 'obsidian'; import { App, PluginSettingTab, Setting } from 'obsidian';
import BindThemPlugin from './main'; import BindThemPlugin from './main';
import { DEFAULT_SENTENCE_REGEX } from './Constants';
// ============================================================ // ============================================================
// Command Definitions - All commands organized by category // Command Definitions - All commands organized by category
@@ -149,8 +150,6 @@ export interface BindThemSettings {
enabledCommands: Record<string, boolean>; enabledCommands: Record<string, boolean>;
} }
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)';
export const DEFAULT_SETTINGS: BindThemSettings = { export const DEFAULT_SETTINGS: BindThemSettings = {
debug: false, debug: false,
sentenceRegexSource: DEFAULT_SENTENCE_REGEX, sentenceRegexSource: DEFAULT_SENTENCE_REGEX,