298 lines
6.5 KiB
TypeScript
298 lines
6.5 KiB
TypeScript
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)
|
|
}
|
|
} |