Markdown Editor

Markdown editor with auto-formatting.

code.tsx

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,
});

Markdown source · More bulbs by samples · Typebulb home