646 lines
17 KiB
Markdown
646 lines
17 KiB
Markdown
---
|
||
name: building-a-rich-text-editor-with-lit
|
||
description: Learn how to build a rich text editor using a Lit web component, complete with a toolbar for formatting text, links, and styles.
|
||
metadata:
|
||
url: https://rodydavis.com/posts/lit-rich-text-editor
|
||
last_modified: Tue, 03 Feb 2026 20:04:26 GMT
|
||
---
|
||
|
||
# Building a Rich Text Editor with Lit
|
||
|
||
|
||
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a rich text editor.
|
||
|
||
> **TLDR** The final source [here](https://github.com/rodydavis/lit-html-editor) and an online [demo](https://rodydavis.github.io/lit-html-editor/).
|
||
|
||
## Prerequisites
|
||
|
||
* Vscode
|
||
* Node >= 16
|
||
* Typescript
|
||
|
||
## Getting Started
|
||
|
||
We can start off by navigating in terminal to the location of the project and run the following:
|
||
|
||
```
|
||
npm init @vitejs/app --template lit-ts
|
||
```
|
||
|
||
Then enter a project name `lit-rich-text-editor` and now open the project in vscode and install the dependencies:
|
||
|
||
```
|
||
cd lit-rich-text-editor
|
||
npm i @material/mwc-icon-button
|
||
npm i -D @types/node
|
||
code .
|
||
```
|
||
|
||
Update the `vite.config.ts` with the following:
|
||
|
||
```
|
||
import { defineConfig } from "vite";
|
||
import { resolve } from "path";
|
||
|
||
export default defineConfig({
|
||
base: '/lit-rich-text-editor/',
|
||
build: {
|
||
lib: {
|
||
entry: "src/lit-rich-text-editor.ts",
|
||
formats: ["es"],
|
||
},
|
||
rollupOptions: {
|
||
input: {
|
||
main: resolve(__dirname, "index.html"),
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
## Template
|
||
|
||
Open up the `index.html` and update it with the following:
|
||
|
||
```
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<link
|
||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||
rel="stylesheet"
|
||
/>
|
||
<title>Lit Rich Text Editor</title>
|
||
<script type="module" src="/src/lit-rich-text-editor.ts"></script>
|
||
<style>
|
||
body {
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
lit-rich-text-editor {
|
||
--editor-width: 100%;
|
||
--editor-height: 100vh;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<lit-rich-text-editor>
|
||
<template>
|
||
<h1>Headline 1</h1>
|
||
<p>This is a paragraph.</p>
|
||
<p>
|
||
<span style="background-color: rgb(255, 0, 0)"
|
||
><font color="#ffffff">Styled Text</font></span
|
||
>
|
||
</p>
|
||
|
||
<p>
|
||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||
aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
|
||
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
|
||
culpa qui officia deserunt mollit anim id est laborum.
|
||
</p>
|
||
</template>
|
||
</lit-rich-text-editor>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
The important things to take away are the styles added to remove the body padding and send size [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to the editor to take up the full viewport.
|
||
|
||
Inside the `lit-rich-text-editor` tags there is a [`template`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template) passed as a slot to provide html that will not be rendered but can be accessed.
|
||
|
||
There is also an import for the [Material Icons](https://fonts.google.com/icons) so it can be used in the editor later.
|
||
|
||
## Editor
|
||
|
||
The next thing to create is the editor itself. Open up `src/lit-rich-text-editor.ts` and update it with the following:
|
||
|
||
```
|
||
import { html, css, LitElement } from "lit";
|
||
import { customElement, property, state } from "lit/decorators.js";
|
||
|
||
import "@material/mwc-icon-button";
|
||
|
||
@customElement("lit-rich-text-editor")
|
||
export class LitRichTextEditor extends LitElement {
|
||
@state() content: string = "";
|
||
@state() root: Element | null = null;
|
||
|
||
static styles = css`
|
||
:host {
|
||
--editor-width: 600px;
|
||
--editor-height: 600px;
|
||
--editor-background: #f1f1f1;
|
||
--editor-toolbar-height: 33px;
|
||
--editor-toolbar-background: black;
|
||
--editor-toolbar-on-background: white;
|
||
--editor-toolbar-on-active-background: #a4a4a4;
|
||
}
|
||
main {
|
||
width: var(--editor-width);
|
||
height: var(--editor-height);
|
||
display: grid;
|
||
grid-template-areas:
|
||
"toolbar toolbar"
|
||
"editor editor";
|
||
grid-template-rows: var(--editor-toolbar-height) auto;
|
||
grid-template-columns: auto auto;
|
||
}
|
||
#editor-actions {
|
||
grid-area: toolbar;
|
||
width: var(--editor-width);
|
||
height: var(--editor-toolbar-height);
|
||
background-color: var(--editor-toolbar-background);
|
||
color: var(--editor-toolbar-on-background);
|
||
overscroll-behavior: contain;
|
||
overflow-y: auto;
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
#editor-actions::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
#editor {
|
||
width: var(--editor-width);
|
||
grid-area: editor;
|
||
background-color: var(--editor-background);
|
||
}
|
||
#toolbar {
|
||
width: 1090px;
|
||
height: var(--editor-toolbar-height);
|
||
}
|
||
[contenteditable] {
|
||
outline: 0px solid transparent;
|
||
}
|
||
#toolbar > mwc-icon-button {
|
||
color: var(--editor-toolbar-on-background);
|
||
--mdc-icon-size: 20px;
|
||
--mdc-icon-button-size: 30px;
|
||
cursor: pointer;
|
||
}
|
||
#toolbar > .active {
|
||
color: var(--editor-toolbar-on-active-background);
|
||
}
|
||
select {
|
||
margin-top: 5px;
|
||
height: calc(var(--editor-toolbar-height) - 10px);
|
||
}
|
||
input[type="color"] {
|
||
height: calc(var(--editor-toolbar-height) - 15px);
|
||
-webkit-appearance: none;
|
||
border: none;
|
||
width: 22px;
|
||
}
|
||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||
padding: 0;
|
||
}
|
||
input[type="color"]::-webkit-color-swatch {
|
||
border: none;
|
||
}
|
||
`;
|
||
|
||
render() {
|
||
return html`<main>
|
||
<input id="bg" type="color" style="display:none" />
|
||
<input id="fg" type="color" style="display:none" />
|
||
<div id="editor-actions">
|
||
<div id="toolbar">
|
||
</div>
|
||
</div>
|
||
<div id="editor">${this.root}</div>
|
||
</main> `;
|
||
}
|
||
|
||
async firstUpdated() {
|
||
const elem = this.parentElement!.querySelector("lit-rich-text-editor template");
|
||
this.content = elem?.innerHTML ?? "";
|
||
this.reset();
|
||
}
|
||
|
||
reset() {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(this.content, "text/html");
|
||
document.execCommand("defaultParagraphSeparator", false, "br");
|
||
document.addEventListener("selectionchange", () => {
|
||
this.requestUpdate();
|
||
});
|
||
const root = doc.querySelector("body");
|
||
root!.setAttribute("contenteditable", "true");
|
||
this.root = root;
|
||
}
|
||
|
||
}
|
||
```
|
||
|
||
With everything updated run `npm run dev` and the following should appear in the browser:
|
||
|
||

