update and fix
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-05-15 16:23:28 -04:00
parent 358ddca2f0
commit fcbf1492b7
26 changed files with 8596 additions and 8272 deletions
+297 -297
View File
@@ -1,298 +1,298 @@
import {
App,
Editor,
EditorChange,
EditorPosition,
EditorRange,
EditorRangeOrCaret,
EditorSelection,
EditorTransaction,
MarkdownFileInfo,
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 | MarkdownFileInfo,
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)
}
import {
App,
Editor,
EditorChange,
EditorPosition,
EditorRange,
EditorRangeOrCaret,
EditorSelection,
EditorTransaction,
MarkdownFileInfo,
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 | MarkdownFileInfo,
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)
}
}
+41 -41
View File
@@ -1,42 +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
*/
// ============================================================
// 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 = /^(#*)( *)(.*)/
+90 -90
View File
@@ -1,91 +1,91 @@
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo,
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)
}
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo,
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)
}
}
+81 -81
View File
@@ -1,82 +1,82 @@
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo,
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)
}
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo,
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)
}
}
+42 -42
View File
@@ -1,43 +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)
// ============================================================
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 }
+64 -64
View File
@@ -1,65 +1,65 @@
import { App, Editor, MarkdownFileInfo, 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 | MarkdownFileInfo): 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)
if (view instanceof MarkdownView) {
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 | MarkdownFileInfo): 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, '')
if (view instanceof MarkdownView) {
await view.leaf.openFile(newFile)
}
} catch (e) {
new Notice(String(e))
}
}
import { App, Editor, MarkdownFileInfo, 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 | MarkdownFileInfo): 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)
if (view instanceof MarkdownView) {
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 | MarkdownFileInfo): 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, '')
if (view instanceof MarkdownView) {
await view.leaf.openFile(newFile)
}
} catch (e) {
new Notice(String(e))
}
}
}
+62 -62
View File
@@ -1,63 +1,63 @@
import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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')
}
import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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')
}
}
+328
View File
@@ -0,0 +1,328 @@
import { Editor, EditorPosition, EditorSelection } from 'obsidian'
import { selectionToRange } from './Utils'
// ============================================================
// Selection occurrence tracking (ported from obsidian-editor-shortcuts)
// ============================================================
/*
Properties used to distinguish between selections that are programmatic
(expanding from a cursor selection) vs. manual (using a mouse / Shift + arrow
keys). This controls the match behaviour for selectWordOrNextOccurrence.
*/
let isManualSelection = true
let isProgrammaticSelectionChange = false
export const setIsManualSelection = (value: boolean) => {
isManualSelection = value
}
export const setIsProgrammaticSelectionChange = (value: boolean) => {
isProgrammaticSelectionChange = value
}
export const getIsManualSelection = () => isManualSelection
export const getIsProgrammaticSelectionChange = () => isProgrammaticSelectionChange
/**
* Check if all selections have the same text content
*/
function hasSameSelectionContent(
editor: Editor,
selections: EditorSelection[],
): boolean {
return (
new Set(
selections.map((selection) => {
const { from, to } = selectionToRange(selection)
return editor.getRange(from, to)
}),
).size === 1
)
}
/**
* Get the search text from the current selections.
* If autoExpand is true and no text is selected, expands to word at cursor.
*/
function getSearchText({
editor,
allSelections,
autoExpand,
}: {
editor: Editor
allSelections: EditorSelection[]
autoExpand: boolean
}): { searchText: string; singleSearchText: boolean } {
const singleSearchText = hasSameSelectionContent(editor, allSelections)
const firstSelection = allSelections[0]
const { from, to } = selectionToRange(firstSelection)
let searchText = editor.getRange(from, to)
if (searchText.length === 0 && autoExpand) {
const wordRange = editor.wordAt(from)
if (wordRange) {
searchText = editor.getRange(wordRange.from, wordRange.to)
}
}
return { searchText, singleSearchText }
}
/**
* Escapes any special regex characters in the given string.
*/
const escapeRegex = (input: string) =>
input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Constructs a custom regex query with word boundaries because `\b` in JS
* doesn't match word boundaries for unicode characters, even with the unicode flag.
*
* Adapted from https://shiba1014.medium.com/regex-word-boundaries-with-unicode-207794f6e7ed.
*/
const withWordBoundaries = (input: string) =>
`(?<=\\W|^)${input}(?=\\W|$)`
/**
* Find all match positions of searchText in the document content.
* When searchWithinWords is false, only whole-word matches are returned.
*/
function findAllMatches({
searchText,
searchWithinWords,
documentContent,
}: {
searchText: string
searchWithinWords: boolean
documentContent: string
}): RegExpMatchArray[] {
const escapedSearchText = escapeRegex(searchText)
const searchExpression = new RegExp(
searchWithinWords
? escapedSearchText
: withWordBoundaries(escapedSearchText),
'g',
)
return Array.from(documentContent.matchAll(searchExpression))
}
/**
* Find all match positions as editor selections.
*/
function findAllMatchPositions({
editor,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection[] {
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
const matchPositions: EditorSelection[] = []
for (const match of matches) {
matchPositions.push({
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
})
}
return matchPositions
}
/**
* Find the next match position after a given position.
* If no match is found after, wraps around to the beginning.
* Already-selected positions are skipped.
*/
function findNextMatchPosition({
editor,
latestMatchPos,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
latestMatchPos: EditorPosition
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection | null {
const latestMatchOffset = editor.posToOffset(latestMatchPos)
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
// Find first match after current position
for (const match of matches) {
if (match.index! > latestMatchOffset) {
return {
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
}
}
}
// Circle back to search from the top, skipping already-selected positions
const selectionIndexes = editor.listSelections().map((selection) => {
const { from } = selectionToRange(selection)
return editor.posToOffset(from)
})
for (const match of matches) {
if (!selectionIndexes.includes(match.index!)) {
return {
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
}
}
}
return null
}
/**
* Find the previous match position before a given position.
* If no match is found before, wraps around to the end.
* Already-selected positions are skipped.
*/
function findPrevMatchPosition({
editor,
latestMatchPos,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
latestMatchPos: EditorPosition
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection | null {
const latestMatchOffset = editor.posToOffset(latestMatchPos)
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
// Find last match before current position
for (let i = matches.length - 1; i >= 0; i--) {
if (matches[i].index! < latestMatchOffset) {
return {
anchor: editor.offsetToPos(matches[i].index!),
head: editor.offsetToPos(matches[i].index! + searchText.length),
}
}
}
// Circle back from the bottom, skipping already-selected positions
const selectionIndexes = editor.listSelections().map((selection) => {
const { from } = selectionToRange(selection)
return editor.posToOffset(from)
})
for (let i = matches.length - 1; i >= 0; i--) {
if (!selectionIndexes.includes(matches[i].index!)) {
return {
anchor: editor.offsetToPos(matches[i].index!),
head: editor.offsetToPos(matches[i].index! + searchText.length),
}
}
}
return null
}
/**
* Select the next or previous occurrence of the current selection text.
* When nothing is selected, expands to the word under cursor first.
*
* Ported from obsidian-editor-shortcuts `selectWordOrNextOccurrence`.
*/
export function selectWordOrNextOccurrence(
editor: Editor,
direction: 'next' | 'prev',
): void {
setIsProgrammaticSelectionChange(true)
const allSelections = editor.listSelections()
const { searchText, singleSearchText } = getSearchText({
editor,
allSelections,
autoExpand: false,
})
if (searchText.length > 0 && singleSearchText) {
// All selections have the same content — find next/prev occurrence
const latestSelection =
direction === 'next'
? allSelections[allSelections.length - 1]
: allSelections[0]
const { from: latestMatchPos } = selectionToRange(latestSelection)
const findFn =
direction === 'next' ? findNextMatchPosition : findPrevMatchPosition
const nextMatch = findFn({
editor,
latestMatchPos,
searchText,
searchWithinWords: isManualSelection,
documentContent: editor.getValue(),
})
const newSelections = nextMatch
? allSelections.concat(nextMatch)
: allSelections
editor.setSelections(newSelections)
const lastSelection = newSelections[newSelections.length - 1]
editor.scrollIntoView(selectionToRange(lastSelection))
} else {
// No text selected — expand each cursor to its word
const newSelections: EditorSelection[] = []
for (const selection of allSelections) {
const { from, to } = selectionToRange(selection)
// Don't modify existing range selections
if (from.line !== to.line || from.ch !== to.ch) {
newSelections.push(selection)
} else {
const wordAt = editor.wordAt(from)
if (wordAt) {
newSelections.push({ anchor: wordAt.from, head: wordAt.to })
} else {
newSelections.push(selection)
}
setIsManualSelection(false)
}
}
editor.setSelections(newSelections)
}
}
/**
* Select all occurrences of the current selection text in the document.
* If nothing is selected, expands to the word under cursor first.
*
* Ported from obsidian-editor-shortcuts `selectAllOccurrences`.
*/
export function selectAllOccurrences(editor: Editor): void {
const allSelections = editor.listSelections()
const { searchText, singleSearchText } = getSearchText({
editor,
allSelections,
autoExpand: true,
})
if (!singleSearchText) {
return
}
const matches = findAllMatchPositions({
editor,
searchText,
searchWithinWords: true,
documentContent: editor.getValue(),
})
editor.setSelections(matches)
}
+275 -275
View File
@@ -1,276 +1,276 @@
import { App, Editor, EditorPosition, MarkdownFileInfo, 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 | MarkdownFileInfo, 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 | MarkdownFileInfo, 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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
}
})
}
import { App, Editor, EditorPosition, MarkdownFileInfo, 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 | MarkdownFileInfo, 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 | MarkdownFileInfo, 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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 | MarkdownFileInfo): 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 -139
View File
@@ -1,140 +1,140 @@
import {
App,
Editor,
EditorChange,
EditorPosition,
EditorTransaction,
MarkdownFileInfo,
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 | MarkdownFileInfo,
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 | MarkdownFileInfo,
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
)
}
import {
App,
Editor,
EditorChange,
EditorPosition,
EditorTransaction,
MarkdownFileInfo,
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 | MarkdownFileInfo,
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 | MarkdownFileInfo,
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
)
}
}
+93 -93
View File
@@ -1,94 +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(' ')
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(' ')
}
+955 -959
View File
File diff suppressed because it is too large Load Diff
+306 -305
View File
@@ -1,306 +1,307 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import BindThemPlugin from './main';
import { DEFAULT_SENTENCE_REGEX } from './Constants';
// ============================================================
// Command Definitions - All commands organized by category
// ============================================================
export interface CommandDefinition {
id: string;
name: string;
description?: string;
}
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<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = {
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' },
{ id: 'select-next-occurrence', name: 'Select next occurrence' },
{ id: 'select-prev-occurrence', name: 'Select previous occurrence' }
],
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<string, boolean> {
const enabled: Record<string, boolean> = {};
for (const cmdId of getAllCommandIds()) {
enabled[cmdId] = true;
}
return enabled;
}
// ============================================================
// Settings Interface
// ============================================================
export interface BindThemSettings {
debug: boolean;
sentenceRegexSource: string;
enabledCommands: Record<string, boolean>;
}
export const DEFAULT_SETTINGS: BindThemSettings = {
debug: false,
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
enabledCommands: createDefaultEnabledCommands()
};
// ============================================================
// Settings Tab
// ============================================================
export class BindThemSettingTab extends PluginSettingTab {
plugin: BindThemPlugin;
constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Hotkeys link at the top
containerEl.createEl('a', {
text: 'Configure keyboard shortcuts',
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();
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
if (hotkeysTab) {
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
}
});
});
// General settings
new Setting(containerEl)
.setName('Debug mode')
.setDesc('Enable debug logging to console')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.debug)
.onChange(async (value) => {
this.plugin.settings.debug = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.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('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
new Setting(containerEl)
.setName('Command availability')
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
.setHeading();
// 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')
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setDesc('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);
// Add toggle all button for this category
categorySetting.addToggle(toggle => {
toggle
.setValue(allEnabled)
.setTooltip('Toggle all commands in this 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();
}));
}
}
import { App, PluginSettingTab, Setting } from 'obsidian';
import BindThemPlugin from './main';
import { DEFAULT_SENTENCE_REGEX } from './Constants';
// ============================================================
// Command Definitions - All commands organized by category
// ============================================================
export interface CommandDefinition {
id: string;
name: string;
description?: string;
}
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<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = {
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' },
{ id: 'select-next-occurrence', name: 'Select next occurrence' },
{ id: 'select-prev-occurrence', name: 'Select previous occurrence' },
{ id: 'select-all-occurrences', name: 'Select all occurrences' }
],
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<string, boolean> {
const enabled: Record<string, boolean> = {};
for (const cmdId of getAllCommandIds()) {
enabled[cmdId] = true;
}
return enabled;
}
// ============================================================
// Settings Interface
// ============================================================
export interface BindThemSettings {
debug: boolean;
sentenceRegexSource: string;
enabledCommands: Record<string, boolean>;
}
export const DEFAULT_SETTINGS: BindThemSettings = {
debug: false,
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
enabledCommands: createDefaultEnabledCommands()
};
// ============================================================
// Settings Tab
// ============================================================
export class BindThemSettingTab extends PluginSettingTab {
plugin: BindThemPlugin;
constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Hotkeys link at the top
containerEl.createEl('a', {
text: 'Configure keyboard shortcuts',
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();
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
if (hotkeysTab) {
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
}
});
});
// General settings
new Setting(containerEl)
.setName('Debug mode')
.setDesc('Enable debug logging to console')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.debug)
.onChange(async (value) => {
this.plugin.settings.debug = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.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('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
new Setting(containerEl)
.setName('Command availability')
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
.setHeading();
// 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')
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setDesc('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);
// Add toggle all button for this category
categorySetting.addToggle(toggle => {
toggle
.setValue(allEnabled)
.setTooltip('Toggle all commands in this 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();
}));
}
}
}