carta-md
Version:
A lightweight, fully customizable, Markdown editor
478 lines (477 loc) • 20.2 kB
JavaScript
import { TextAreaHistory as TextAreaHistory } from './history';
import { areEqualSets } from './utils';
export class InputEnhancer {
textarea;
container;
settings;
pressedKeys;
escapePressed = false;
// Used to detect keys that actually changed the textarea value
onKeyDownValue;
history;
events = new EventTarget();
constructor(textarea, container, settings) {
this.textarea = textarea;
this.container = container;
this.settings = settings;
this.pressedKeys = new Set();
textarea.addEventListener('keydown', this.handleKeyDown.bind(this));
textarea.addEventListener('keyup', this.handleKeyUp.bind(this));
textarea.addEventListener('focus', () => {
this.pressedKeys.clear();
this.escapePressed = false;
});
textarea.addEventListener('blur', () => {
this.pressedKeys.clear();
});
textarea.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.history = new TextAreaHistory(settings.historyOpts);
// Save initial value
this.history.saveState(this.textarea.value, this.textarea.selectionStart);
// Register listeners
for (const listener of settings.listeners)
textarea.addEventListener(...listener);
}
isWordCharacter(char) {
return new RegExp(/^[a-zA-Z0-9_\-']*$/).test(char);
}
handleMouseDown(e) {
const cursor = this.getSelection().start;
const currentChar = this.textarea.value.at(cursor);
// Prevent the browser from selecting the space after the word on double clicks, if any
if (e.detail == 2 && currentChar != '\n' && currentChar != ' ') {
// Do not use e.preventDefault() as it will also capture following drag events
requestAnimationFrame(() => {
// Select the actual word/special chars
const isWordChar = this.isWordCharacter(this.textarea.value[cursor]);
let startPosition = cursor, endPosition = cursor;
while (startPosition >= 0 &&
this.isWordCharacter(this.textarea.value[startPosition]) == isWordChar &&
this.textarea.value[startPosition] != ' ')
startPosition--;
while (endPosition < this.textarea.value.length &&
this.isWordCharacter(this.textarea.value[endPosition]) == isWordChar &&
this.textarea.value[endPosition] != ' ')
endPosition++;
this.textarea.setSelectionRange(startPosition + 1, endPosition);
});
}
}
handleKeyDown(e) {
const key = e.key.toLowerCase();
this.pressedKeys.add(key);
// Check for shortcuts
const shortcuts = this.settings.shortcuts.filter((shortcut) => areEqualSets(this.pressedKeys, shortcut.combination));
if (shortcuts.length > 0) {
e.preventDefault();
if (shortcuts.length > 1) {
console.warn(`[carta] Multiple keyboard shortcuts have the same the combination: ${this.pressedKeys}`);
}
// Execute all the shortcuts
for (const shortcut of shortcuts) {
shortcut.action(this);
// Save state for shortcuts
if (!shortcut.preventSave)
this.history.saveState(this.textarea.value, this.textarea.selectionStart);
this.update();
}
this.onKeyDownValue = undefined;
return;
}
// On newline
if (key === 'enter') {
// Check prefixes
this.handleNewLine(e);
}
else if (key == 'tab' && !this.escapePressed && !this.settings.disableTabCapture) {
e.preventDefault(); // Don't select other stuff
// Check for tab-outs
let matchedDelimiter = null;
const tabOutsDelimiters = this.settings.tabOuts.map((tabOut) => tabOut.delimiter).flat();
for (const delimiter of tabOutsDelimiters) {
if (this.textarea.value.slice(this.textarea.selectionEnd, this.textarea.selectionEnd + delimiter.length) === delimiter) {
matchedDelimiter = delimiter;
break;
}
}
if (matchedDelimiter) {
const cursor = this.textarea.selectionEnd + matchedDelimiter.length;
this.textarea.setSelectionRange(cursor, cursor);
}
else {
// Indentation
if (e.shiftKey) {
// Unindent
const line = this.getLine();
const lineStart = line.start;
const lineContent = line.value;
const position = this.textarea.selectionStart;
// Check if the line starts with a tab
if (lineContent.startsWith('\t')) {
// Remove the tab
this.removeAt(lineStart, 1);
this.textarea.selectionStart = position - 1;
this.textarea.selectionEnd = position - 1;
}
}
else {
// Indent
const position = this.textarea.selectionStart;
this.insertAt(this.textarea.selectionStart, '\t');
this.textarea.selectionStart = position + 1;
this.textarea.selectionEnd = position + 1;
}
this.update();
}
}
else if (key === 'escape') {
this.escapePressed = true;
}
this.onKeyDownValue = this.textarea.value;
}
handleKeyUp(e) {
const key = e.key.toLowerCase();
this.pressedKeys.delete(key);
if (this.onKeyDownValue !== undefined && this.textarea.value != this.onKeyDownValue) {
this.history.saveState(this.textarea.value, this.textarea.selectionStart);
}
}
handleNewLine(e) {
const cursor = this.textarea.selectionStart;
// Get all the line
let lineStartingIndex;
for (lineStartingIndex = cursor; lineStartingIndex > 0 && this.textarea.value.at(lineStartingIndex - 1) !== '\n'; lineStartingIndex--)
;
const line = this.textarea.value.slice(lineStartingIndex, cursor);
for (const prefix of this.settings.prefixes) {
const match = prefix.match(line);
if (match) {
e.preventDefault();
const strMatch = Array.isArray(match) ? match[0] : match;
// Check if anything was typed.
// If not, remove the prefix.
const content = line.slice(strMatch.length).trim();
if (content === '') {
const line = this.getLine(lineStartingIndex);
this.removeAt(lineStartingIndex, line.value.length);
this.textarea.setSelectionRange(line.start, line.start);
this.update();
return;
}
const newPrefix = prefix.maker(match, line);
this.insertAt(cursor, '\n' + newPrefix);
this.update();
// Update cursor position
const newCursorPosition = cursor + newPrefix.length + 1;
this.textarea.setSelectionRange(newCursorPosition, newCursorPosition);
break;
}
}
}
/**
* Get the selected text data.
* @returns The selection text data.
*/
getSelection() {
const start = this.textarea.selectionStart;
const end = this.textarea.selectionEnd;
return {
start,
end,
direction: this.textarea.selectionDirection,
slice: this.textarea.value.slice(start, end)
};
}
/**
* Get the current line, along with indices information.
* @returns Current line info.
*/
getLine(index = this.textarea.selectionStart) {
let lineStartingIndex, lineEndingIndex;
for (lineStartingIndex = index; lineStartingIndex > 0 && this.textarea.value.at(lineStartingIndex - 1) !== '\n'; lineStartingIndex--)
;
for (lineEndingIndex = index; lineEndingIndex < this.textarea.value.length - 1 &&
this.textarea.value.at(lineEndingIndex) !== '\n'; lineEndingIndex++)
;
return {
start: lineStartingIndex,
end: lineEndingIndex,
value: this.textarea.value.slice(lineStartingIndex, lineEndingIndex)
};
}
/**
* Insert a string at a specific index.
* @param position The position at which to insert the string.
* @param string The string to insert.
*/
insertAt(position, string) {
const value = this.textarea.value;
this.textarea.value = value.slice(0, position) + string + value.slice(position);
}
/**
* Remove `count` characters at the provided position.
* @param position The position to remove characters at.
* @param count The number of characters to remove.
*/
removeAt(position, count = 1) {
const value = this.textarea.value;
this.textarea.value = value.slice(0, position) + value.slice(position + count);
}
/**
* Surround the current selection with a delimiter.
* @param delimiter The string delimiter.
*/
toggleSelectionSurrounding(delimiter) {
const selection = this.getSelection();
const delimiterLeft = Array.isArray(delimiter) ? delimiter[0] : delimiter;
const delimiterRight = Array.isArray(delimiter) ? delimiter[1] : delimiter;
const prevSection = this.textarea.value.slice(selection.start - delimiterLeft.length, selection.start);
const nextSection = this.textarea.value.slice(selection.end, selection.end + delimiterRight.length);
if (prevSection === delimiterLeft && nextSection === delimiterRight) {
this.removeAt(selection.end, delimiterRight.length);
this.removeAt(selection.start - delimiterLeft.length, delimiterLeft.length);
this.textarea.setSelectionRange(selection.start - delimiterRight.length, selection.end - delimiterRight.length);
}
else {
this.insertAt(selection.end, delimiterRight);
this.insertAt(selection.start, delimiterLeft);
this.textarea.setSelectionRange(selection.start + delimiterLeft.length, selection.end + delimiterLeft.length);
}
}
/**
* Toggle a prefix for the current line.
* @param prefix The string prefix.
* @param whitespace Whether to handle whitespace separations.
*/
toggleLinePrefix(prefix, whitespace = 'attach') {
const selection = this.getSelection();
let index = selection.start;
while (index > 0 && this.textarea.value.at(index - 1) !== '\n')
index--;
let furtherLinesExist = true;
const startLocation = selection.start;
let endLocation = selection.end;
while (furtherLinesExist) {
const currentPrefix = this.textarea.value.slice(index, index + prefix.length);
if (currentPrefix === prefix) {
if (whitespace === 'attach' && this.textarea.value.at(index + prefix.length) === ' ') {
this.removeAt(index, prefix.length + 1);
endLocation -= prefix.length + 1;
}
else {
this.removeAt(index, prefix.length);
endLocation -= prefix.length;
}
}
else {
if (whitespace === 'attach') {
this.insertAt(index, prefix + ' ');
endLocation += prefix.length + 1;
}
else {
this.insertAt(index, prefix);
endLocation += prefix.length;
}
}
while (index < this.textarea.value.length && this.textarea.value.at(index) !== '\n')
index++;
if (this.textarea.value.at(index) == '\n')
index++;
furtherLinesExist = index < endLocation;
}
this.textarea.setSelectionRange(startLocation, endLocation);
}
/**
* Update the textarea.
*/
update = () => this.events.dispatchEvent(new Event('update'));
/**
* Returns x, y coordinates for absolute positioning of a span within a given text input
* at a given selection point. [Source](https://jh3y.medium.com/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a)
* @param selectionPoint The selection point for the input. Defaults at current cursor position.
*/
getCursorXY(selectionPoint = this.textarea.selectionStart) {
const { offsetLeft: inputX, offsetTop: inputY } = this.textarea;
const div = document.createElement('div');
const copyStyle = getComputedStyle(this.textarea);
for (const prop of copyStyle) {
// This monstrosity is needed to prevent linting errors...
div.style[prop] = copyStyle[prop];
}
const swap = '.';
const inputValue = this.textarea.tagName === 'INPUT'
? this.textarea.value.replace(/ /g, swap)
: this.textarea.value;
const textContent = inputValue.substr(0, selectionPoint);
div.textContent = textContent;
if (this.textarea.tagName === 'TEXTAREA')
div.style.height = 'auto';
if (this.textarea.tagName === 'INPUT')
div.style.width = 'auto';
// Create an element to measure cursor size
const span = document.createElement('span');
span.className += 'carta-font-code';
span.textContent = inputValue.substr(selectionPoint) || '.';
div.appendChild(span);
document.body.appendChild(div);
const { offsetLeft: spanX, offsetTop: spanY } = span;
document.body.removeChild(div);
return {
x: inputX + spanX,
y: inputY + spanY,
left: inputX + spanX,
top: inputY + spanY,
right: this.textarea.clientWidth - inputX,
bottom: this.textarea.clientHeight - inputY
};
}
/**
* Moves an element next to the caret. Shall be called every time the element
* changes width, height or the caret position changes. Consider using `bindToCaret` instead.
*
* @example
* ```svelte
* <script>
* // ...
*
* export let carta;
*
* let caretPosition;
* let elem;
*
* onMount(() => {
* carta.input.addEventListener('input', handleInput);
* });
*
* onDestroy(() => {
* carta.input.removeEventListener('input', handleInput);
* });
*
* function handleInput() {
* caretPosition = carta.input.getCursorXY();
* }
*
* $: {
* caretPosition, elem.clientWidth, elem.clientHeight;
* carta.input.moveElemToCaret(elem);
* }
* </script>
*
* <div bind:this={elem}>
* <!-- My stuff -->
* </div>
* ```
*
* @param elem The element to move.
*/
moveElemToCaret(elem) {
const elemWidth = elem.clientWidth;
const elemHeight = elem.clientHeight;
const caretPosition = this.getCursorXY();
const fontSize = this.getRowHeight();
// Left/Right
let left = caretPosition.left;
let right;
if (elemWidth < this.container.clientWidth &&
left + elemWidth - this.container.scrollLeft >= this.container.clientWidth) {
right = this.container.clientWidth - left;
left = undefined;
}
// Top/Bottom
let top = caretPosition.top;
let bottom;
if (elemHeight < this.container.clientHeight &&
top + elemHeight - this.container.scrollTop >= this.container.clientHeight) {
bottom = this.container.clientHeight - top;
top = undefined;
}
elem.style.left = left !== undefined ? left + 'px' : 'unset';
elem.style.right = right !== undefined ? right + 'px' : 'unset';
elem.style.top = top !== undefined ? top + fontSize + 'px' : 'unset';
elem.style.bottom = bottom !== undefined ? bottom + 'px' : 'unset';
}
/**
* **Internal**: Svelte action to bind an element to the caret position.
* Use `bindToCaret` from the `carta` instance instead.
* @param elem The element to position.
* @param portal The portal to append the element to. Defaults to `document.body`.
*/
$bindToCaret(elem, data) {
// Move the element to body
data.portal.appendChild(elem);
// Add theme class as the the teleported element is not a child of the container
const themeClass = Array.from(data.editorElement?.classList ?? []).find((c) => c.startsWith('carta-theme__'));
elem.classList.add(themeClass ?? 'carta-theme__default');
elem.style.position = 'fixed';
const callback = () => {
const relativePosition = this.getCursorXY();
const absolutePosition = {
x: relativePosition.x + this.textarea.getBoundingClientRect().left,
y: relativePosition.y + this.textarea.getBoundingClientRect().top
};
const fontSize = this.getRowHeight();
const width = elem.clientWidth;
const height = elem.clientHeight;
// Left/Right
let left = absolutePosition.x;
let right;
if (left + width >= window.innerWidth) {
right = window.innerWidth - left;
left = undefined;
}
// Top/Bottom
let top = absolutePosition.y;
let bottom;
if (top + height >= window.innerHeight) {
bottom = window.innerHeight - top;
top = undefined;
}
elem.style.left = left !== undefined ? left + 'px' : 'unset';
elem.style.right = right !== undefined ? right + 'px' : 'unset';
elem.style.top = top !== undefined ? top + fontSize + 'px' : 'unset';
elem.style.bottom = bottom !== undefined ? bottom + 'px' : 'unset';
};
this.textarea.addEventListener('input', callback);
window.addEventListener('resize', callback);
window.addEventListener('scroll', callback);
// Initial positioning
callback();
return {
destroy: () => {
try {
data.portal.removeChild(elem);
}
catch {
// Ignore
}
this.textarea.removeEventListener('input', callback);
window.removeEventListener('resize', callback);
window.removeEventListener('scroll', callback);
}
};
}
/**
* Get rough value for a row of the textarea.
*/
getRowHeight() {
// Turns out calculating line height is quite tricky
const rawLineHeight = getComputedStyle(this.container).lineHeight;
const lineHeight = parseFloat(rawLineHeight);
const fontSize = parseFloat(getComputedStyle(this.container).fontSize);
if (isNaN(lineHeight)) {
// "normal" => use default 1.2 value for all modern browser
return Math.ceil(fontSize * 1.2);
}
if (rawLineHeight.endsWith('em')) {
return Math.ceil(lineHeight * fontSize);
}
if (rawLineHeight.endsWith('%')) {
return Math.ceil((lineHeight / 100) * fontSize);
}
if (rawLineHeight.endsWith('px')) {
return Math.ceil(lineHeight);
}
// Line height can also be a multiplier of the font size
return Math.ceil(fontSize * lineHeight);
}
}