|
||
|
||
Nothing special is happening yet, but the template is being read and passed into the element, parsed and setting the [`contenteditable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable) attribute to `true`.
|
||
|
||
This is a way to access the slots and use the nodes to hold data that are not used for rendering. Doing it this way allows for a transformation of the HTML source into a format that can be used.
|
||
|
||
## Toolbar
|
||
|
||
At the bottom of the class before the last `}` add the following:
|
||
|
||
```
|
||
renderToolbar(command: (c: string, val: string | undefined) => void) {
|
||
// TODO: Selection does not work on Safari iOS
|
||
const selection = this.shadowRoot?.getSelection
|
||
? this.shadowRoot!.getSelection()
|
||
: null;
|
||
const tags: string[] = [];
|
||
if (selection?.type === "Range") {
|
||
// @ts-ignore
|
||
let parentNode = selection?.baseNode;
|
||
if (parentNode) {
|
||
const checkNode = () => {
|
||
const parentTagName = parentNode?.tagName?.toLowerCase()?.trim();
|
||
if (parentTagName) tags.push(parentTagName);
|
||
};
|
||
while (parentNode != null) {
|
||
checkNode();
|
||
parentNode = parentNode?.parentNode;
|
||
}
|
||
}
|
||
}
|
||
|
||
const commands: {
|
||
icon: string;
|
||
command: string | (() => void);
|
||
active?: boolean;
|
||
type?: string;
|
||
values?: { value: string; name: string; font?: boolean }[];
|
||
command_value?: string;
|
||
}[] = [
|
||
{
|
||
icon: "format_clear",
|
||
command: "removeFormat",
|
||
},
|
||
|
||
{
|
||
icon: "format_bold",
|
||
command: "bold",
|
||
active: tags.includes("b"),
|
||
},
|
||
{
|
||
icon: "format_italic",
|
||
command: "italic",
|
||
active: tags.includes("i"),
|
||
},
|
||
{
|
||
icon: "format_underlined",
|
||
command: "underline",
|
||
active: tags.includes("u"),
|
||
},
|
||
{
|
||
icon: "format_align_left",
|
||
command: "justifyleft",
|
||
},
|
||
{
|
||
icon: "format_align_center",
|
||
command: "justifycenter",
|
||
},
|
||
{
|
||
icon: "format_align_right",
|
||
command: "justifyright",
|
||
},
|
||
{
|
||
icon: "format_list_numbered",
|
||
command: "insertorderedlist",
|
||
active: tags.includes("ol"),
|
||
},
|
||
{
|
||
icon: "format_list_bulleted",
|
||
command: "insertunorderedlist",
|
||
active: tags.includes("ul"),
|
||
},
|
||
{
|
||
icon: "format_quote",
|
||
command: "formatblock",
|
||
command_value: "blockquote",
|
||
},
|
||
{
|
||
icon: "format_indent_decrease",
|
||
command: "outdent",
|
||
},
|
||
{
|
||
icon: "format_indent_increase",
|
||
command: "indent",
|
||
},
|
||
|
||
{
|
||
icon: "add_link",
|
||
command: () => {
|
||
const newLink = prompt("Write the URL here", "http://");
|
||
if (newLink && newLink != "" && newLink != "http://") {
|
||
command("createlink", newLink);
|
||
}
|
||
},
|
||
},
|
||
{ icon: "link_off", command: "unlink" },
|
||
{
|
||
icon: "format_color_text",
|
||
command: () => {
|
||
const input = this.shadowRoot!.querySelector(
|
||
"#fg"
|
||
)! as HTMLInputElement;
|
||
input.addEventListener("input", (e: any) => {
|
||
const val = e.target.value;
|
||
command("forecolor", val);
|
||
});
|
||
input.click();
|
||
},
|
||
type: "color",
|
||
},
|
||
{
|
||
icon: "border_color",
|
||
command: () => {
|
||
const input = this.shadowRoot!.querySelector(
|
||
"#bg"
|
||
)! as HTMLInputElement;
|
||
input.addEventListener("input", (e: any) => {
|
||
const val = e.target.value;
|
||
command("backcolor", val);
|
||
});
|
||
input.click();
|
||
},
|
||
type: "color",
|
||
},
|
||
{
|
||
icon: "title",
|
||
command: "formatblock",
|
||
values: [
|
||
{ name: "Normal Text", value: "--" },
|
||
{ name: "Heading 1", value: "h1" },
|
||
{ name: "Heading 2", value: "h2" },
|
||
{ name: "Heading 3", value: "h3" },
|
||
{ name: "Heading 4", value: "h4" },
|
||
{ name: "Heading 5", value: "h5" },
|
||
{ name: "Heading 6", value: "h6" },
|
||
{ name: "Paragraph", value: "p" },
|
||
{ name: "Pre-Formatted", value: "pre" },
|
||
],
|
||
},
|
||
{
|
||
icon: "text_format",
|
||
command: "fontname",
|
||
values: [
|
||
{ name: "Font Name", value: "--" },
|
||
...[...checkFonts()].map((f) => ({
|
||
name: f,
|
||
value: f,
|
||
font: true,
|
||
})),
|
||
],
|
||
},
|
||
{
|
||
icon: "format_size",
|
||
command: "fontsize",
|
||
values: [
|
||
{ name: "Font Size", value: "--" },
|
||
{ name: "Very Small", value: "1" },
|
||
{ name: "Small", value: "2" },
|
||
{ name: "Normal", value: "3" },
|
||
{ name: "Medium Large", value: "4" },
|
||
{ name: "Large", value: "5" },
|
||
{ name: "Very Large", value: "6" },
|
||
{ name: "Maximum", value: "7" },
|
||
],
|
||
},
|
||
{
|
||
icon: "undo",
|
||
command: "undo",
|
||
},
|
||
{
|
||
icon: "redo",
|
||
command: "redo",
|
||
},
|
||
{
|
||
icon: "content_cut",
|
||
command: "cut",
|
||
},
|
||
{
|
||
icon: "content_copy",
|
||
command: "copy",
|
||
},
|
||
{
|
||
icon: "content_paste",
|
||
command: "paste",
|
||
},
|
||
];
|
||
|
||
return html`
|
||
${commands.map((n) => {
|
||
return html`
|
||
${n.values
|
||
? html` <select
|
||
id="${n.icon}"
|
||
@change=${(e: any) => {
|
||
const val = e.target.value;
|
||
if (val === "--") {
|
||
command("removeFormat", undefined);
|
||
} else if (typeof n.command === "string") {
|
||
command(n.command, val);
|
||
}
|
||
}}
|
||
>
|
||
${n.values.map(
|
||
(v) => html` <option value=${v.value}>${v.name}</option>`
|
||
)}
|
||
</select>`
|
||
: html` <mwc-icon-button
|
||
icon="${n.icon}"
|
||
class="${n.active ? "active" : "inactive"}"
|
||
@click=${() => {
|
||
if (n.values) {
|
||
} else if (typeof n.command === "string") {
|
||
command(n.command, n.command_value);
|
||
} else {
|
||
n.command();
|
||
}
|
||
}}
|
||
></mwc-icon-button>`}
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
```
|
||
|
||
This takes an array of objects that we can map to `mwc-icon-button` or `select` depending on the passed values. This will also set up the event listeners and execute the command for the given action.
|
||
|
||
Inside the `<div id="toolbar">` tag add the following:
|
||
|
||
```
|
||
${this.renderToolbar((command, val) => {
|
||
document.execCommand(command, false, val);
|
||
console.log("command", command, val);
|
||
})}
|
||
```
|
||
|
||
This will listen for the callback and fire the command on the document and log it to the console.
|
||
|
||
And finally at the bottom of the file add the following:
|
||
|
||
```
|
||
export function checkFonts(): string[] {
|
||
const fontCheck = new Set(
|
||
[
|
||
// Windows 10
|
||
"Arial",
|
||
"Arial Black",
|
||
"Bahnschrift",
|
||
"Calibri",
|
||
"Cambria",
|
||
"Cambria Math",
|
||
"Candara",
|
||
"Comic Sans MS",
|
||
"Consolas",
|
||
"Constantia",
|
||
"Corbel",
|
||
"Courier New",
|
||
"Ebrima",
|
||
"Franklin Gothic Medium",
|
||
"Gabriola",
|
||
"Gadugi",
|
||
"Georgia",
|
||
"HoloLens MDL2 Assets",
|
||
"Impact",
|
||
"Ink Free",
|
||
"Javanese Text",
|
||
"Leelawadee UI",
|
||
"Lucida Console",
|
||
"Lucida Sans Unicode",
|
||
"Malgun Gothic",
|
||
"Marlett",
|
||
"Microsoft Himalaya",
|
||
"Microsoft JhengHei",
|
||
"Microsoft New Tai Lue",
|
||
"Microsoft PhagsPa",
|
||
"Microsoft Sans Serif",
|
||
"Microsoft Tai Le",
|
||
"Microsoft YaHei",
|
||
"Microsoft Yi Baiti",
|
||
"MingLiU-ExtB",
|
||
"Mongolian Baiti",
|
||
"MS Gothic",
|
||
"MV Boli",
|
||
"Myanmar Text",
|
||
"Nirmala UI",
|
||
"Palatino Linotype",
|
||
"Segoe MDL2 Assets",
|
||
"Segoe Print",
|
||
"Segoe Script",
|
||
"Segoe UI",
|
||
"Segoe UI Historic",
|
||
"Segoe UI Emoji",
|
||
"Segoe UI Symbol",
|
||
"SimSun",
|
||
"Sitka",
|
||
"Sylfaen",
|
||
"Symbol",
|
||
"Tahoma",
|
||
"Times New Roman",
|
||
"Trebuchet MS",
|
||
"Verdana",
|
||
"Webdings",
|
||
"Wingdings",
|
||
"Yu Gothic",
|
||
// macOS
|
||
"American Typewriter",
|
||
"Andale Mono",
|
||
"Arial",
|
||
"Arial Black",
|
||
"Arial Narrow",
|
||
"Arial Rounded MT Bold",
|
||
"Arial Unicode MS",
|
||
"Avenir",
|
||
"Avenir Next",
|
||
"Avenir Next Condensed",
|
||
"Baskerville",
|
||
"Big Caslon",
|
||
"Bodoni 72",
|
||
"Bodoni 72 Oldstyle",
|
||
"Bodoni 72 Smallcaps",
|
||
"Bradley Hand",
|
||
"Brush Script MT",
|
||
"Chalkboard",
|
||
"Chalkboard SE",
|
||
"Chalkduster",
|
||
"Charter",
|
||
"Cochin",
|
||
"Comic Sans MS",
|
||
"Copperplate",
|
||
"Courier",
|
||
"Courier New",
|
||
"Didot",
|
||
"DIN Alternate",
|
||
"DIN Condensed",
|
||
"Futura",
|
||
"Geneva",
|
||
"Georgia",
|
||
"Gill Sans",
|
||
"Helvetica",
|
||
"Helvetica Neue",
|
||
"Herculanum",
|
||
"Hoefler Text",
|
||
"Impact",
|
||
"Lucida Grande",
|
||
"Luminari",
|
||
"Marker Felt",
|
||
"Menlo",
|
||
"Microsoft Sans Serif",
|
||
"Monaco",
|
||
"Noteworthy",
|
||
"Optima",
|
||
"Palatino",
|
||
"Papyrus",
|
||
"Phosphate",
|
||
"Rockwell",
|
||
"Savoye LET",
|
||
"SignPainter",
|
||
"Skia",
|
||
"Snell Roundhand",
|
||
"Tahoma",
|
||
"Times",
|
||
"Times New Roman",
|
||
"Trattatello",
|
||
"Trebuchet MS",
|
||
"Verdana",
|
||
"Zapfino",
|
||
].sort()
|
||
);
|
||
const fontAvailable = new Set<string>();
|
||
// @ts-ignore
|
||
for (const font of fontCheck.values()) {
|
||
// @ts-ignore
|
||
if (document.fonts.check(`12px "${font}"`)) {
|
||
fontAvailable.add(font);
|
||
}
|
||
}
|
||
// @ts-ignore
|
||
return fontAvailable.values();
|
||
}
|
||
```
|
||
|
||
Following this great suggestion [here](https://stackoverflow.com/a/62755574/7303311) the document checks to see all the avaliable fonts for the browser and given document.
|
||
|
||
## Running
|
||
|
||
If everything went well when the command `npm run dev` is run the following should appear in the viewport:
|
||
|
||

|
||
|
||
## Conclusion
|
||
|
||
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
|
||
|
||
The source for this example can be found [here](https://github.com/rodydavis/lit-html-editor). |