Files
BindThem/src/BetterFormatting.ts
olivier 358ddca2f0
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled
fix typescript
2026-02-27 10:12:27 -05:00

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)
}
}