import { html, LitElement, PropertyValueMap, render } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { filter, fromEvent } from "rxjs";
import { tagName as tableIconTag, TableIcon } from "./icons/table-icon.js";
import { tagName as linkIconTag, LinkIcon } from "./icons/link-icon.js";
import { loadComponent } from "./helpers/index.js";
import { globalStyles } from "./styles/index.js";
import formStyles from "./styles/form.js";
import { markdownStyles } from "./styles/markdown.js";
import { tagName as newPictureIconTagName, NewPictureIcon } from "./icons/new-picture-icon.js";
import { tagName as loadingIconTagName, LoadingIcon } from "./icons/loading-icon.js";
import { tagName as bulletListIconTag, BulletListIcon } from "./icons/bullet-list-icon.js";
import { tagName as numberListIconTag, NumberListIcon } from "./icons/number-list-icon.js";
export const tagName = "lit-markdown-editor";
/**
* A markdown editor text area built using Lit
*
* Usage in lit:
* @example import "lit-markdown-editor"
* ...
* render() {
* return html``
* }
*
* Adding event listeners:
* @example
*
*/
@customElement(tagName)
export class LitMarkdownEditor extends LitElement {
private markdownMap: Map;
private controller = new AbortController();
private internals: ReturnType["attachInternals"]> | null; // Not supported in safari.
static formAssociated = true;
@state()
protected loading = false;
@property({ attribute: "name" })
public name = "";
@property({ attribute: "minlength" })
public minlength = "";
@property({ attribute: "maxlength" })
public maxlength = "";
@query("textarea")
protected textarea!: HTMLTextAreaElement;
@query("input#add-file")
protected fileInput!: HTMLInputElement;
@query("li#b")
protected boldButton!: HTMLLIElement;
@query("li#i")
protected italicsButton!: HTMLLIElement;
#required = false;
@property({ attribute: "required", type: Boolean })
public get required() {
return this.#required;
}
public set required(newVal: boolean) {
this.#required = newVal;
this.internals && (this.internals.ariaRequired = String(this.required));
this.renderToLightDom();
}
/**
* Acts as an intermediate for this element to behave like a textarea.
*/
get value() {
return this.textarea?.value ?? "";
}
set value(value: string) {
if (typeof value !== "string") throw TypeError("Value must be string.");
if (!this.textarea) throw Error("Cannot set textarea value before render.");
this.textarea.value = value;
this.renderToLightDom();
this.triggerInputEvent();
}
constructor() {
super();
const elementInternalsSupported = "attachInternals" in this;
this.internals = elementInternalsSupported ? this.attachInternals() : null;
this.markdownMap = new Map([
["h1", "#"],
["h2", "##"],
["h3", "###"],
["h4", "####"],
["h5", "#####"],
["ul", " -"],
["ol", "1."],
["i", "_"],
["b", "**"],
["table", "| A | B |" + "\n" + "| --- | --- |" + "\n" + "| a | b |"],
]);
loadComponent(tableIconTag, TableIcon);
loadComponent(linkIconTag, LinkIcon);
loadComponent(newPictureIconTagName, NewPictureIcon);
loadComponent(loadingIconTagName, LoadingIcon);
loadComponent(bulletListIconTag, BulletListIcon);
loadComponent(numberListIconTag, NumberListIcon);
}
protected firstUpdated(_changedProperties: PropertyValueMap | Map): void {
super.firstUpdated(_changedProperties);
this.value = this.textContent ?? "";
this.renderToLightDom();
const textareaKeydown$ = fromEvent(this.textarea, "keydown");
const metaKeyDown$ = textareaKeydown$.pipe(filter((event) => !event.isComposing && event.metaKey));
const enterKeyDown$ = textareaKeydown$.pipe(filter((event) => event.key === "Enter"));
metaKeyDown$.subscribe(this.handleMetaKeydown);
enterKeyDown$.subscribe(this.handleEnterKeydown);
this.textarea.addEventListener(
"input",
() => {
this.renderToLightDom();
},
{ signal: this.controller.signal },
);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.controller.abort();
}
/**
* Appends text to textarea.
* Uses deprecated execCommand if available and defaults to substitution if fails.
*/
private appendTextToTextArea(textToAdd: string, selectionPadding = 1) {
const { selectionStart, selectionEnd, value } = this.textarea;
const newSelectionStart = selectionStart + selectionPadding;
const execCommandSupported = "execCommand" in document;
if (execCommandSupported) {
this.textarea.focus();
this.textarea.setSelectionRange(selectionStart, selectionEnd);
const succeeded = document.execCommand("insertText", false, textToAdd);
if (succeeded) {
this.textarea.setSelectionRange(newSelectionStart, newSelectionStart);
return;
}
}
const textUntilSelectionStart = value.substring(0, selectionStart);
const textAfterSelectionEnd = value.substring(selectionEnd);
this.value = textUntilSelectionStart + textToAdd + textAfterSelectionEnd;
this.textarea.focus();
this.textarea.setSelectionRange(newSelectionStart, newSelectionStart);
}
/**
* Renders header Markdown symbols into the textarea.
*/
protected handleHeaderClick: EventListener = (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) throw TypeError();
const id = target.id;
const markdownSymbol = this.markdownMap.get(id) ?? "";
const { selectionStart, value } = this.textarea;
const isFullParagraph = selectionStart ? value.at(selectionStart - 1) === "\n" : true;
const newText = `${isFullParagraph ? "" : "\n"}${markdownSymbol} `;
const padding = markdownSymbol.length + 2;
this.appendTextToTextArea(newText, padding);
};
/**
* Handles a click to modifiers like italics.
* The textarea selection will be set to the middle of symbols.
*/
protected handleModifierClick: EventListener = (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) throw TypeError();
const id = target.id;
const markdownSymbol = this.markdownMap.get(id) ?? "";
const { selectionStart, selectionEnd, value } = this.textarea;
const newValue = ` ${markdownSymbol}${value.substring(selectionStart, selectionEnd)}${markdownSymbol} `;
const padding = markdownSymbol.length + 1;
this.appendTextToTextArea(newValue, padding);
};
/**
* Renders templates when a template icon is clicked.
*/
protected handleTemplateClick: EventListener = (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) throw TypeError();
const id = target.id;
const markdownSymbol = this.markdownMap.get(id) ?? "";
const newLine = "\n";
const newValue = newLine + markdownSymbol + newLine;
this.appendTextToTextArea(newValue);
};
/**
* Handles link icon click to wrap selected text in a URL markdown text.
*/
protected handleLinkClick: EventListener = () => {
const { selectionStart, selectionEnd, value } = this.textarea;
const selectedText = value.substring(selectionStart, selectionEnd);
const markdown = `[${selectedText ? selectedText : "URL"}](https://)`;
this.appendTextToTextArea(markdown);
const startOfURL = selectionStart + 1 + (selectedText ? selectedText.length : 3) + 2;
this.textarea.setSelectionRange(startOfURL, startOfURL + 8);
};
/**
* Opens file selection dialogue on a click to the add picture icon.
*/
protected handleAddPictureClick: EventListener = () => {
if (this.loading) return;
this.fileInput.click();
};
/**
* Handles Enter keydown event and adds a new line for lists.
*/
private handleEnterKeydown = (event: KeyboardEvent) => {
const { selectionStart, value } = this.textarea;
const startOfParagraph = value.lastIndexOf("\n", selectionStart - 2);
const currentParagraph = value.slice(startOfParagraph + 1, selectionStart);
const olRegex = /^([1-9][0-9]*). [^\n ]+/;
const isOl = currentParagraph.match(olRegex);
const ulRegex = / - [^\n ]+/;
const isUl = currentParagraph.match(ulRegex);
const isEmptyUlOrOl = /^(([1-9][0-9]*).| -) +$/.test(currentParagraph);
if (isOl || isUl || isEmptyUlOrOl) {
event.preventDefault();
if (isEmptyUlOrOl && "execCommand" in document) {
this.textarea.focus();
this.textarea.setSelectionRange(startOfParagraph + 1, selectionStart);
return document.execCommand("delete", false);
}
const symbol = isOl ? `\n${Number(isOl[1]) + 1}. ` : "\n - ";
this.appendTextToTextArea(symbol, symbol.length);
}
};
/**
* Hanldes the keydown event when the meta key is held.
*/
private handleMetaKeydown = (event: KeyboardEvent) => {
const clickEvent = new Event("click");
switch (event.key.toUpperCase()) {
case "U":
case "K":
case "L":
event.preventDefault();
this.handleLinkClick(event);
break;
case "B":
event.preventDefault();
this.boldButton.dispatchEvent(clickEvent);
break;
case "I":
event.preventDefault();
this.italicsButton.dispatchEvent(clickEvent);
case "O":
event.preventDefault();
this.fileInput.click();
}
};
/**
* Handles file input when add picture button is clicked
*/
protected handleFileInput: EventListener = (event) => {
event.stopPropagation(); // stop input event from bleeding through to light DOM
const { files } = this.fileInput;
if (!files) throw Error("No files object was found on input");
const file = files[0];
if (!file || file.size === 0) return;
this.loading = true;
this.provideFileURL(file)
.then((url) => this.renderImageToTextArea(file, url))
.finally(() => {
this.loading = false;
this.fileInput.value = "";
});
};
/**
* Handles a file drop on the textarea element.
* Can render multiple files at once.
*/
protected handleDrop = (event: DragEvent) => {
event.preventDefault();
if (this.loading || !event.dataTransfer) return;
const { files } = event.dataTransfer;
const filesArray = Array.from(files);
const regex = /(image|video)\/.*/;
const filteredFiles = filesArray.filter(({ type }) => regex.test(type));
this.loading = true;
Promise.all(
filteredFiles.map((file) => {
return this.provideFileURL(file).then((url) => this.renderImageToTextArea(file, url));
}),
).finally(() => {
this.loading = false;
});
};
/**
* Renders a file to the text area as markdown text with a link to the objects URL.
*/
private renderImageToTextArea(file: File, url: string) {
const markdown = ``;
const padding = markdown.length + 1;
this.appendTextToTextArea(markdown, padding);
}
/**
* Processes a file for uploading to hosting provider before being rendered in the text editor.
* By default, will simply return an object URL for the file.
*/
protected provideFileURL(file: File): Promise {
const objectURL = URL.createObjectURL(file);
return Promise.resolve(objectURL);
}
/**
* Triggers input event on button clicks.
*/
protected triggerInputEvent() {
this.dispatchEvent(new Event("input", { composed: true }));
}
static styles = [globalStyles, formStyles, markdownStyles];
/**
* Renders a hidden textarea to the lightdom so this element can be used with forms.
* Will use element internals later.
*/
renderToLightDom() {
if (!this.textarea) return; // Do not allow render until element is fully loaded.
if (!this.internals) {
render(
html``,
this,
);
return;
}
this.internals.setFormValue(this.value);
if (this.required && this.value.length === 0)
return this.internals.setValidity({ valueMissing: true }, "Editor is empty.", this.textarea);
const maxlengthNum = Number(this.maxlength);
if (Boolean(maxlengthNum) && this.value.length > maxlengthNum)
return this.internals.setValidity(
{ tooLong: true },
`Max character length is ${this.maxlength} characters. Current character length is ${this.value.length}.`,
this.textarea,
);
const minlengthNum = Number(this.minlength);
if (Boolean(minlengthNum) && this.value.length < minlengthNum)
return this.internals.setValidity(
{ tooShort: true },
`At least ${this.minlength} characters are required.`,
this.textarea,
);
this.internals.setValidity({});
}
render() {
return html`
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-markdown-editor": LitMarkdownEditor;
}
// interface HTMLElementEventMap {
// "my-event": CustomEvent<{ foo: number }>;
// }
}