update and fix
This commit is contained in:
+297
-297
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+306
-305
@@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user