create better structure inspired by ObsidianTweaks'
This commit is contained in:
297
src/BetterFormatting.ts
Normal file
297
src/BetterFormatting.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Editor,
|
||||||
|
EditorChange,
|
||||||
|
EditorPosition,
|
||||||
|
EditorRange,
|
||||||
|
EditorRangeOrCaret,
|
||||||
|
EditorSelection,
|
||||||
|
EditorTransaction,
|
||||||
|
MarkdownView,
|
||||||
|
} from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { getMainSelection, selectionToRange } from './Utils'
|
||||||
|
import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BetterFormatting provides enhanced markdown formatting toggling
|
||||||
|
* that intelligently expands selections to word boundaries and
|
||||||
|
* handles widow characters.
|
||||||
|
*/
|
||||||
|
export class BetterFormatting {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a range by words, including intelligent handling of widow characters
|
||||||
|
*/
|
||||||
|
private expandRangeByWords(
|
||||||
|
editor: Editor,
|
||||||
|
range: EditorRange,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd: string
|
||||||
|
): EditorRange {
|
||||||
|
// Expand to word boundaries
|
||||||
|
let from = (editor.wordAt(range.from) || range).from
|
||||||
|
let to = (editor.wordAt(range.to) || range).to
|
||||||
|
|
||||||
|
// Include leading 'widows' (characters that should be included before the word)
|
||||||
|
while (true) {
|
||||||
|
const newFrom: EditorPosition = {
|
||||||
|
line: from.line,
|
||||||
|
ch: from.ch - 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTo: EditorPosition = {
|
||||||
|
line: to.line,
|
||||||
|
ch: to.ch + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const preChar = editor.getRange(newFrom, from)
|
||||||
|
const postChar = editor.getRange(to, newTo)
|
||||||
|
|
||||||
|
// Don't do this if widows form a matched pair
|
||||||
|
if (preChar === postChar) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symbolStart.endsWith(preChar)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
from = newFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include trailing 'widows'
|
||||||
|
while (true) {
|
||||||
|
const newFrom: EditorPosition = {
|
||||||
|
line: from.line,
|
||||||
|
ch: from.ch - 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTo: EditorPosition = {
|
||||||
|
line: to.line,
|
||||||
|
ch: to.ch + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const preChar = editor.getRange(newFrom, from)
|
||||||
|
const postChar = editor.getRange(to, newTo)
|
||||||
|
|
||||||
|
// Don't do this if widows form a matched pair
|
||||||
|
if (preChar === postChar) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symbolEnd.startsWith(postChar)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
to = newTo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include leading symbolStart
|
||||||
|
const newFrom = { line: from.line, ch: from.ch - symbolStart.length }
|
||||||
|
const preString = editor.getRange(newFrom, from)
|
||||||
|
if (preString === symbolStart) {
|
||||||
|
from = newFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include following symbolEnd
|
||||||
|
const newTo = { line: to.line, ch: to.ch + symbolEnd.length }
|
||||||
|
const postString = editor.getRange(to, newTo)
|
||||||
|
if (postString === symbolEnd) {
|
||||||
|
to = newTo
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text in the given range is already wrapped with the specified symbols
|
||||||
|
*/
|
||||||
|
private isWrapped(
|
||||||
|
editor: Editor,
|
||||||
|
range: EditorRange,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd: string
|
||||||
|
): boolean {
|
||||||
|
const text = editor.getRange(range.from, range.to)
|
||||||
|
return text.startsWith(symbolStart) && text.endsWith(symbolEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create changes to wrap text with symbols
|
||||||
|
*/
|
||||||
|
private wrap(
|
||||||
|
editor: Editor,
|
||||||
|
textRange: EditorRange,
|
||||||
|
selection: EditorSelection,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd: string
|
||||||
|
): [Array<EditorChange>, EditorSelection] {
|
||||||
|
const changes: Array<EditorChange> = [
|
||||||
|
{
|
||||||
|
from: textRange.from,
|
||||||
|
text: symbolStart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: textRange.to,
|
||||||
|
text: symbolEnd,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const newSelection: EditorSelection = {
|
||||||
|
anchor: { ...selection.anchor },
|
||||||
|
head: { ...selection.head },
|
||||||
|
}
|
||||||
|
newSelection.anchor.ch += symbolStart.length
|
||||||
|
newSelection.head.ch += symbolEnd.length
|
||||||
|
|
||||||
|
return [changes, newSelection]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create changes to unwrap text from symbols
|
||||||
|
*/
|
||||||
|
private unwrap(
|
||||||
|
editor: Editor,
|
||||||
|
textRange: EditorRange,
|
||||||
|
selection: EditorSelection,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd: string
|
||||||
|
): [Array<EditorChange>, EditorSelection] {
|
||||||
|
const changes: Array<EditorChange> = [
|
||||||
|
{
|
||||||
|
from: textRange.from,
|
||||||
|
to: {
|
||||||
|
line: textRange.from.line,
|
||||||
|
ch: textRange.from.ch + symbolStart.length,
|
||||||
|
},
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: {
|
||||||
|
line: textRange.to.line,
|
||||||
|
ch: textRange.to.ch - symbolEnd.length,
|
||||||
|
},
|
||||||
|
to: textRange.to,
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const newSelection: EditorSelection = {
|
||||||
|
anchor: { ...selection.anchor },
|
||||||
|
head: { ...selection.head },
|
||||||
|
}
|
||||||
|
newSelection.anchor.ch -= symbolStart.length
|
||||||
|
newSelection.head.ch -= symbolStart.length
|
||||||
|
|
||||||
|
return [changes, newSelection]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the wrap state for a selection
|
||||||
|
*/
|
||||||
|
private setSelectionWrapState(
|
||||||
|
editor: Editor,
|
||||||
|
selection: EditorSelection,
|
||||||
|
wrapState: boolean,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd: string
|
||||||
|
): [Array<EditorChange>, EditorSelection] {
|
||||||
|
const initialRange = selectionToRange(selection)
|
||||||
|
const textRange: EditorRange = this.expandRangeByWords(
|
||||||
|
editor,
|
||||||
|
initialRange,
|
||||||
|
symbolStart,
|
||||||
|
symbolEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if already wrapped
|
||||||
|
const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd)
|
||||||
|
if (alreadyWrapped === wrapState) {
|
||||||
|
return [[], selection]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap or unwrap
|
||||||
|
if (wrapState) {
|
||||||
|
return this.wrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle wrapper symbols around the current selection(s)
|
||||||
|
*
|
||||||
|
* Principle: Toggling twice == no-op
|
||||||
|
*/
|
||||||
|
public toggleWrapper(
|
||||||
|
editor: Editor,
|
||||||
|
view: MarkdownView,
|
||||||
|
symbolStart: string,
|
||||||
|
symbolEnd?: string
|
||||||
|
): void {
|
||||||
|
if (symbolEnd === undefined) {
|
||||||
|
symbolEnd = symbolStart
|
||||||
|
}
|
||||||
|
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
const mainSelection = getMainSelection(editor)
|
||||||
|
|
||||||
|
// Get wrapped state of main selection
|
||||||
|
const mainRange: EditorRange = this.expandRangeByWords(
|
||||||
|
editor,
|
||||||
|
selectionToRange(mainSelection),
|
||||||
|
symbolStart,
|
||||||
|
symbolEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if already wrapped
|
||||||
|
const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd)
|
||||||
|
const targetWrapState = !isWrapped
|
||||||
|
|
||||||
|
// Process all selections
|
||||||
|
const newChanges: Array<EditorChange> = []
|
||||||
|
const newSelectionRanges: Array<EditorRangeOrCaret> = []
|
||||||
|
|
||||||
|
for (const selection of selections) {
|
||||||
|
const [changes, newSelection] = this.setSelectionWrapState(
|
||||||
|
editor,
|
||||||
|
selection,
|
||||||
|
targetWrapState,
|
||||||
|
symbolStart,
|
||||||
|
symbolEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
newChanges.push(...changes)
|
||||||
|
newSelectionRanges.push(selectionToRange(newSelection))
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
changes: newChanges,
|
||||||
|
selections: newSelectionRanges,
|
||||||
|
}
|
||||||
|
|
||||||
|
let origin = targetWrapState ? '+' : '-'
|
||||||
|
origin += 'BetterFormatting_' + symbolStart + symbolEnd
|
||||||
|
|
||||||
|
editor.transaction(transaction, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Constants.ts
Normal file
42
src/Constants.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Debug Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const DEBUG_HEAD = '[BindThem] '
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Better Formatting Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters to expand selection before wrapper symbols
|
||||||
|
*/
|
||||||
|
export const PRE_WIDOW_EXPAND_CHARS = '#$@'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters to expand selection after wrapper symbols
|
||||||
|
*/
|
||||||
|
export const POST_WIDOW_EXPAND_CHARS = '!?:'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sentence Navigator Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default regex for matching sentences
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex for matching list item prefixes
|
||||||
|
*/
|
||||||
|
export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Heading Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex for matching heading markers at the start of a line
|
||||||
|
*/
|
||||||
|
export const HEADING_REGEX = /^(#*)( *)(.*)/
|
||||||
91
src/DirectionalCopy.ts
Normal file
91
src/DirectionalCopy.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { App, Editor, EditorChange, EditorTransaction, MarkdownView } from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { Direction } from './Entities'
|
||||||
|
import { selectionToLine, selectionToRange } from './Utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DirectionalCopy provides commands to copy content in different directions
|
||||||
|
*/
|
||||||
|
export class DirectionalCopy {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the current selection(s) in the specified direction
|
||||||
|
*/
|
||||||
|
public directionalCopy(
|
||||||
|
editor: Editor,
|
||||||
|
view: MarkdownView,
|
||||||
|
direction: Direction
|
||||||
|
): void {
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
|
||||||
|
const vertical: boolean = direction === Direction.Up || direction === Direction.Down
|
||||||
|
|
||||||
|
// If vertical we want to work with whole lines
|
||||||
|
if (vertical) {
|
||||||
|
selections.forEach((selection, idx, arr) => {
|
||||||
|
arr[idx] = selectionToLine(editor, selection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: Array<EditorChange> = []
|
||||||
|
for (const selection of selections) {
|
||||||
|
const range = selectionToRange(selection)
|
||||||
|
const content = editor.getRange(range.from, range.to)
|
||||||
|
|
||||||
|
let change: EditorChange
|
||||||
|
switch (direction) {
|
||||||
|
case Direction.Up: {
|
||||||
|
change = {
|
||||||
|
from: range.from,
|
||||||
|
to: range.from,
|
||||||
|
text: content + '\n',
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case Direction.Down: {
|
||||||
|
change = {
|
||||||
|
from: range.to,
|
||||||
|
to: range.to,
|
||||||
|
text: '\n' + content,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case Direction.Left: {
|
||||||
|
change = {
|
||||||
|
from: range.from,
|
||||||
|
to: range.from,
|
||||||
|
text: content,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case Direction.Right: {
|
||||||
|
change = {
|
||||||
|
from: range.to,
|
||||||
|
to: range.to,
|
||||||
|
text: content,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.push(change)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
changes: changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = 'DirectionalCopy_' + String(direction)
|
||||||
|
editor.transaction(transaction, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/DirectionalMove.ts
Normal file
82
src/DirectionalMove.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { App, Editor, EditorChange, EditorTransaction, MarkdownView } from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { Direction } from './Entities'
|
||||||
|
import { selectionToRange } from './Utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DirectionalMove provides commands to move selection content left or right
|
||||||
|
*/
|
||||||
|
export class DirectionalMove {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the current selection(s) in the specified direction (left or right)
|
||||||
|
*/
|
||||||
|
public directionalMove(
|
||||||
|
editor: Editor,
|
||||||
|
view: MarkdownView,
|
||||||
|
direction: Direction.Left | Direction.Right
|
||||||
|
): void {
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
|
||||||
|
const changes: Array<EditorChange> = []
|
||||||
|
for (const selection of selections) {
|
||||||
|
const range = selectionToRange(selection)
|
||||||
|
|
||||||
|
let additionChange: EditorChange
|
||||||
|
let deletionChange: EditorChange
|
||||||
|
switch (direction) {
|
||||||
|
case Direction.Left: {
|
||||||
|
deletionChange = {
|
||||||
|
from: {
|
||||||
|
line: range.from.line,
|
||||||
|
ch: range.from.ch - 1,
|
||||||
|
},
|
||||||
|
to: range.from,
|
||||||
|
text: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
additionChange = {
|
||||||
|
from: range.to,
|
||||||
|
to: range.to,
|
||||||
|
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case Direction.Right: {
|
||||||
|
deletionChange = {
|
||||||
|
from: range.to,
|
||||||
|
to: {
|
||||||
|
line: range.to.line,
|
||||||
|
ch: range.to.ch + 1,
|
||||||
|
},
|
||||||
|
text: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
additionChange = {
|
||||||
|
from: range.from,
|
||||||
|
to: range.from,
|
||||||
|
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.push(deletionChange, additionChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
changes: changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = 'DirectionalMove_' + String(direction)
|
||||||
|
editor.transaction(transaction, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Entities.ts
Normal file
43
src/Entities.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { EditorPosition, EditorRange, EditorSelection } from 'obsidian'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Direction Enum
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum Direction {
|
||||||
|
Up = 'Up',
|
||||||
|
Down = 'Down',
|
||||||
|
Left = 'Left',
|
||||||
|
Right = 'Right',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Heading Enum
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum Heading {
|
||||||
|
NORMAL = 0,
|
||||||
|
H1 = 1,
|
||||||
|
H2 = 2,
|
||||||
|
H3 = 3,
|
||||||
|
H4 = 4,
|
||||||
|
H5 = 5,
|
||||||
|
H6 = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Case Type Enum
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum CaseType {
|
||||||
|
Upper,
|
||||||
|
Lower,
|
||||||
|
Title,
|
||||||
|
Next,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Editor Types (re-exported from Obsidian for convenience)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type { EditorPosition, EditorRange, EditorSelection }
|
||||||
61
src/FileHelper.ts
Normal file
61
src/FileHelper.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { App, Editor, MarkdownView, Notice } from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileHelper provides commands for file operations
|
||||||
|
*/
|
||||||
|
export class FileHelper {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate the current file
|
||||||
|
*/
|
||||||
|
public async duplicateFile(editor: Editor, view: MarkdownView): Promise<void> {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile()
|
||||||
|
|
||||||
|
if (activeFile === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
const parentPath = activeFile.parent?.path ?? ''
|
||||||
|
const newFilePath =
|
||||||
|
parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newFile = await this.app.vault.copy(activeFile, newFilePath)
|
||||||
|
await view.leaf.openFile(newFile)
|
||||||
|
|
||||||
|
editor.setSelections(selections)
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new file in the same directory as the current file
|
||||||
|
*/
|
||||||
|
public async newAdjacentFile(editor: Editor, view: MarkdownView): Promise<void> {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile()
|
||||||
|
|
||||||
|
if (activeFile === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = activeFile.parent?.path ?? ''
|
||||||
|
const newFilePath = parentPath + '/' + 'Untitled.md'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newFile = await this.app.vault.create(newFilePath, '')
|
||||||
|
await view.leaf.openFile(newFile)
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/SelectionHelper.ts
Normal file
63
src/SelectionHelper.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { App, Editor, EditorRange, EditorTransaction, MarkdownView } from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { selectionToLine, selectionToRange } from './Utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectionHelper provides commands for manipulating text selections
|
||||||
|
*/
|
||||||
|
export class SelectionHelper {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the current line(s)
|
||||||
|
*/
|
||||||
|
public selectLine(editor: Editor, view: MarkdownView): void {
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
|
||||||
|
const newSelectionRanges: Array<EditorRange> = []
|
||||||
|
for (const selection of selections) {
|
||||||
|
const newSelection = selectionToLine(editor, selection)
|
||||||
|
newSelectionRanges.push(selectionToRange(newSelection))
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
selections: newSelectionRanges,
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.transaction(transaction, 'SelectionHelper_Line')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the current word(s)
|
||||||
|
*/
|
||||||
|
public selectWord(editor: Editor, view: MarkdownView): void {
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
|
||||||
|
const newSelections: Array<EditorRange> = []
|
||||||
|
for (const selection of selections) {
|
||||||
|
const range = selectionToRange(selection)
|
||||||
|
|
||||||
|
const wordStart = editor.wordAt(range.from)?.from ?? range.from
|
||||||
|
const wordEnd = editor.wordAt(range.to)?.to ?? range.from
|
||||||
|
|
||||||
|
const newSelection: EditorRange = {
|
||||||
|
from: { line: wordStart.line, ch: wordStart.ch },
|
||||||
|
to: { line: wordEnd.line, ch: wordEnd.ch },
|
||||||
|
}
|
||||||
|
|
||||||
|
newSelections.push(newSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
selections: newSelections,
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.transaction(transaction, 'SelectionHelper_Word')
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/SentenceNavigator.ts
Normal file
276
src/SentenceNavigator.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { App, Editor, EditorPosition, MarkdownView } from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { LIST_CHARACTER_REGEX } from './Constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SentenceNavigator provides commands for navigating and manipulating sentences
|
||||||
|
* Based on obsidian-sentence-navigator functionality
|
||||||
|
*/
|
||||||
|
export class SentenceNavigator {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
private sentenceRegex: RegExp
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin, regexSource: string) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the sentence regex pattern
|
||||||
|
*/
|
||||||
|
public updateRegex(regexSource: string): void {
|
||||||
|
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cursor position and current paragraph text
|
||||||
|
*/
|
||||||
|
private getCursorAndParagraphText(editor: Editor): {
|
||||||
|
cursorPosition: EditorPosition
|
||||||
|
paragraphText: string
|
||||||
|
} {
|
||||||
|
const cursorPosition = editor.getCursor()
|
||||||
|
return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over all sentences in a paragraph
|
||||||
|
*/
|
||||||
|
private forEachSentence(
|
||||||
|
paragraphText: string,
|
||||||
|
callback: (sentence: RegExpMatchArray) => boolean | void
|
||||||
|
): void {
|
||||||
|
for (const sentence of paragraphText.matchAll(this.sentenceRegex)) {
|
||||||
|
if (callback(sentence)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cursor at the next non-space character
|
||||||
|
*/
|
||||||
|
private setCursorAtNextWordCharacter(
|
||||||
|
editor: Editor,
|
||||||
|
cursorPosition: EditorPosition,
|
||||||
|
paragraphText: string,
|
||||||
|
direction: 'start' | 'end'
|
||||||
|
): void {
|
||||||
|
let ch = cursorPosition.ch
|
||||||
|
if (direction === 'start') {
|
||||||
|
while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch--
|
||||||
|
} else {
|
||||||
|
while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++
|
||||||
|
}
|
||||||
|
editor.setCursor({ ch, line: cursorPosition.line })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the previous non-empty line number
|
||||||
|
*/
|
||||||
|
private getPrevNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||||
|
let prevLine = currentLine - 1
|
||||||
|
while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine--
|
||||||
|
return prevLine
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next non-empty line number
|
||||||
|
*/
|
||||||
|
private getNextNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||||
|
let nextLine = currentLine + 1
|
||||||
|
while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++
|
||||||
|
return nextLine
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cursor is at the start of a list item
|
||||||
|
*/
|
||||||
|
private isAtStartOfListItem(
|
||||||
|
cursorPosition: EditorPosition,
|
||||||
|
paragraphText: string
|
||||||
|
): boolean {
|
||||||
|
return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete text from cursor to sentence boundary
|
||||||
|
*/
|
||||||
|
public deleteToBoundary(editor: Editor, view: MarkdownView, boundary: 'start' | 'end'): void {
|
||||||
|
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||||
|
const originalCursorPosition = cursorPosition
|
||||||
|
|
||||||
|
if (
|
||||||
|
paragraphText.charAt(cursorPosition.ch) === ' ' ||
|
||||||
|
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||||
|
) {
|
||||||
|
this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary)
|
||||||
|
;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forEachSentence(paragraphText, (sentence) => {
|
||||||
|
const idx = sentence.index ?? 0
|
||||||
|
if (
|
||||||
|
cursorPosition.ch >= idx &&
|
||||||
|
cursorPosition.ch <= idx + sentence[0].length
|
||||||
|
) {
|
||||||
|
if (boundary === 'start') {
|
||||||
|
const newText =
|
||||||
|
paragraphText.substring(0, idx) +
|
||||||
|
paragraphText.substring(originalCursorPosition.ch)
|
||||||
|
const cutLength = paragraphText.length - newText.length
|
||||||
|
editor.replaceRange(
|
||||||
|
newText,
|
||||||
|
{ line: cursorPosition.line, ch: 0 },
|
||||||
|
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||||
|
)
|
||||||
|
editor.setCursor({
|
||||||
|
line: cursorPosition.line,
|
||||||
|
ch: originalCursorPosition.ch - cutLength,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const remainingLength = idx + sentence[0].length - cursorPosition.ch
|
||||||
|
const newText =
|
||||||
|
paragraphText.substring(0, originalCursorPosition.ch) +
|
||||||
|
paragraphText.substring(cursorPosition.ch + remainingLength)
|
||||||
|
editor.replaceRange(
|
||||||
|
newText,
|
||||||
|
{ line: cursorPosition.line, ch: 0 },
|
||||||
|
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||||
|
)
|
||||||
|
editor.setCursor(originalCursorPosition)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select text from cursor to sentence boundary
|
||||||
|
*/
|
||||||
|
public selectToBoundary(editor: Editor, view: MarkdownView, boundary: 'start' | 'end'): void {
|
||||||
|
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||||
|
this.forEachSentence(paragraphText, (sentence) => {
|
||||||
|
const idx = sentence.index ?? 0
|
||||||
|
if (
|
||||||
|
cursorPosition.ch >= idx &&
|
||||||
|
cursorPosition.ch <= idx + sentence[0].length
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
editor.getSelection().length > 0 &&
|
||||||
|
(cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (boundary === 'start') {
|
||||||
|
const precedingLength = cursorPosition.ch - idx
|
||||||
|
editor.setSelection(cursorPosition, {
|
||||||
|
line: cursorPosition.line,
|
||||||
|
ch: cursorPosition.ch - precedingLength,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
editor.setSelection(cursorPosition, {
|
||||||
|
line: cursorPosition.line,
|
||||||
|
ch: idx + sentence[0].length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move cursor to the start of the current sentence
|
||||||
|
*/
|
||||||
|
public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView): void {
|
||||||
|
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||||
|
|
||||||
|
if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) {
|
||||||
|
const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line)
|
||||||
|
if (prevLine >= 0) {
|
||||||
|
editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forEachSentence(paragraphText, (sentence) => {
|
||||||
|
const idx = sentence.index ?? 0
|
||||||
|
while (
|
||||||
|
cursorPosition.ch > 0 &&
|
||||||
|
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||||
|
) {
|
||||||
|
editor.setCursor({
|
||||||
|
line: cursorPosition.line,
|
||||||
|
ch: --cursorPosition.ch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) {
|
||||||
|
editor.setCursor({ line: cursorPosition.line, ch: idx })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move cursor to the start of the next sentence
|
||||||
|
*/
|
||||||
|
public moveToStartOfNextSentence(editor: Editor, view: MarkdownView): void {
|
||||||
|
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||||
|
|
||||||
|
if (cursorPosition.ch === paragraphText.length) {
|
||||||
|
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||||
|
if (nextLine < editor.lineCount()) {
|
||||||
|
editor.setCursor({ line: nextLine, ch: 0 })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forEachSentence(paragraphText, (sentence) => {
|
||||||
|
const idx = sentence.index ?? 0
|
||||||
|
while (
|
||||||
|
cursorPosition.ch < paragraphText.length &&
|
||||||
|
paragraphText.charAt(cursorPosition.ch) === ' '
|
||||||
|
) {
|
||||||
|
editor.setCursor({
|
||||||
|
line: cursorPosition.line,
|
||||||
|
ch: ++cursorPosition.ch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) {
|
||||||
|
const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length }
|
||||||
|
this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end')
|
||||||
|
if (endPos.ch >= paragraphText.length) {
|
||||||
|
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||||
|
if (nextLine < editor.lineCount()) {
|
||||||
|
editor.setCursor({ line: nextLine, ch: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the current sentence
|
||||||
|
*/
|
||||||
|
public selectSentence(editor: Editor, view: MarkdownView): void {
|
||||||
|
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||||
|
let offset = 0
|
||||||
|
let text = paragraphText
|
||||||
|
const matches = paragraphText.match(/^(\d+\.|[-*+]) /)
|
||||||
|
if (matches) {
|
||||||
|
offset = matches[0].length
|
||||||
|
text = paragraphText.slice(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forEachSentence(text, (sentence) => {
|
||||||
|
const idx = sentence.index ?? 0
|
||||||
|
if (cursorPosition.ch <= offset + idx + sentence[0].length) {
|
||||||
|
editor.setSelection(
|
||||||
|
{ line: cursorPosition.line, ch: offset + idx },
|
||||||
|
{ line: cursorPosition.line, ch: offset + idx + sentence[0].length }
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/ToggleHeading.ts
Normal file
139
src/ToggleHeading.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Editor,
|
||||||
|
EditorChange,
|
||||||
|
EditorPosition,
|
||||||
|
EditorTransaction,
|
||||||
|
MarkdownView,
|
||||||
|
} from 'obsidian'
|
||||||
|
import BindThemPlugin from './main'
|
||||||
|
import { Heading } from './Entities'
|
||||||
|
import { getMainSelection, selectionToRange } from './Utils'
|
||||||
|
import { HEADING_REGEX } from './Constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToggleHeading provides commands to toggle markdown heading levels
|
||||||
|
*/
|
||||||
|
export class ToggleHeading {
|
||||||
|
public app: App
|
||||||
|
private plugin: BindThemPlugin
|
||||||
|
|
||||||
|
constructor(app: App, plugin: BindThemPlugin) {
|
||||||
|
this.app = app
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a change to set the heading level for a specific line
|
||||||
|
*/
|
||||||
|
private setHeading(
|
||||||
|
editor: Editor,
|
||||||
|
heading: Heading,
|
||||||
|
line: number
|
||||||
|
): EditorChange {
|
||||||
|
const headingStr = '#'.repeat(heading)
|
||||||
|
const text = editor.getLine(line)
|
||||||
|
const matches = HEADING_REGEX.exec(text)!
|
||||||
|
|
||||||
|
const from: EditorPosition = {
|
||||||
|
line: line,
|
||||||
|
ch: 0,
|
||||||
|
}
|
||||||
|
const to: EditorPosition = {
|
||||||
|
line: line,
|
||||||
|
ch: matches[1].length + matches[2].length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' '
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
text: replacementStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the heading level of a specific line
|
||||||
|
*/
|
||||||
|
private getHeadingOfLine(editor: Editor, line: number): Heading {
|
||||||
|
const text = editor.getLine(line)
|
||||||
|
const matches = HEADING_REGEX.exec(text)!
|
||||||
|
|
||||||
|
return matches[1].length as Heading
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle heading for the current selection(s)
|
||||||
|
* If the line already has the specified heading, remove it (set to NORMAL)
|
||||||
|
*/
|
||||||
|
public toggleHeading(
|
||||||
|
editor: Editor,
|
||||||
|
view: MarkdownView,
|
||||||
|
heading: Heading
|
||||||
|
): void {
|
||||||
|
const selections = editor.listSelections()
|
||||||
|
const mainSelection = getMainSelection(editor)
|
||||||
|
const mainRange = selectionToRange(mainSelection)
|
||||||
|
|
||||||
|
const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line)
|
||||||
|
const targetHeading = heading === mainHeading ? Heading.NORMAL : heading
|
||||||
|
|
||||||
|
const changes: Array<EditorChange> = []
|
||||||
|
|
||||||
|
// Collect unique lines from all selections
|
||||||
|
const linesToSet: Set<number> = new Set()
|
||||||
|
for (const selection of selections) {
|
||||||
|
const range = selectionToRange(selection)
|
||||||
|
for (let line = range.from.line; line <= range.to.line; line++) {
|
||||||
|
linesToSet.add(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create changes for each line
|
||||||
|
for (const line of linesToSet) {
|
||||||
|
if (editor.getLine(line) === '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes.push(this.setHeading(editor, targetHeading, line))
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction: EditorTransaction = {
|
||||||
|
changes: changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.transaction(transaction, 'ToggleHeading' + heading.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle heading with stripping of inline formatting
|
||||||
|
* This removes bold/italic formatting when toggling headings
|
||||||
|
*/
|
||||||
|
public toggleHeadingWithStrip(
|
||||||
|
editor: Editor,
|
||||||
|
view: MarkdownView,
|
||||||
|
level: number
|
||||||
|
): void {
|
||||||
|
const cursor = editor.getCursor()
|
||||||
|
const line = editor.getLine(cursor.line)
|
||||||
|
const match = line.match(/^(#+)\s(.*)/)
|
||||||
|
|
||||||
|
let content = line
|
||||||
|
let currentLevel = 0
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
currentLevel = match[1].length
|
||||||
|
content = match[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip bold and italic formatting
|
||||||
|
content = content
|
||||||
|
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||||
|
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||||
|
|
||||||
|
editor.setLine(
|
||||||
|
cursor.line,
|
||||||
|
currentLevel === level ? content : '#'.repeat(level) + ' ' + content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/Utils.ts
Normal file
94
src/Utils.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Editor, EditorRange, EditorSelection } from 'obsidian'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Selection Utilities
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the main (primary) selection from the editor
|
||||||
|
*/
|
||||||
|
export function getMainSelection(editor: Editor): EditorSelection {
|
||||||
|
return {
|
||||||
|
anchor: editor.getCursor('anchor'),
|
||||||
|
head: editor.getCursor('head'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a selection to a range (from/to with sorted positions)
|
||||||
|
*/
|
||||||
|
export function selectionToRange(selection: EditorSelection): EditorRange {
|
||||||
|
const sortedPositions = [selection.anchor, selection.head].sort((a, b) => {
|
||||||
|
if (a.line !== b.line) return a.line - b.line
|
||||||
|
return a.ch - b.ch
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
from: sortedPositions[0],
|
||||||
|
to: sortedPositions[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a selection to encompass full lines
|
||||||
|
*/
|
||||||
|
export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection {
|
||||||
|
const range = selectionToRange(selection)
|
||||||
|
return {
|
||||||
|
anchor: { line: range.from.line, ch: 0 },
|
||||||
|
head: { line: range.to.line, ch: editor.getLine(range.to.line).length },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Text Utilities
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get leading whitespace (indentation) from a line
|
||||||
|
*/
|
||||||
|
export function getLeadingWhitespace(lineContent: string): string {
|
||||||
|
const indentation = lineContent.match(/^\s+/)
|
||||||
|
return indentation ? indentation[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a character is a letter or digit
|
||||||
|
*/
|
||||||
|
export function isLetterOrDigit(char: string): boolean {
|
||||||
|
return /\p{L}\p{M}*/u.test(char) || /\d/.test(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the word range at a given position
|
||||||
|
*/
|
||||||
|
export function wordRangeAtPos(
|
||||||
|
pos: { line: number; ch: number },
|
||||||
|
lineContent: string
|
||||||
|
): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } {
|
||||||
|
let start = pos.ch
|
||||||
|
let end = pos.ch
|
||||||
|
while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start--
|
||||||
|
while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++
|
||||||
|
return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Case Transformation Utilities
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const LOWERCASE_ARTICLES = ['the', 'a', 'an']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert text to title case
|
||||||
|
*/
|
||||||
|
export function toTitleCase(text: string): string {
|
||||||
|
return text
|
||||||
|
.split(/(\s+)/)
|
||||||
|
.map((w, i, all) => {
|
||||||
|
if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) {
|
||||||
|
return w.toLowerCase()
|
||||||
|
}
|
||||||
|
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
1540
src/main.ts
1540
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||||
import BindThemPlugin from './main';
|
import BindThemPlugin from './main';
|
||||||
|
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Command Definitions - All commands organized by category
|
// Command Definitions - All commands organized by category
|
||||||
@@ -149,8 +150,6 @@ export interface BindThemSettings {
|
|||||||
enabledCommands: Record<string, boolean>;
|
enabledCommands: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)';
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: BindThemSettings = {
|
export const DEFAULT_SETTINGS: BindThemSettings = {
|
||||||
debug: false,
|
debug: false,
|
||||||
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
|
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
|
||||||
|
|||||||
Reference in New Issue
Block a user