drab
Version:
Interactivity for You
449 lines (382 loc) • 12.4 kB
text/typescript
import {
Content,
type ContentAttributes,
Lifecycle,
Trigger,
type TriggerAttributes,
} from "../base/index.js";
export interface EditorAttributes
extends TriggerAttributes,
ContentAttributes {}
export interface EditorTriggerAttributes {
"data-value": string;
"data-key": string;
"data-type": "block" | "wrap" | "inline";
}
/**
* A piece of content to insert into the `textarea`.
*/
export type ContentElement = {
/** How to insert the content. */
type: "block" | "inline" | "wrap";
/** The value to insert. */
value: string;
/** An optional keyboard shortcut. */
key?: string;
};
/**
* 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.
*
* - 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 or dedent (+shift) instead of focus change 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.
*
* ### Trigger attributes
*
* `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.
*
*/
export class Editor extends Lifecycle(Trigger(Content())) {
/** Array of `keyPair` characters that have been opened. */
#openChars: string[] = [];
/** Keys that will reset the type over for keyPairs */
#resetKeys = new Set(["ArrowUp", "ArrowDown", "Delete"]);
#inputEvent = new Event("input", { bubbles: true, cancelable: true });
/** Characters that will be automatically closed when typed. */
#keyPairs: Record<string, string> = {
"(": ")",
"{": "}",
"[": "]",
"<": ">",
'"': '"',
"`": "`",
};
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.content(HTMLTextAreaElement);
}
/** The current `value` of the `textarea`. */
get #text() {
return this.#textArea.value;
}
set #text(value) {
this.#textArea.value = value;
this.#textArea.dispatchEvent(this.#inputEvent);
}
/** Array of `ContentElement`s derived from each `trigger`'s data attributes. */
get #contentElements() {
const contentElements: ContentElement[] = [];
for (const trigger of this.triggers()) {
contentElements.push(trigger.dataset as ContentElement);
}
return contentElements;
}
/** Gets the end position of the selection */
get #selEnd() {
return this.#textArea.selectionEnd;
}
/** Gets the start position of the selection. */
get #selStart() {
return this.#textArea.selectionStart;
}
/**
* @param str string to insert into `text`
* @param index where to insert the string
*/
#insertStr(str: string, index: number) {
this.#text = this.#text.slice(0, index) + str + this.#text.slice(index);
}
/**
* @param start Starting index for removal.
* @param end Optional ending index - defaults to start + 1 to remove 1 character.
*/
#removeStr(start: number, end = start + 1) {
this.#text = this.#text.slice(0, start) + this.#text.slice(end);
}
/** Sets the current cursor selection in the `textarea` */
#setSelection(start: number, end = start) {
this.#textArea.setSelectionRange(start, end);
this.#textArea.focus();
}
/**
* Inserts text and sets selection based on the `ContentElement` selected.
*
* @param content
*/
#addContent({ value, type }: ContentElement) {
let start = this.#selStart;
if (type === "inline") {
// insert at current position
this.#insertStr(value, start);
const match = /[a-z]+/i.exec(value);
if (match?.index != null) {
start += match.index;
this.#setSelection(start, start + match[0].length);
} else {
this.#setSelection(start + value.length);
}
} else if (type === "wrap") {
const end = this.#selEnd + value.length;
this.#insertStr(value, start);
this.#insertStr(this.#keyPairs[value]!, end);
this.#setSelection(start + value.length, end);
// if single char, add to opened
if (value.length === 1) this.#openChars.push(value);
} else {
// "block"
const { lines, lineNumber } = this.#lineMeta();
// avoids `# # # `, instead adds trimmed => `### `
const firstChar = value[0];
if (firstChar && lines[lineNumber]?.startsWith(firstChar)) {
value = value.trim();
}
// add the string to the beginning of the line
lines[lineNumber] = value + lines[lineNumber];
this.#text = lines.join("\n");
this.#setSelection(start + value.length);
}
}
/**
* Checks if there is a block element at the beginning of the string.
*
* @param line
* @returns Whatever is found, otherwise null
*/
#startsWithBlock(line: string) {
for (const blockString of this.#contentElements
.filter((el) => el.type === "block")
.map((el) => el.value)) {
if (line.startsWith(blockString)) return blockString;
}
return null;
}
/**
* @param line
* @returns The number, if the line starts with a number and a period.
*/
#startsWithNumberAndPeriod(line: string) {
const match = line.match(/^(\d+)\./);
return match ? Number(match[1]) : null;
}
/**
* @returns Metadata describing the current position of the selection.
*/
#lineMeta() {
const lines = this.#text.split("\n");
let charCount = 0;
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber]!;
const len = line.length + 1; // account for removed "\n" due to .split()
charCount += len;
// find the line that the cursor is on
if (charCount > this.#selEnd) {
return {
line,
lines,
lineNumber,
columnNumber: this.#selEnd - (charCount - len),
};
}
}
return { line: lines[0]!, lines, lineNumber: 0, columnNumber: 0 };
}
/**
* Increments/decrements the start of following lines if they are numbers.
*
* @param decrement if following lines should be decremented instead of incremented
*
* @example
*
* ```md
* Prevents this, instead fixes the following lines.
*
* 1. presses enter here when two items in list
* 2.
* 2. (repeat of 2)
* ```
*/
#correctFollowing(decrement = false) {
let { lines, lineNumber } = this.#lineMeta();
for (; ++lineNumber < lines.length; ) {
let line = lines[lineNumber];
if (line) {
const num = this.#startsWithNumberAndPeriod(line);
if (num) {
let newNum: number;
if (decrement) {
if (num > 1) {
newNum = num - 1;
} else {
break;
}
} else {
newNum = num + 1;
}
lines[lineNumber] = newNum + line.slice(String(num).length);
} else {
break;
}
}
}
const start = this.#selStart;
this.#text = lines.join("\n");
this.#setSelection(start);
}
override mount() {
this.#textArea.addEventListener("keydown", (e) => {
const nextChar = this.#text[this.#selEnd] ?? "";
const notHighlighted = this.#selStart === this.#selEnd;
if (this.#resetKeys.has(e.key)) {
this.#openChars = [];
} else if (e.key === "Backspace") {
const prevChar = this.#text[this.#selStart - 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.#selStart - 1;
const end = this.#selEnd - 1;
this.#removeStr(start);
this.#removeStr(end);
this.#setSelection(start, end);
this.#openChars.pop();
} else if (prevChar === "\n" && this.#selStart === this.#selEnd) {
e.preventDefault();
const newPos = this.#selStart - 1;
this.#correctFollowing(true);
this.#removeStr(newPos);
this.#setSelection(newPos, newPos);
}
} else if (e.key === "Tab") {
const blocks = this.#text.split("```");
let totalChars = 0;
for (const [i, block] of blocks.entries()) {
totalChars += block.length + 3;
if (totalChars > this.#selStart) {
if (i % 2) {
// caret is inside of a codeblock
e.preventDefault();
if (e.shiftKey) {
const { line, columnNumber } = this.#lineMeta();
if (line.startsWith("\t")) {
// dedent
const start = this.#selStart;
this.#removeStr(start - columnNumber);
this.#setSelection(start - 1);
}
} else {
// indent
this.#addContent({ type: "inline", value: "\t" });
}
}
break;
}
}
} else if (e.key === "Enter" && notHighlighted) {
// autocomplete start of next line if block or number
const { line, columnNumber } = this.#lineMeta();
let repeat = this.#startsWithBlock(line);
if (!repeat) {
const num = this.#startsWithNumberAndPeriod(line);
if (num) repeat = `${num + 1}. `;
}
if (repeat) {
e.preventDefault();
if (repeat.length < columnNumber) {
// repeat same on next line
this.#addContent({ type: "inline", value: "\n" + repeat });
this.#correctFollowing();
} else {
// remove repeat from current line
const end = this.#selEnd;
const newPos = end - repeat.length;
this.#removeStr(newPos, end);
this.#setSelection(newPos);
}
}
} else if ((e.ctrlKey || e.metaKey) && e.key) {
if (notHighlighted && (e.key === "c" || e.key === "x")) {
// copy or cut entire line
e.preventDefault();
const { line, lines, lineNumber, columnNumber } = this.#lineMeta();
navigator.clipboard.writeText(line);
if (e.key === "x") {
const newPos = this.#selStart - columnNumber;
lines.splice(lineNumber, 1);
this.#text = lines.join("\n");
this.#setSelection(newPos, newPos);
}
}
const shortcut = this.#contentElements.find((el) => el.key === e.key);
if (shortcut) this.#addContent(shortcut);
} else if (
this.#openChars.length &&
notHighlighted &&
(nextChar === e.key || e.key === "ArrowRight") &&
Object.values(this.#keyPairs).includes(nextChar)
) {
// type over the next character instead of inserting
e.preventDefault();
this.#setSelection(this.#selStart + 1, this.#selEnd + 1);
this.#openChars.pop();
} else if (e.key in this.#keyPairs) {
e.preventDefault();
this.#addContent({ type: "wrap", value: e.key });
}
});
// trims the selection if there is an extra space around it
this.#textArea.addEventListener("dblclick", () => {
if (this.#selStart !== this.#selEnd) {
if (this.#text[this.#selStart] === " ") {
this.#setSelection(this.#selStart + 1, this.#selEnd);
}
if (this.#text[this.#selEnd - 1] === " ") {
this.#setSelection(this.#selStart, this.#selEnd - 1);
}
}
});
// reset #openChars on click since the cursor has changed position
this.#textArea.addEventListener("click", () => (this.#openChars = []));
this.listener((e) =>
this.#addContent(
(e.currentTarget as HTMLElement).dataset as ContentElement,
),
);
}
}