tldraw
Version:
A tiny little drawing editor.
212 lines (211 loc) • 8.45 kB
JavaScript
/*!
* MIT License
* Adapted (mostly copied) the work of https://github.com/fregante/text-field-edit
* Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
*/
const INDENT = " ";
class TextHelpers {
static insertTextFirefox(field, text) {
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, text) {
const document = field.ownerDocument;
const initialFocus = document.activeElement;
if (initialFocus !== field) {
field.focus();
}
if (!document.execCommand("insertText", false, text)) {
TextHelpers.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, text) {
field.select();
TextHelpers.insert(field, text);
}
/** Get the selected text in a field or an empty string if nothing is selected. */
static getSelection(field) {
const { selectionStart, selectionEnd } = field;
return field.value.slice(
selectionStart ? selectionStart : void 0,
selectionEnd ? selectionEnd : void 0
);
}
/**
* 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, wrap, wrapEnd) {
const { selectionStart, selectionEnd } = field;
const selection = TextHelpers.getSelection(field);
TextHelpers.insert(field, wrap + selection + (wrapEnd ?? wrap));
field.selectionStart = (selectionStart || 0) + wrap.length;
field.selectionEnd = (selectionEnd || 0) + wrap.length;
}
/** Finds and replaces strings and regex in the field’s value. */
static replace(field, searchValue, replacer) {
let drift = 0;
field.value.replace(searchValue, (...args) => {
const matchStart = drift + args[args.length - 2];
const matchLength = args[0].length;
field.selectionStart = matchStart;
field.selectionEnd = matchStart + matchLength;
const replacement = typeof replacer === "string" ? replacer : replacer(...args);
TextHelpers.insert(field, replacement);
field.selectionStart = matchStart;
drift += replacement.length - matchLength;
return replacement;
});
}
static findLineEnd(value, currentEnd) {
const lastLineStart = value.lastIndexOf("\n", currentEnd - 1) + 1;
if (value.charAt(lastLineStart) !== " ") {
return currentEnd;
}
return lastLineStart + 1;
}
static indent(element) {
const { selectionStart, selectionEnd, value } = element;
const selectedContrast = value.slice(selectionStart, selectionEnd);
const lineBreakCount = /\n/g.exec(selectedContrast)?.length;
if (lineBreakCount && lineBreakCount > 0) {
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;
element.setSelectionRange(firstLineStart, selectionEnd - 1);
TextHelpers.insert(element, indentedText);
element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount);
} else {
TextHelpers.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) {
const { selectionStart, selectionEnd, value } = element;
const firstLineStart = value.lastIndexOf("\n", selectionStart - 1) + 1;
const minimumSelectionEnd = TextHelpers.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;
element.setSelectionRange(firstLineStart, minimumSelectionEnd);
TextHelpers.insert(element, indentedText);
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)
);
}
static indentCE(element) {
const selection = window.getSelection();
const value = element.innerText;
const selectionStart = getCaretIndex(element) ?? 0;
const selectionEnd = getCaretIndex(element) ?? 0;
const selectedContrast = value.slice(selectionStart, selectionEnd);
const lineBreakCount = /\n/g.exec(selectedContrast)?.length;
if (lineBreakCount && lineBreakCount > 0) {
const firstLineStart = value.lastIndexOf("\n", selectionStart - 1) + 1;
const newSelection = value.slice(firstLineStart, selectionEnd - 1);
const indentedText = newSelection.replace(
/^|\n/g,
// Match all line starts
`$&${INDENT}`
);
const replacementsCount = indentedText.length - newSelection.length;
if (selection) {
selection.setBaseAndExtent(
element,
selectionStart + 1,
element,
selectionEnd + replacementsCount
);
}
} else {
const selection2 = window.getSelection();
element.innerText = value.slice(0, selectionStart) + INDENT + value.slice(selectionStart);
selection2?.setBaseAndExtent(element, selectionStart + 1, element, selectionStart + 2);
}
}
static unindentCE(element) {
const selection = window.getSelection();
const value = element.innerText;
const selectionStart = getCaretIndex(element) ?? 0;
const selectionEnd = getCaretIndex(element) ?? 0;
const firstLineStart = value.lastIndexOf("\n", selectionStart - 1) + 1;
const minimumSelectionEnd = TextHelpers.findLineEnd(value, selectionEnd);
const newSelection = value.slice(firstLineStart, minimumSelectionEnd);
const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, "$1");
const replacementsCount = newSelection.length - indentedText.length;
if (selection) {
selection.setBaseAndExtent(element, firstLineStart, element, minimumSelectionEnd);
const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart));
const difference = firstLineIndentation ? firstLineIndentation[0].length : 0;
const newSelectionStart = selectionStart - difference;
selection.setBaseAndExtent(
element,
selectionStart - difference,
element,
Math.max(newSelectionStart, selectionEnd - replacementsCount)
);
}
}
static fixNewLines = /\r?\n|\r/g;
static normalizeText(text) {
return text.replace(TextHelpers.fixNewLines, "\n");
}
static normalizeTextForDom(text) {
return text.replace(TextHelpers.fixNewLines, "\n").split("\n").map((x) => x || " ").join("\n");
}
}
function getCaretIndex(element) {
if (typeof window.getSelection === "undefined") return;
const selection = window.getSelection();
if (!selection) return;
let position = 0;
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
position = preCaretRange.toString().length;
}
return position;
}
export {
INDENT,
TextHelpers
};
//# sourceMappingURL=TextHelpers.mjs.map