@lnsy/markdown-editor
v0.4.5
Published
A lightweight Markdown editor Web Component built on CodeMirror 6
Maintainers
Readme
Markdown Editor Web Component (@lnsy/markdown-editor)
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)
Quick start (rspack)
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=5173Using the element
The 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>Placeholder
Use the placeholder attribute to show hint text while the editor is empty:
<markdown-editor placeholder="Start typing your note…"></markdown-editor>Autocomplete attributes
You can configure symbol-triggered autocomplete by providing attributes in the form symbol;url. The URL should return JSON containing an array of strings or objects:
<markdown-editor
autocomplete--atsymbol="@;./mentions.json"
autocomplete-poundsign="#;./tags.json">
Type @ or # to see suggestions.
</markdown-editor>Supported attributes:
autocomplete--atsymbol— triggers autocomplete after typing@autocomplete-atsymbol— fallback form for@autocomplete-poundsign— triggers autocomplete after typing#
Endpoint JSON shape:
["alice", "bob", "charlie"]Or with labels and values:
[
{ "label": "Alice", "value": "alice" },
{ "label": "Bob", "value": "bob" }
]When no autocomplete attributes are present, the editor falls back to the default CodeMirror completions (e.g. HTML tag/attribute completions from the built-in HTML support).
Programmatic API
The element provides convenience methods for common operations, as well as direct access to the CodeMirror EditorView instance (el.view) for advanced use.
Convenience methods
- 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
Direct CodeMirror access
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.
Events
The editor dispatches bubbling, composed CustomEvents for various interactions:
EDITOR-UPDATED
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());
});WIKILINK-CLICKED
Fires when a user clicks a wikilink ([[target]] or [[target|label]]) on an inactive line. The line does not become active — clicking navigates rather than editing, mirroring the todo-marker behavior.
Event detail properties:
target(string): The link target (the first part of[[target|label]])label(string): The display label (falls back totargetwhen no pipe-label is present)
const el = document.querySelector("markdown-editor");
el.addEventListener("WIKILINK-CLICKED", (evt) => {
const { target, label } = evt.detail;
console.log(`Wikilink clicked: ${label} → ${target}`);
// Navigate to the linked page, open a modal, etc.
});HASHTAG-CLICKED
Fires when a user clicks a hashtag (#tag) on an inactive line. The line does not become active — clicking navigates rather than editing, mirroring the wikilink behaviour.
Event detail properties:
tag(string): The tag text without the leading#
const el = document.querySelector("markdown-editor");
el.addEventListener("HASHTAG-CLICKED", (evt) => {
const { tag } = evt.detail;
console.log(`Hashtag clicked: #${tag}`);
// Filter by tag, open a tag page, etc.
});REFERENCE-CLICKED
Fires when a user clicks an @ reference (@mention) on an inactive line. The line does not become active — clicking navigates rather than editing, mirroring the wikilink behaviour.
Event detail properties:
reference(string): The reference text without the leading@
const el = document.querySelector("markdown-editor");
el.addEventListener("REFERENCE-CLICKED", (evt) => {
const { reference } = evt.detail;
console.log(`Reference clicked: @${reference}`);
// Open a profile, mention detail view, etc.
});IMAGE-DROPPED
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|data:image/...;base64,...]] at the drop point
});IMAGE-DROP-ERROR
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}`);
});Styling and CSS variables
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.
Behavior details
- 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).
Project scripts (rspack)
- Start dev server:
npm run start- Build production bundle:
npm run build- Optional .env settings:
OUTPUT_FILE_NAME=my-custom-filename.js
PORT=8080License
See LICENSE.
