Markdown editor with auto-formatting.
import { EditorState, StateField } from "@codemirror/state";
import {
EditorView,
keymap,
highlightActiveLine,
Decoration,
type DecorationSet,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { GFM } from "@lezer/markdown";
import { classHighlighter } from "@lezer/highlight";
import {
syntaxHighlighting,
defaultHighlightStyle,
syntaxTree,
} from "@codemirror/language";
// ============ Types ============
type Alignment = "left" | "center" | "right" | "none";
interface TableRange {
from: number;
to: number;
}
interface ParsedTable {
rows: string[][];
alignments: Alignment[];
}
const DELIMITER_LINE_RE = /^\|?[\s|:\-]+\|?$/;
// ============ Table Detection ============
function detectTables(state: EditorState): TableRange[] {
const tables: TableRange[] = [];
syntaxTree(state).iterate({
enter(node) {
if (node.name === "Table") {
tables.push({ from: node.from, to: node.to });
}
},
});
return tables;
}
const tableRangesField = StateField.define<TableRange[]>({
create: (state) => detectTables(state),
update: (value, tr) => (tr.docChanged ? detectTables(tr.state) : value),
});
// ============ Table Parsing ============
function parseTableText(text: string): ParsedTable | null {
const lines = text.split("\n");
if (lines.length < 2) return null;
let delimiterIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (isDelimiterLine(lines[i])) {
delimiterIdx = i;
break;
}
}
if (delimiterIdx < 1) return null;
const rows: string[][] = [];
const alignments: Alignment[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
if (line.startsWith("|")) line = line.slice(1);
if (line.endsWith("|")) line = line.slice(0, -1);
const cells = line.split("|").map((c) => c.trim());
if (i === delimiterIdx) {
for (const cell of cells) {
const l = cell.startsWith(":");
const r = cell.endsWith(":");
alignments.push(l && r ? "center" : r ? "right" : l ? "left" : "none");
}
} else {
rows.push(cells);
}
}
return rows.length && alignments.length ? { rows, alignments } : null;
}
// ============ Table Formatting ============
function normalizeRows(rows: string[][], numCols: number): string[][] {
return rows.map((row) => {
const r = row.slice(0, numCols);
while (r.length < numCols) r.push("");
return r;
});
}
function computeWidths(rows: string[][], numCols: number, minWidth = 3): number[] {
const widths = Array.from({ length: numCols }, () => minWidth);
for (const row of rows) {
for (let c = 0; c < numCols; c++) {
widths[c] = Math.max(widths[c], row[c].length);
}
}
return widths;
}
function padCell(text: string, w: number, align: Alignment): string {
const p = w - text.length;
if (p <= 0) return text;
if (align === "right") return " ".repeat(p) + text;
if (align === "center") {
const l = Math.floor(p / 2);
return " ".repeat(l) + text + " ".repeat(p - l);
}
return text + " ".repeat(p);
}
function formatRow(cells: string[], widths: number[], aligns: Alignment[]): string {
return "| " + cells.map((c, i) => padCell(c, widths[i], aligns[i])).join(" | ") + " |";
}
function formatDelimiter(widths: number[], aligns: Alignment[]): string {
return (
"| " +
widths
.map((w, i) => {
const d = "-".repeat(w);
const a = aligns[i];
if (a === "left") return ":" + d.slice(1);
if (a === "right") return d.slice(1) + ":";
if (a === "center") return ":" + d.slice(2) + ":";
return d;
})
.join(" | ") +
" |"
);
}
function buildFormattedLines(parsed: ParsedTable): {
lines: string[];
normalized: string[][];
widths: number[];
} {
const { rows, alignments } = parsed;
const numCols = alignments.length;
const normalized = normalizeRows(rows, numCols);
const widths = computeWidths(normalized, numCols);
const lines: string[] = [];
if (normalized.length > 0) lines.push(formatRow(normalized[0], widths, alignments));
lines.push(formatDelimiter(widths, alignments));
for (let i = 1; i < normalized.length; i++) {
lines.push(formatRow(normalized[i], widths, alignments));
}
return { lines, normalized, widths };
}
function formatTable(parsed: ParsedTable): string {
return buildFormattedLines(parsed).lines.join("\n");
}
// ============ Cursor Position Helpers ============
function isDelimiterLine(line: string): boolean {
const trimmed = line.trim();
return DELIMITER_LINE_RE.test(trimmed) && trimmed.includes("-");
}
function getCellSegments(line: string): { start: number; end: number; text: string }[] {
const segments: { start: number; end: number; text: string }[] = [];
const pipePositions: number[] = [-1];
for (let i = 0; i < line.length; i++) {
if (line[i] === "|") pipePositions.push(i);
}
pipePositions.push(line.length);
const trimmed = line.trim();
const ignoreFirst = trimmed.startsWith("|");
const ignoreLast = trimmed.endsWith("|");
for (let i = 0; i < pipePositions.length - 1; i++) {
const start = pipePositions[i] + 1;
const end = pipePositions[i + 1];
if (ignoreFirst && i === 0) continue;
if (ignoreLast && i === pipePositions.length - 2) continue;
segments.push({ start, end, text: line.slice(start, end) });
}
return segments;
}
function getLeftPad(width: number, textLength: number, align: Alignment): number {
const space = Math.max(0, width - textLength);
if (align === "right") return space;
if (align === "center") return Math.floor(space / 2);
return 0;
}
function getLineStartOffset(lines: string[], lineIndex: number): number {
let offset = 0;
for (let i = 0; i < lineIndex; i++) offset += lines[i].length + 1;
return offset;
}
function getCellStartOffset(widths: number[], col: number): number {
let start = 2; // "| "
for (let i = 0; i < col; i++) start += widths[i] + 3; // width + " | "
return start;
}
function findCursorPositionInTable(
text: string,
cursorOffset: number
): { row: number; col: number; offsetInCell: number } | null {
const lines = text.split("\n");
let pos = 0;
let dataRowIndex = 0;
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx];
const lineEnd = pos + line.length;
const delimiter = isDelimiterLine(line);
// Check if cursor is on this line
if (cursorOffset >= pos && cursorOffset <= lineEnd) {
if (delimiter) return null;
const cursorInLine = cursorOffset - pos;
const segments = getCellSegments(line);
for (let col = 0; col < segments.length; col++) {
const seg = segments[col];
const isLast = seg.end === line.length;
const within =
cursorInLine >= seg.start &&
(cursorInLine < seg.end || (isLast && cursorInLine === seg.end));
if (within) {
const leftTrim = seg.text.match(/^\s*/)?.[0].length ?? 0;
const trimmedText = seg.text.trim();
const rawOffset = cursorInLine - seg.start;
const contentOffset = rawOffset - leftTrim;
const offsetInCell = Math.min(Math.max(contentOffset, 0), trimmedText.length);
return { row: dataRowIndex, col, offsetInCell };
}
}
}
pos = lineEnd + 1; // +1 for newline
if (!delimiter) dataRowIndex++;
}
return null;
}
function getFormattedCursorOffset(
parsed: ParsedTable,
cellInfo: { row: number; col: number; offsetInCell: number }
): number | null {
const { alignments } = parsed;
const { lines, normalized, widths } = buildFormattedLines(parsed);
if (!normalized.length) return null;
if (cellInfo.row < 0 || cellInfo.row >= normalized.length) return null;
if (cellInfo.col < 0 || cellInfo.col >= alignments.length) return null;
const rowLineIndex = cellInfo.row === 0 ? 0 : cellInfo.row + 1;
if (rowLineIndex >= lines.length) return null;
const linePos = getLineStartOffset(lines, rowLineIndex);
const cellText = normalized[cellInfo.row][cellInfo.col];
const leftPad = getLeftPad(widths[cellInfo.col], cellText.length, alignments[cellInfo.col]);
const cellStart = getCellStartOffset(widths, cellInfo.col);
const offset = Math.min(cellInfo.offsetInCell, cellText.length);
return linePos + cellStart + leftPad + offset;
}
// ============ Table Auto-Formatter Extension ============
let formatTimeout: number | null = null;
const tableFormatter = EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
if (formatTimeout !== null) clearTimeout(formatTimeout);
formatTimeout = window.setTimeout(() => {
formatTimeout = null;
const { view } = update;
// Use the latest state to capture all changes (pastes, multiple edits)
const state = view.state;
const cursor = state.selection.main.head;
const ranges = state.field(tableRangesField);
const changes: { from: number; to: number; insert: string }[] = [];
let cursorAdjustment: { newPos: number } | null = null;
for (const range of ranges) {
const text = state.doc.sliceString(range.from, range.to);
const parsed = parseTableText(text);
if (!parsed) continue;
const formatted = formatTable(parsed);
if (formatted !== text) {
// Check if cursor is in this table
if (cursor >= range.from && cursor <= range.to) {
const cursorInTable = cursor - range.from;
const cellInfo = findCursorPositionInTable(text, cursorInTable);
if (cellInfo) {
const newOffset = getFormattedCursorOffset(parsed, cellInfo);
if (newOffset !== null) {
cursorAdjustment = { newPos: range.from + newOffset };
}
}
}
changes.push({ from: range.from, to: range.to, insert: formatted });
}
}
if (changes.length > 0) {
view.dispatch({
changes,
selection: cursorAdjustment ? { anchor: cursorAdjustment.newPos } : undefined
});
}
}, 400);
});
// ============ Markdown Styling Extension ============
const headingDeco = {
1: Decoration.mark({ class: "cm-h1" }),
2: Decoration.mark({ class: "cm-h2" }),
3: Decoration.mark({ class: "cm-h3" }),
};
const boldDeco = Decoration.mark({ class: "cm-bold" });
const italicDeco = Decoration.mark({ class: "cm-italic" });
const codeDeco = Decoration.mark({ class: "cm-code" });
const quoteDeco = Decoration.mark({ class: "cm-quote" });
const markdownStyling = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.build(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.build(update.view);
}
}
build(view: EditorView) {
const decos: any[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
const n = node.name;
if (n === "ATXHeading1" || n === "SetextHeading1")
decos.push(headingDeco[1].range(node.from, node.to));
else if (n === "ATXHeading2" || n === "SetextHeading2")
decos.push(headingDeco[2].range(node.from, node.to));
else if (n.startsWith("ATXHeading"))
decos.push(headingDeco[3].range(node.from, node.to));
else if (n === "StrongEmphasis")
decos.push(boldDeco.range(node.from, node.to));
else if (n === "Emphasis")
decos.push(italicDeco.range(node.from, node.to));
else if (n === "InlineCode" || n === "FencedCode" || n === "CodeBlock")
decos.push(codeDeco.range(node.from, node.to));
else if (n === "Blockquote")
decos.push(quoteDeco.range(node.from, node.to));
},
});
}
return Decoration.set(decos, true);
}
},
{ decorations: (v) => v.decorations }
);
// ============ Editor Setup ============
const container = document.getElementById("root")!;
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
new EditorView({
state: EditorState.create({
doc: `# Markdown Editor
Edit with **bold**, *italic*, and \`code\`.
## Auto-Formatting Tables
Type in the table below - columns auto-align!
| Name | Age | City |
|:--------|----:|:-------:|
| Alice | 30 | NYC |
| Bob | 25 | LA |
| Charlie | 35 | Chicago |
`,
extensions: [
highlightActiveLine(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown({ extensions: [GFM] }),
// Add class-based token names (e.g. .tok-meta/.tok-punctuation) so CSS can reliably style
// Markdown formatting markers like "#" and "*".
syntaxHighlighting(classHighlighter),
// Keep the default highlight style for everything else (CSS overrides can still win via !important).
syntaxHighlighting(defaultHighlightStyle),
tableRangesField,
markdownStyling,
tableFormatter,
EditorView.theme({
"&": { height: "100%", fontSize: "14px" },
".cm-scroller": { fontFamily: "monospace", padding: "10px" },
}, { dark: isDark }),
],
}),
parent: container,
});