A lightweight Markdown editor custom element built on CodeMirror 6. It registers the tag when imported and is themeable via CSS variables.
Highlights:
- Web Component: with zero framework dependencies
- CodeMirror 6 under the hood (minimalSetup, Markdown, HTML, autocompletion)
- Smart light-DOM bootstrapping: initial content can be authored between the tags and is normalized/dedented
- Convenience keybinding: Tab inserts two spaces
- Extra line decorations: horizontal rules (---), fenced code blocks (```), and asides (:::) with full-line styling
- Emits EDITOR-UPDATED when content changes
- Fully themeable using CSS custom properties (variables)
Install the package:
npm install @lnsy/markdown-editorFor local development in this repo, install dependencies and run the dev server:
npm install
npm run startBy default this serves index.html and emits dist/markdown-editor.min.js. Open http://localhost:3000.
Build for production:
npm run buildThis writes optimized assets to dist/ (e.g., dist/markdown-editor.min.js).
Optional .env customization:
# Output file name (defaults to markdown-editor.min.js)
OUTPUT_FILE_NAME=my-app.js
# Dev server port (defaults to 3000)
PORT=5173The component is automatically registered when you import the bundle. You can either use the built bundle (no-bundler) or import the source into your own bundler (rspack, webpack, Vite, etc.).
- Using the built bundle (no bundler):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Markdown Editor</title>
<script type="module" src="./dist/markdown-editor.min.js"></script>
</head>
<body>
<markdown-editor>
# Hello Markdown
Type here. Use --- for an HR line, ::: for asides, and fenced ```js blocks.
</markdown-editor>
</body>
</html>- Importing in your bundler entry (rspack example):
// index.js
import "./index.css"; // your global styles (optional)
import "./src/markdown-editor.js"; // registers <markdown-editor>// rspack.config.js (already provided in this repo)
module.exports = {
entry: "./index.js",
// ...
};- Adding an instance in your app markup:
<markdown-editor>
# Title
Some initial content here. It will be normalized (dedented and without leading/trailing blank noise).
</markdown-editor>The element provides convenience methods for common operations, as well as direct access to the CodeMirror EditorView instance (el.view) for advanced use.
- insertString(str, line_number, char_number): Insert text at a specific position
str(string): The text to insertline_number(number): 1-indexed line numberchar_number(number): 0-indexed character position within the line- Returns:
trueon success,falseon failure
const el = document.querySelector("markdown-editor");
el.insertString("Hello world", 1, 0); // Insert at beginning of line 1- replaceString(str, start_line, start_char, end_line, end_char): Replace text in a range
str(string): The replacement textstart_line(number): 1-indexed starting line numberstart_char(number): 0-indexed starting character positionend_line(number): 1-indexed ending line numberend_char(number): 0-indexed ending character position- Returns:
trueon success,falseon failure
const el = document.querySelector("markdown-editor");
// Replace first 5 characters of line 1 with "# Title"
el.replaceString("# Title", 1, 0, 1, 5);- getCursor(): Get current cursor position and context information
- Returns: Object with cursor details, or
nullon errorline(number): 1-indexed line numbercharacter(number): 0-indexed character position within linecontext(string[]): Array of context tags describing cursor locationselected(string, optional): Selected text if a selection exists
- Returns: Object with cursor details, or
const el = document.querySelector("markdown-editor");
const cursor = el.getCursor();
console.log(cursor);
// Example outputs:
// { line: 5, character: 12, context: ["js", "codeblock"] }
// { line: 2, character: 0, context: ["heading", "h1"] }
// { line: 10, character: 5, context: ["note", "aside"] }
// { line: 3, character: 2, context: [], selected: "some text" }Context tags:
"codeblock": Cursor is inside a fenced code block (```)- Language name (e.g.,
"js","python"): Specific code block language "aside": Cursor is inside an aside block (:::)- Aside type (e.g.,
"note","warning"): Specific aside type "heading": Current line is a heading- Heading level (e.g.,
"h1","h2"): Specific heading level
The element exposes its CodeMirror EditorView instance as el.view. You can read and write the document programmatically.
- Get current text:
const el = document.querySelector("markdown-editor");
const text = el.view.state.doc.toString();
console.log(text);- Replace entire document:
const el = document.querySelector("markdown-editor");
const { state } = el.view;
el.view.dispatch({
changes: { from: 0, to: state.doc.length, insert: "# Replaced\n\nNew content." }
});- Append text at the end:
const el = document.querySelector("markdown-editor");
const end = el.view.state.doc.length;
el.view.dispatch({ changes: { from: end, insert: "\n\nAppended line." } });- Focus the editor:
const el = document.querySelector("markdown-editor");
el.view.focus();Notes:
- Tab inserts two spaces by default.
- Markdown language support includes fenced code languages. JavaScript (js/javascript/node) is loaded eagerly; other languages are provided via @codemirror/language-data.
The editor dispatches bubbling, composed CustomEvents for various interactions:
Fires whenever the document content changes.
const el = document.querySelector("markdown-editor");
el.addEventListener("EDITOR-UPDATED", (evt) => {
// Respond to content changes
console.log("markdown changed", el.view.state.doc.toString());
});Fires when an image file is dropped onto the editor. The event detail contains image metadata and a data URL.
Event detail properties:
fileName(string): Original filenamefileSize(number): File size in bytesfileType(string): MIME type (e.g., "image/png")lastModified(number): Unix timestamplastModifiedDate(string): ISO 8601 date stringdataURL(string): Base64-encoded data URL of the image
const el = document.querySelector("markdown-editor");
el.addEventListener("IMAGE-DROPPED", (evt) => {
const { fileName, fileSize, fileType, dataURL } = evt.detail;
console.log(`Image dropped: ${fileName} (${fileSize} bytes)`);
// Use dataURL to display or upload the image
// The editor automatically inserts ![[filename]] syntax at cursor
});Fires if an error occurs while processing a dropped image.
Event detail properties:
error(string): Error messagefileName(string): Name of the file that caused the error
const el = document.querySelector("markdown-editor");
el.addEventListener("IMAGE-DROP-ERROR", (evt) => {
const { error, fileName } = evt.detail;
console.error(`Failed to process ${fileName}: ${error}`);
});All base theming is driven by CSS custom properties. The component imports styles/variables.css (defaults) and styles/theme.css (rules that consume the variables). You can override any variable globally (via :root) or locally (scoped to a container).
Common variables (from styles/variables.css):
- --bg-color, --fg-color
- --darker-neutral-color, --neutral-bg-color, --lighter-neutral-bg-color, --neutral-fg-color
- --highlight-color
- --secondary-color, --trinary-color, --quaternary-color
- --link-color
- --code-secondary-highlight-color, --code-foreground-color
- --code-background-color, --code-background-color-darker
- --code-highlight-color, --code-neutral-color
- --font-size, --line-height
- --md-heading-weight, --md-h1-size, --md-h2-size, --md-h3-size, --md-h4-size, --md-h5-size, --md-h6-size
Additional variables used by theme.css line decorations:
- --md-hr-line-bg
- --md-aside-fence-bg
- --md-aside-bg
The theme also respects a body font variable if you provide it:
-
--body-font-family
-
Global, site-wide overrides:
:root {
--body-font-family: "Fira Code", monospace;
--bg-color: #0d1117;
--fg-color: #e6edf3;
--neutral-bg-color: #30363d;
--lighter-neutral-bg-color: #21262d;
--highlight-color: rgba(56, 139, 253, 0.35);
--secondary-color: #58a6ff;
--trinary-color: #7ee787;
--quaternary-color: #d2a8ff;
--font-size: 15px;
--line-height: 1.6;
/* line decoration specifics */
--md-hr-line-bg: rgba(125,125,125,0.12);
--md-aside-fence-bg: rgba(253,186,116,0.18);
--md-aside-bg: rgba(253,186,116,0.12);
}- Scoped overrides (only affect editors inside .note):
.note {
--bg-color: #fff8e7;
--fg-color: #5b4931;
--highlight-color: #fff176; /* selection color */
--code-background-color: #e8e1d6;
}- Override only selection/active-line accents:
:root {
--highlight-color: rgba(255, 199, 0, 0.35); /* selection */
--neutral-bg-color: #b0bec5; /* active line left bar */
}- Heading line accents (applied by decoration classes):
:root {
/* The theme maps heading decoration backgrounds to these */
--darker-neutral-color: #cfd8dc; /* h1 */
--secondary-color: #a5d6a7; /* h2 */
--trinary-color: #90caf9; /* h3 */
--quaternary-color: #b39ddb; /* h4–h6 */
}- Example: index.html with inline overrides
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demo</title>
<script type="module" src="/markdown-editor.min.js"></script>
<style>
:root {
--body-font-family: "Fira Code", monospace;
--bg-color: pink;
--fg-color: blue;
--darker-neutral-color: green;
--neutral-bg-color: yellow;
--lighter-neutral-bg-color: purple;
}
</style>
</head>
<body>
<markdown-editor>
# Markdown Editor
Try typing, or paste code fences and asides.
</markdown-editor>
</body>
</html>Advanced styling
- Token colors come from classHighlighter and theme.css selectors like .tok-keyword, .tok-string, .tok-comment, etc. You can further refine these with custom CSS if needed.
- Line decoration classes you can target: .cm-hr-line, .cm-code-fence-line, .cm-code-block-line, .cm-aside-fence-line, .cm-aside-block-line, .cm-md-heading with variants .cm-md-h1..h6.
- Initial content: The editor reads the element’s textContent on first mount, normalizes CRLF to LF, removes an initial stray newline, dedents common indentation, and trims trailing whitespace at the end of the block. The light-DOM content is then cleared and the editor is mounted.
- Keybindings: Tab inserts two spaces. Ctrl-Space triggers completion (via CodeMirror completionKeymap).
- Start dev server:
npm run start- Build production bundle:
npm run build- Optional .env settings:
OUTPUT_FILE_NAME=my-custom-filename.js
PORT=8080See LICENSE.