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