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 BindThemPlugin from './main';
|
||||
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
||||
|
||||
// ============================================================
|
||||
// Command Definitions - All commands organized by category
|
||||
@@ -149,8 +150,6 @@ export interface BindThemSettings {
|
||||
enabledCommands: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)';
|
||||
|
||||
export const DEFAULT_SETTINGS: BindThemSettings = {
|
||||
debug: false,
|
||||
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
|
||||
|
||||
Reference in New Issue
Block a user