UNPKG

drab

Version:

Interactivity for You

481 lines (480 loc) 19.1 kB
import { Base } from "../base/index.js"; /** * Enhances the `textarea` element with controls to add content and keyboard shortcuts. Compared to other WYSIWYG editors, the `text` value is just a `string`, so you can easily store it in a database or manipulate it without learning a separate API. * * `data-value` * * Set the value of the text to be inserted using the `data-value` attribute on the `trigger`. * * `data-type` * * Set the `data-type` attribute of the `trigger` to specify how the content should be inserted into the `textarea`. * * - `block` will be inserted at the beginning of the selected line. * - `wrap` will be inserted before and after the current selection. * - `inline` will be inserted at the current selection. * * `data-key` * * Add a `ctrl`/`meta` keyboard shortcut for the content based on the `data-key` attribute. * * Other features: * * - Automatically adds closing characters for `keyPairs`. For example, when typing `(`, `)` will be inserted and typed over when reached. All content with `data-type="wrap"` is also added to `keyPairs`. * - Highlights the first word of the text inserted if it contains letters. * - Automatically increments/decrements ordered lists. * - Adds the starting character to the next line for `block` content. * - On double click, highlight is corrected to only highlight the current word without space around it. * - `tab` key will indent and not change focus if the selection is within a code block (three backticks). * - When text is highlighted and a `wrap` character `keyPair` is typed, the highlighted text will be wrapped with the character instead of removing it. For example, if a word is highlighted and the `"` character is typed, the work will be surrounded by `"`s. */ export class Editor extends Base { /** Array of keyPair characters that have been opened. */ #openChars = []; /** The characters that will be automatically closed when typed. */ keyPairs = { "(": ")", "{": "}", "[": "]", "<": ">", '"': '"', "`": "`", }; constructor() { super(); // add any `type: "wrap"` values from `contentElements` to `keyPairs` for (const element of this.#contentElements) { if (element.type === "wrap") { this.keyPairs[element.value] = element.value; } } } /** The `content`, expects an `HTMLTextAreaElement`. */ get textArea() { return this.getContent(HTMLTextAreaElement); } /** The current `value` of the `textarea`. */ get text() { return this.textArea.value; } set text(value) { this.textArea.value = value; } /** An array of `ContentElement`s derived from each `trigger`'s data attributes. */ get #contentElements() { const contentElements = []; for (const trigger of this.getTrigger()) { contentElements.push(this.#getContentElement(trigger)); } return contentElements; } /** * - splits the content by "```" and finds the current index * of the selectionStart * * @returns current codeblock (index) of selectionStart */ get #currentBlock() { const blocks = this.text.split("```"); let totalChars = 0; for (const [i, block] of blocks.entries()) { totalChars += block.length + 3; if (this.#selectionStart < totalChars) { return i; } } return 0; } /** Gets the end position of the selection */ get #selectionEnd() { return this.textArea.selectionEnd; } /** Gets the start position of the selection. */ get #selectionStart() { return this.textArea.selectionStart; } /** Sets the current cursor selection in the `textarea` */ #setSelectionRange(start, end) { this.textArea.setSelectionRange(start, end); } /** * @param trigger The trigger html element. * @returns The ContentElement based on the `trigger`'s attributes. */ #getContentElement(trigger) { const type = trigger.dataset.type; const value = trigger.dataset.value; const key = trigger.dataset.key ?? undefined; return { type, value, key }; } /** * - Inserts text into the `textarea` based on the `display` property of * the `ContentElement`. * * @param el the content element * @param selectionStart current start position the selection * @param selectionEnd current end position of the selection */ async #insertText(el, selectionStart, selectionEnd) { if (el.type === "inline") { // insert at current position this.text = `${this.text.slice(0, selectionEnd)}${el.value}${this.text.slice(selectionEnd)}`; } else if (el.type === "wrap") { this.text = insertChar(this.text, el.value, selectionStart); this.text = insertChar(this.text, this.keyPairs[el.value], selectionEnd + el.value.length); // if single char, add to opened if (el.value.length < 2) this.#openChars.push(el.value); } else if (el.type === "block") { const { lines, lineNumber } = this.#getLineInfo(); const firstChar = el.value.at(0); // add the string to the beginning of the line if (firstChar && lines[lineNumber]?.startsWith(firstChar)) { // avoids `# # # `, instead adds trimmed => `### ` lines[lineNumber] = el.value.trim() + lines[lineNumber]; } else { lines[lineNumber] = el.value + lines[lineNumber]; } this.text = lines.join("\n"); } } /** * - Sets the caret position after text is inserted based on * the length of the text. * - Highlights text if the content contains any letters. * * @param text * @param selectionStart current start position the selection * @param selectionEnd current end position of the selection */ async #setCaretPosition(text, selectionStart, selectionEnd) { let startPos = 0; let endPos = 0; if (/[a-z]/i.test(text)) { // if string contains letters, highlight the first word for (let i = selectionEnd; i < this.text.length; i++) { if (this.text[i]?.match(/[a-z]/i)) { if (!startPos) { startPos = i; } else { endPos = i + 1; } } else if (startPos) { break; } } } else { // leave the cursor in place startPos = selectionStart + text.length; endPos = selectionEnd + text.length; } this.#setSelectionRange(startPos, endPos); this.textArea.focus(); } /** * - Inserts the text and then sets the caret position * based on the `ContentElement` selected. * * @param el selected content element */ async #addContent(el) { const selectionEnd = this.#selectionEnd; const selectionStart = this.#selectionStart; await this.#insertText(el, selectionStart, selectionEnd); this.#setCaretPosition(el.value, selectionStart, selectionEnd); } /** * - checks if there is a block element or a number * at the beginning of the string * * @param str * @returns what is found, or the empty string */ #getRepeat(str) { if (str) { const blockStrings = []; this.#contentElements.forEach((el) => { if (el.type === "block") blockStrings.push(el.value); }); for (let i = 0; i < blockStrings.length; i++) { const repeatString = blockStrings[i]; if (repeatString && str.startsWith(repeatString)) { return repeatString; } } const repeatNum = startsWithNumberAndPeriod(str); if (repeatNum) return `${repeatNum}. `; } return ""; } /** * @returns lines as an array, current line number, current column number * * @example * * ```js * const { lines, lineNumber, columnNumber } = getLineInfo(); * ``` */ #getLineInfo() { const lines = this.text.split("\n"); let characterCount = 0; for (let i = 0; i < lines.length; i++) { const lineLength = lines.at(i)?.length ?? 0; // for each line characterCount++; // account for removed "\n" due to .split() characterCount += lineLength; // find the line that the cursor is on if (characterCount > this.#selectionEnd) { return { lines, lineNumber: i, columnNumber: this.#selectionEnd - (characterCount - lineLength - 1), }; } } return { lines, lineNumber: 0, columnNumber: 0 }; } /** * - Increments/decrements the start of following lines if they are numbers * * Prevents this: * * ``` * 1. presses enter here when two items in list * 2. * 2. * ``` * * Instead: * * ``` * 1. * 2. * 3. * ``` * * @param currentLineNumber * @param decrement if following lines should be decremented instead of incremented */ #correctFollowing(currentLineNumber, decrement = false) { const { lines } = this.#getLineInfo(); for (let i = currentLineNumber + 1; i < lines.length; i++) { const line = lines[i]; if (line) { const num = startsWithNumberAndPeriod(line); if (!num) { break; } else { let newNum; if (decrement) { if (num > 1) { newNum = num - 1; } else { break; } } else { newNum = num + 1; } lines[i] = line.slice(String(num).length); // remove number from start lines[i] = String(newNum) + lines[i]; } } } this.text = lines.join("\n"); } mount() { this.textArea.addEventListener("keydown", async (e) => { // these keys will reset the type over for characters like " const resetKeys = ["ArrowUp", "ArrowDown", "Delete"]; const nextChar = this.text[this.#selectionEnd] ?? ""; if (resetKeys.includes(e.key)) { // reset this.#openChars = []; } else if (e.key === "Backspace") { const prevChar = this.text[this.#selectionStart - 1]; if (prevChar && prevChar in this.keyPairs && nextChar === this.keyPairs[prevChar]) { // remove both characters if the next one is the match of the prev e.preventDefault(); const start = this.#selectionStart - 1; const end = this.#selectionEnd - 1; this.text = removeChar(this.text, start); this.text = removeChar(this.text, end); setTimeout(() => { this.#setSelectionRange(start, end); }, 0); this.#openChars.pop(); } if (prevChar === "\n" && this.#selectionStart === this.#selectionEnd) { // see `correctFollowing` e.preventDefault(); const newPos = this.#selectionStart - 1; const { lineNumber } = this.#getLineInfo(); this.#correctFollowing(lineNumber, true); this.text = removeChar(this.text, newPos); setTimeout(async () => { this.#setSelectionRange(newPos, newPos); }, 0); } } else if (e.key === "Tab") { if (this.#currentBlock % 2 !== 0) { // if caret is inside of a codeblock, indent e.preventDefault(); await this.#addContent({ type: "inline", value: "\t", }); } } else if (e.key === "Enter") { // autocomplete start of next line if block or number const { lines, lineNumber, columnNumber } = this.#getLineInfo(); const currentLine = lines.at(lineNumber); let repeat = this.#getRepeat(currentLine); const original = repeat; const num = startsWithNumberAndPeriod(repeat); // line starts with number and period? - increment if (num) repeat = `${num + 1}. `; if (repeat && original.length < columnNumber) { e.preventDefault(); if (num) this.#correctFollowing(lineNumber); await this.#addContent({ type: "inline", value: `\n${repeat}`, }); } else if (repeat && original.length === columnNumber) { // remove if the repeat and caret at the end of the original e.preventDefault(); // have to set a placeholder since `this.#selectionEnd` will change // as characters are being removed const originalSelectionEnd = this.#selectionEnd; // go back the the length of the original const newPos = originalSelectionEnd - original.length; // for each character in the original for (let i = 0; i < original.length; i++) { this.text = removeChar(this.text, originalSelectionEnd - (i + 1)); } setTimeout(async () => { this.#setSelectionRange(newPos, newPos); this.textArea.focus(); await this.#addContent({ type: "inline", value: `\n`, }); }, 0); } } else { const nextCharIsClosing = Object.values(this.keyPairs).includes(nextChar); const highlighted = this.#selectionStart !== this.#selectionEnd; if (e.ctrlKey || e.metaKey) { if (this.#selectionStart === this.#selectionEnd) { // no selection if (e.key === "c" || e.key === "x") { // copy or cut entire line e.preventDefault(); const { lines, lineNumber, columnNumber } = this.#getLineInfo(); await navigator.clipboard.writeText(`${lineNumber === 0 && e.key === "x" ? "" : "\n"}${lines[lineNumber]}`); if (e.key === "x") { const newPos = this.#selectionStart - columnNumber; lines.splice(lineNumber, 1); this.text = lines.join("\n"); setTimeout(() => { this.#setSelectionRange(newPos, newPos); }, 0); } } } } if ((e.ctrlKey || e.metaKey) && e.key) { // keyboard shortcut const matchedEl = this.#contentElements.find((el) => el.key === e.key); if (matchedEl) this.#addContent(matchedEl); } else if (nextCharIsClosing && (nextChar === e.key || e.key === "ArrowRight") && this.#openChars.length && !highlighted) { // type over the next character instead of inserting e.preventDefault(); this.#setSelectionRange(this.#selectionStart + 1, this.#selectionEnd + 1); this.#openChars.pop(); } else if (e.key in this.keyPairs) { e.preventDefault(); await this.#addContent({ type: "wrap", value: e.key, }); this.#openChars.push(e.key); } } }); // trims the selection if there is an extra space around it this.textArea.addEventListener("dblclick", () => { if (this.#selectionStart !== this.#selectionEnd) { if (this.text[this.#selectionStart] === " ") { this.#setSelectionRange(this.#selectionStart + 1, this.#selectionEnd); } if (this.text[this.#selectionEnd - 1] === " ") { this.#setSelectionRange(this.#selectionStart, this.#selectionEnd - 1); } } }); // reset #openChars on click since the cursor has changed position this.textArea.addEventListener("click", () => (this.#openChars = [])); for (const trigger of this.getTrigger()) { trigger.addEventListener(this.event, () => { this.#addContent(this.#getContentElement(trigger)); }); } } } /** * @param str * @returns the number, if the string starts with a number and a period */ const startsWithNumberAndPeriod = (str) => { const result = str.match(/^(\d+)\./); return result ? Number(result[1]) : null; }; /** * - insert character into string at index * * @param str string to insert into * @param char characters to insert into `str` * @param index where to insert the characters * @returns the new string */ const insertChar = (str, char, index) => { return str.slice(0, index) + char + str.slice(index); }; /** * - remove char from string at index * * @param str string to remove the character from * @param index index of character to remove * @returns the new string */ const removeChar = (str, index) => { return str.slice(0, index) + str.slice(index + 1); };