186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
|
// Adapted (mostly copied) the work of https://github.com/fregante
|
|||
|
// Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
|
|||
|
|
|||
|
type ReplacerCallback = (substring: string, ...args: any[]) => string
|
|||
|
|
|||
|
const INDENT = ' '
|
|||
|
|
|||
|
export default class TextAreaUtils {
|
|||
|
static insertTextFirefox(
|
|||
|
field: HTMLTextAreaElement | HTMLInputElement,
|
|||
|
text: string
|
|||
|
): void {
|
|||
|
// Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈
|
|||
|
field.setRangeText(
|
|||
|
text,
|
|||
|
field.selectionStart || 0,
|
|||
|
field.selectionEnd || 0,
|
|||
|
'end' // Without this, the cursor is either at the beginning or `text` remains selected
|
|||
|
)
|
|||
|
|
|||
|
field.dispatchEvent(
|
|||
|
new InputEvent('input', {
|
|||
|
data: text,
|
|||
|
inputType: 'insertText',
|
|||
|
isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required
|
|||
|
})
|
|||
|
)
|
|||
|
}
|
|||
|
|
|||
|
/** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
|
|||
|
static insert(
|
|||
|
field: HTMLTextAreaElement | HTMLInputElement,
|
|||
|
text: string
|
|||
|
): void {
|
|||
|
const document = field.ownerDocument
|
|||
|
const initialFocus = document.activeElement
|
|||
|
if (initialFocus !== field) {
|
|||
|
field.focus()
|
|||
|
}
|
|||
|
|
|||
|
if (!document.execCommand('insertText', false, text)) {
|
|||
|
TextAreaUtils.insertTextFirefox(field, text)
|
|||
|
}
|
|||
|
|
|||
|
if (initialFocus === document.body) {
|
|||
|
field.blur()
|
|||
|
} else if (initialFocus instanceof HTMLElement && initialFocus !== field) {
|
|||
|
initialFocus.focus()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
|
|||
|
static set(
|
|||
|
field: HTMLTextAreaElement | HTMLInputElement,
|
|||
|
text: string
|
|||
|
): void {
|
|||
|
field.select()
|
|||
|
TextAreaUtils.insert(field, text)
|
|||
|
}
|
|||
|
|
|||
|
/** Get the selected text in a field or an empty string if nothing is selected. */
|
|||
|
static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string {
|
|||
|
return field.value.slice(field.selectionStart, field.selectionEnd)
|
|||
|
}
|
|||
|
|
|||
|
/** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */
|
|||
|
static wrapSelection(
|
|||
|
field: HTMLTextAreaElement | HTMLInputElement,
|
|||
|
wrap: string,
|
|||
|
wrapEnd?: string
|
|||
|
): void {
|
|||
|
const { selectionStart, selectionEnd } = field
|
|||
|
const selection = TextAreaUtils.getSelection(field)
|
|||
|
TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap))
|
|||
|
|
|||
|
// Restore the selection around the previously-selected text
|
|||
|
field.selectionStart = selectionStart + wrap.length
|
|||
|
field.selectionEnd = selectionEnd + wrap.length
|
|||
|
}
|
|||
|
|
|||
|
/** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */
|
|||
|
static replace(
|
|||
|
field: HTMLTextAreaElement | HTMLInputElement,
|
|||
|
searchValue: string | RegExp,
|
|||
|
replacer: string | ReplacerCallback
|
|||
|
): void {
|
|||
|
/** Remembers how much each match offset should be adjusted */
|
|||
|
let drift = 0
|
|||
|
|
|||
|
field.value.replace(searchValue, (...args): string => {
|
|||
|
// Select current match to replace it later
|
|||
|
const matchStart = drift + (args[args.length - 2] as number)
|
|||
|
const matchLength = args[0].length
|
|||
|
field.selectionStart = matchStart
|
|||
|
field.selectionEnd = matchStart + matchLength
|
|||
|
|
|||
|
const replacement =
|
|||
|
typeof replacer === 'string' ? replacer : replacer(...args)
|
|||
|
TextAreaUtils.insert(field, replacement)
|
|||
|
|
|||
|
// Select replacement. Without this, the cursor would be after the replacement
|
|||
|
field.selectionStart = matchStart
|
|||
|
drift += replacement.length - matchLength
|
|||
|
return replacement
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
static findLineEnd(value: string, currentEnd: number): number {
|
|||
|
// Go to the beginning of the last line
|
|||
|
const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1
|
|||
|
|
|||
|
// There's nothing to unindent after the last cursor, so leave it as is
|
|||
|
if (value.charAt(lastLineStart) !== '\t') {
|
|||
|
return currentEnd
|
|||
|
}
|
|||
|
|
|||
|
return lastLineStart + 1 // Include the first character, which will be a tab
|
|||
|
}
|
|||
|
|
|||
|
static indent(element: HTMLTextAreaElement): void {
|
|||
|
const { selectionStart, selectionEnd, value } = element
|
|||
|
const selectedText = value.slice(selectionStart, selectionEnd)
|
|||
|
// The first line should be indented, even if it starts with `\n`
|
|||
|
// The last line should only be indented if includes any character after `\n`
|
|||
|
const lineBreakCount = /\n/g.exec(selectedText)?.length
|
|||
|
|
|||
|
if (lineBreakCount > 0) {
|
|||
|
// Select full first line to replace everything at once
|
|||
|
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
|||
|
|
|||
|
const newSelection = element.value.slice(firstLineStart, selectionEnd - 1)
|
|||
|
const indentedText = newSelection.replace(
|
|||
|
/^|\n/g, // Match all line starts
|
|||
|
`$&${INDENT}`
|
|||
|
)
|
|||
|
const replacementsCount = indentedText.length - newSelection.length
|
|||
|
|
|||
|
// Replace newSelection with indentedText
|
|||
|
element.setSelectionRange(firstLineStart, selectionEnd - 1)
|
|||
|
TextAreaUtils.insert(element, indentedText)
|
|||
|
|
|||
|
// Restore selection position, including the indentation
|
|||
|
element.setSelectionRange(
|
|||
|
selectionStart + 1,
|
|||
|
selectionEnd + replacementsCount
|
|||
|
)
|
|||
|
} else {
|
|||
|
TextAreaUtils.insert(element, INDENT)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// The first line should always be unindented
|
|||
|
// The last line should only be unindented if the selection includes any characters after `\n`
|
|||
|
static unindent(element: HTMLTextAreaElement): void {
|
|||
|
const { selectionStart, selectionEnd, value } = element
|
|||
|
|
|||
|
// Select the whole first line because it might contain \t
|
|||
|
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
|||
|
const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd)
|
|||
|
|
|||
|
const newSelection = element.value.slice(
|
|||
|
firstLineStart,
|
|||
|
minimumSelectionEnd
|
|||
|
)
|
|||
|
const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1')
|
|||
|
const replacementsCount = newSelection.length - indentedText.length
|
|||
|
|
|||
|
// Replace newSelection with indentedText
|
|||
|
element.setSelectionRange(firstLineStart, minimumSelectionEnd)
|
|||
|
TextAreaUtils.insert(element, indentedText)
|
|||
|
|
|||
|
// Restore selection position, including the indentation
|
|||
|
const firstLineIndentation = /\t| {1,2}/.exec(
|
|||
|
value.slice(firstLineStart, selectionStart)
|
|||
|
)
|
|||
|
|
|||
|
const difference = firstLineIndentation ? firstLineIndentation[0].length : 0
|
|||
|
|
|||
|
const newSelectionStart = selectionStart - difference
|
|||
|
element.setSelectionRange(
|
|||
|
selectionStart - difference,
|
|||
|
Math.max(newSelectionStart, selectionEnd - replacementsCount)
|
|||
|
)
|
|||
|
}
|
|||
|
}
|