shadimage
nteract
componentcodemirror-editorsource: ok · hit

CodeMirror Editor

A CodeMirror 6 editor component for notebook cells with syntax highlighting, key bindings, and language support for Python, IPython (with magics and shell commands), Markdown, SQL, HTML, JavaScript, TypeScript, and JSON.

npx shadcn add "https://nteract-elements.vercel.app/r/codemirror-editor.json"
free

Visual style

terminal-mono

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@codemirror/view@codemirror/state@codemirror/commands@codemirror/autocomplete@codemirror/language@codemirror/lint@codemirror/lang-python@codemirror/lang-markdown@codemirror/lang-sql@codemirror/lang-html@codemirror/lang-javascript@codemirror/lang-json@uiw/react-codemirror@uiw/codemirror-theme-github

Files7 · source cached 10/15/2026, 10:11:16 AM

registry/editor/codemirror-editor.tsx
"use client";

import {
  EditorView,
  type KeyBinding,
  keymap,
  placeholder as placeholderExt,
} from "@codemirror/view";
import { type Extension, useCodeMirror } from "@uiw/react-codemirror";
import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";
import { defaultExtensions } from "./extensions";
import {
  getIPythonExtension,
  getLanguageExtension,
  type SupportedLanguage,
} from "./languages";
import { darkTheme, isDarkMode, lightTheme, type ThemeMode } from "./themes";

export interface CodeMirrorEditorRef {
  /** Focus the editor */
  focus: () => void;
  /** Set cursor position to start or end of document */
  setCursorPosition: (position: "start" | "end") => void;
  /** Get the underlying EditorView instance */
  getEditor: () => EditorView | null;
}

export interface CodeMirrorEditorProps {
  /** Current editor content */
  value: string;
  /** Language for syntax highlighting */
  language?: SupportedLanguage;
  /** Callback when content changes */
  onValueChange?: (value: string) => void;
  /** Auto-focus on mount */
  autoFocus?: boolean;
  /** Callback when editor gains focus */
  onFocus?: () => void;
  /** Callback when editor loses focus */
  onBlur?: () => void;
  /** Placeholder text when empty */
  placeholder?: string;
  /** Additional key bindings */
  keyMap?: KeyBinding[];
  /** Additional CSS classes */
  className?: string;
  /** Maximum height (CSS value) */
  maxHeight?: string;
  /** Enable line wrapping */
  lineWrapping?: boolean;
  /** Additional CodeMirror extensions */
  extensions?: Extension[];
  /** Replace default extensions entirely */
  baseExtensions?: Extension[];
  /** Read-only mode */
  readOnly?: boolean;
  /** Theme mode: "light", "dark", or "auto" (default) */
  theme?: ThemeMode;
}

/**
 * CodeMirror 6 editor component for notebook cells
 *
 * Provides syntax highlighting, key bindings, and a clean API
 * for integration with notebook cell components.
 *
 * @example
 * ```tsx
 * <CodeMirrorEditor
 *   value={source}
 *   language="python"
 *   onValueChange={setSource}
 *   placeholder="Enter code..."
 * />
 * ```
 */
export const CodeMirrorEditor = forwardRef<
  CodeMirrorEditorRef,
  CodeMirrorEditorProps
>(
  (
    {
      value,
      language = "python",
      onValueChange,
      autoFocus = false,
      onFocus,
      onBlur,
      placeholder,
      keyMap,
      className,
      maxHeight,
      lineWrapping = false,
      extensions: additionalExtensions,
      baseExtensions = defaultExtensions,
      readOnly = false,
      theme = "system",
    },
    ref,
  ) => {
    const editorRef = useRef<HTMLDivElement | null>(null);
    const editorViewRef = useRef<EditorView | null>(null);

    // Track dark mode state for "system" theme
    const [isDark, setIsDark] = useState(() =>
      typeof window !== "undefined" ? isDarkMode() : false,
    );

    // Listen for dark mode changes (system preference + document class)
    useEffect(() => {
      if (theme !== "system") return;

      // Check initial state
      setIsDark(isDarkMode());

      // Listen for system preference changes
      const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
      const handleMediaChange = () => setIsDark(isDarkMode());
      mediaQuery.addEventListener("change", handleMediaChange);

      // Observe document changes (for site-level dark mode toggle)
      // Watch class, style (for color-scheme), and data attributes
      const observer = new MutationObserver(() => {
        setIsDark(isDarkMode());
      });
      observer.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["class", "style", "data-theme", "data-mode"],
      });

      return () => {
        mediaQuery.removeEventListener("change", handleMediaChange);
        observer.disconnect();
      };
    }, [theme]);

    // For IPython, detect cell magics and switch to appropriate language mode
    const langExtension = useMemo(() => {
      if (language === "ipython") {
        return getIPythonExtension(value).extension;
      }
      return getLanguageExtension(language);
    }, [language, value]);

    // Determine which theme to use
    const themeExtension = useMemo(() => {
      if (theme === "light") return lightTheme;
      if (theme === "dark") return darkTheme;
      // "system" - use detected state
      return isDark ? darkTheme : lightTheme;
    }, [theme, isDark]);

    const extensions = useMemo(() => {
      const exts: Extension[] = [
        ...baseExtensions,
        langExtension,
        themeExtension,
      ];

      if (keyMap && keyMap.length > 0) {
        exts.unshift(keymap.of(keyMap));
      }

      if (placeholder) {
        exts.push(placeholderExt(placeholder));
      }

      if (lineWrapping) {
        exts.push(EditorView.lineWrapping);
      }

      if (readOnly) {
        exts.push(EditorView.editable.of(false));
      }

      if (additionalExtensions) {
        exts.push(...additionalExtensions);
      }

      return exts;
    }, [
      baseExtensions,
      langExtension,
      themeExtension,
      keyMap,
      placeholder,
      lineWrapping,
      readOnly,
      additionalExtensions,
    ]);

    const handleChange = useCallback(
      (val: string) => {
        onValueChange?.(val);
      },
      [onValueChange],
    );

    const handleFocus = useCallback(() => {
      onFocus?.();
    }, [onFocus]);

    const { setContainer, view } = useCodeMirror({
      container: editorRef.current,
      basicSetup: false,
      indentWithTab: false,
      extensions,
      maxHeight,
      value,
      onChange: handleChange,
      autoFocus,
    });

    // Store the editor view reference
    useEffect(() => {
      editorViewRef.current = view || null;
    }, [view]);

    // Expose methods via ref
    useImperativeHandle(
      ref,
      () => ({
        focus: () => {
          editorViewRef.current?.focus();
        },
        setCursorPosition: (position: "start" | "end") => {
          if (editorViewRef.current) {
            const doc = editorViewRef.current.state.doc;
            const pos = position === "start" ? 0 : doc.length;
            editorViewRef.current.dispatch({
              selection: { anchor: pos, head: pos },
              scrollIntoView: true,
            });
          }
        },
        getEditor: () => editorViewRef.current,
      }),
      [],
    );

    useEffect(() => {
      if (editorRef.current) {
        setContainer(editorRef.current);
      }
    }, [setContainer]);

    return (
      <div
        ref={editorRef}
        onBlur={onBlur}
        onFocus={handleFocus}
        className={cn("text-sm", className)}
      />
    );
  },
);

CodeMirrorEditor.displayName = "CodeMirrorEditor";

export default CodeMirrorEditor;
registry/editor/extensions.ts
import {
  closeBrackets,
  closeBracketsKeymap,
  completionKeymap,
} from "@codemirror/autocomplete";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import {
  bracketMatching,
  defaultHighlightStyle,
  indentOnInput,
  syntaxHighlighting,
} from "@codemirror/language";
import { lintKeymap } from "@codemirror/lint";
import { EditorState, type Extension } from "@codemirror/state";
import {
  crosshairCursor,
  drawSelection,
  dropCursor,
  EditorView,
  keymap,
  rectangularSelection,
} from "@codemirror/view";

/**
 * Custom editor styles for notebook contexts
 */
export const notebookEditorTheme = EditorView.theme({
  // Transparent background so editor inherits from container
  // (overrides theme's background)
  "&.cm-editor": {
    backgroundColor: "transparent",
  },
  // Remove focus dotted outline
  "&.cm-focused": {
    outline: "none",
  },
  // Reset line padding so code aligns with output areas
  // (CodeMirror's base theme adds "padding: 0 2px 0 6px" to .cm-line)
  ".cm-line": {
    paddingLeft: "0",
  },
  // Mobile-friendly padding
  "@media (max-width: 640px)": {
    ".cm-content": {
      padding: "0.75rem 0.5rem",
    },
  },
  // Slightly thicker cursor for better visibility
  ".cm-cursor": {
    borderLeftWidth: "2px",
  },
});

/**
 * Core editor setup with all standard features
 * Includes: history, bracket matching, autocomplete, syntax highlighting
 */
export const coreSetup: Extension = (() => [
  history(),
  drawSelection(),
  dropCursor(),
  EditorState.allowMultipleSelections.of(true),
  indentOnInput(),
  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
  bracketMatching(),
  closeBrackets(),
  rectangularSelection(),
  crosshairCursor(),
  keymap.of([
    ...closeBracketsKeymap,
    ...defaultKeymap,
    ...historyKeymap,
    ...completionKeymap,
    ...lintKeymap,
  ]),
  notebookEditorTheme,
])();

/**
 * Minimal editor setup without autocomplete
 * Useful for AI prompts or simple text input
 */
export const minimalSetup: Extension = (() => [
  history(),
  drawSelection(),
  dropCursor(),
  EditorState.allowMultipleSelections.of(true),
  indentOnInput(),
  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
  bracketMatching(),
  closeBrackets(),
  rectangularSelection(),
  crosshairCursor(),
  keymap.of([
    ...closeBracketsKeymap,
    ...defaultKeymap,
    ...historyKeymap,
    ...lintKeymap,
  ]),
  notebookEditorTheme,
])();

/**
 * Default extensions bundle for notebook cells
 * Uses core setup - add your own theme on top
 */
export const defaultExtensions: Extension[] = [coreSetup];

/**
 * Extensions bundle without autocomplete
 */
export const minimalExtensions: Extension[] = [minimalSetup];
registry/editor/languages.ts
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import { python } from "@codemirror/lang-python";
import { sql } from "@codemirror/lang-sql";
import { indentUnit } from "@codemirror/language";
import type { Extension } from "@codemirror/state";

import {
  CELL_MAGIC_LANGUAGES,
  detectCellMagic,
  getCellMagicLanguage,
  ipythonHighlighting,
  ipythonIndent,
  ipythonStyles,
  ipythonStylesDark,
} from "./ipython";

/**
 * Supported languages for the CodeMirror editor
 */
export type SupportedLanguage =
  | "python"
  | "ipython"
  | "markdown"
  | "sql"
  | "html"
  | "javascript"
  | "typescript"
  | "json"
  | "plain";

/**
 * Get the CodeMirror language extension for a given language
 */
// PEP 8 specifies 4-space indentation for Python
const pythonIndent = indentUnit.of("    ");

export function getLanguageExtension(language: SupportedLanguage): Extension {
  switch (language) {
    case "python":
      return [python(), pythonIndent];
    case "ipython":
      return [
        python(),
        pythonIndent,
        ipythonIndent,
        ipythonHighlighting(),
        ipythonStyles,
        ipythonStylesDark,
      ];
    case "markdown":
      return markdown();
    case "sql":
      return sql();
    case "html":
      return html();
    case "javascript":
      return javascript();
    case "typescript":
      return javascript({ typescript: true });
    case "json":
      return json();
    default:
      return [];
  }
}

/**
 * Get the language extension for IPython content, detecting cell magics.
 *
 * If the content starts with a cell magic (e.g., %%html, %%bash),
 * returns the appropriate language extension for that magic.
 * Otherwise returns the standard IPython extension.
 *
 * @param content - The editor content to analyze
 * @returns Object with language extension and detected cell magic (if any)
 *
 * @example
 * // Cell magic - returns HTML language
 * getIPythonExtension("%%html\n<div>Hello</div>")
 * // { extension: html(), cellMagic: "html", language: "html" }
 *
 * // No cell magic - returns IPython
 * getIPythonExtension("%time x = sum(range(100))")
 * // { extension: [python(), ...], cellMagic: null, language: "ipython" }
 */
export function getIPythonExtension(content: string): {
  extension: Extension;
  cellMagic: string | null;
  language: SupportedLanguage;
} {
  const magic = detectCellMagic(content);

  if (magic) {
    const langId = getCellMagicLanguage(magic);
    const language = (
      langId in languageDisplayNames ? langId : "plain"
    ) as SupportedLanguage;

    // For cell magics, use the target language but add IPython decoration
    // for the first line (the %%magic declaration)
    const baseExtension = getLanguageExtension(language);
    return {
      extension: [
        baseExtension,
        ipythonHighlighting(),
        ipythonStyles,
        ipythonStylesDark,
      ],
      cellMagic: magic,
      language,
    };
  }

  // No cell magic - use standard IPython mode
  return {
    extension: getLanguageExtension("ipython"),
    cellMagic: null,
    language: "ipython",
  };
}

// Re-export cell magic utilities for consumers
export { CELL_MAGIC_LANGUAGES, detectCellMagic, getCellMagicLanguage };

/**
 * Language display names for UI
 */
export const languageDisplayNames: Record<SupportedLanguage, string> = {
  python: "Python",
  ipython: "IPython",
  markdown: "Markdown",
  sql: "SQL",
  html: "HTML",
  javascript: "JavaScript",
  typescript: "TypeScript",
  json: "JSON",
  plain: "Plain Text",
};

/**
 * File extensions mapped to languages
 */
export const fileExtensionToLanguage: Record<string, SupportedLanguage> = {
  ".py": "python",
  ".ipy": "ipython",
  ".md": "markdown",
  ".markdown": "markdown",
  ".sql": "sql",
  ".html": "html",
  ".htm": "html",
  ".js": "javascript",
  ".jsx": "javascript",
  ".ts": "typescript",
  ".tsx": "typescript",
  ".json": "json",
  ".txt": "plain",
};

/**
 * Detect language from filename
 */
export function detectLanguage(filename: string): SupportedLanguage {
  const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
  return fileExtensionToLanguage[ext] || "plain";
}
registry/editor/themes.ts
import type { Extension } from "@codemirror/state";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";

import {
  documentHasDarkMode,
  isDarkMode,
  prefersDarkMode,
  useDarkMode,
} from "@/lib/dark-mode";

// Re-export theme detection utilities from canonical location
export { documentHasDarkMode, isDarkMode, prefersDarkMode, useDarkMode };

/**
 * Theme mode options
 */
export type ThemeMode = "light" | "dark" | "system";

/**
 * Light theme - GitHub Light
 */
export const lightTheme: Extension = githubLight;

/**
 * Dark theme - GitHub Dark
 */
export const darkTheme: Extension = githubDark;

/**
 * Get the appropriate theme extension based on mode
 */
export function getTheme(mode: ThemeMode): Extension {
  if (mode === "light") {
    return lightTheme;
  }
  if (mode === "dark") {
    return darkTheme;
  }
  // System mode - detect from media query
  if (typeof window !== "undefined") {
    const prefersDark = window.matchMedia(
      "(prefers-color-scheme: dark)",
    ).matches;
    return prefersDark ? darkTheme : lightTheme;
  }
  // SSR fallback
  return lightTheme;
}

/**
 * Get the current theme based on automatic detection
 * Checks document class, color-scheme, data-theme attribute, and system preference
 */
export function getAutoTheme(): Extension {
  return isDarkMode() ? darkTheme : lightTheme;
}
registry/editor/ipython.ts
import { indentService } from "@codemirror/language";
import { RangeSetBuilder } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";

/**
 * IPython syntax highlighting extension for CodeMirror 6
 *
 * Highlights IPython-specific syntax on top of standard Python:
 * - Shell commands: !ls, !pip install
 * - Line magics: %time, %run script.py
 * - Cell magics: %%bash, %%javascript (first line only)
 * - Help operators: object?, object??
 *
 * Note: For cell magics (%%), only the first line is decorated.
 * The rest of the cell should use the appropriate language mode.
 * Use detectCellMagic() and CELL_MAGIC_LANGUAGES to determine
 * the correct language for cell magic content.
 */

// Decoration marks for IPython syntax
const shellMark = Decoration.mark({ class: "cm-ipython-shell" });
const magicMark = Decoration.mark({ class: "cm-ipython-magic" });
const cellMagicMark = Decoration.mark({ class: "cm-ipython-cell-magic" });
const helpMark = Decoration.mark({ class: "cm-ipython-help" });

// Patterns for IPython syntax (applied to line content)
const CELL_MAGIC_PATTERN = /^(%%[a-zA-Z_]\w*)/;
const LINE_MAGIC_PATTERN = /^(%[a-zA-Z_]\w*)/;
const SHELL_PATTERN = /^(!)/;
const HELP_PATTERN = /(\?\??)$/;

/**
 * Custom indentation service for IPython.
 *
 * Prevents auto-indent after:
 * - Line magics (%time, %matplotlib, etc.)
 * - Shell commands (!pip, !ls, etc.)
 * - Cell magic declarations (%%bash, %%html, etc.)
 *
 * These lines don't follow Python's indentation rules,
 * so we reset to column 0 after them.
 */
export const ipythonIndent = indentService.of((context, pos) => {
  // Get the previous line
  const line = context.state.doc.lineAt(pos);
  if (line.number <= 1) return null; // Let Python handle first line

  const prevLine = context.state.doc.line(line.number - 1);
  const prevText = prevLine.text.trim();

  // After line magic, shell command, or cell magic - don't auto-indent
  if (
    LINE_MAGIC_PATTERN.test(prevText) ||
    SHELL_PATTERN.test(prevText) ||
    CELL_MAGIC_PATTERN.test(prevText)
  ) {
    return 0;
  }

  // Let Python's indentation handle everything else
  return null;
});

/**
 * Mapping of cell magic names to language identifiers.
 * Use with getLanguageExtension() from languages.ts
 */
export const CELL_MAGIC_LANGUAGES: Record<string, string> = {
  // HTML/SVG
  html: "html",
  HTML: "html",
  svg: "html",
  SVG: "html",
  // JavaScript
  javascript: "javascript",
  js: "javascript",
  // TypeScript
  typescript: "typescript",
  ts: "typescript",
  // SQL
  sql: "sql",
  SQL: "sql",
  // Markdown
  markdown: "markdown",
  md: "markdown",
  // JSON
  json: "json",
  // Shell (falls back to plain since we don't have shell lang)
  bash: "plain",
  sh: "plain",
  // Python (stays as Python)
  python: "python",
  python3: "python",
  // Others without specific support fall back to plain
};

/**
 * Detect cell magic from content.
 * Cell magics must be on the first line.
 *
 * @param content - The editor content
 * @returns The magic name (without %%) if found, null otherwise
 *
 * @example
 * detectCellMagic("%%html\n<div>Hello</div>") // "html"
 * detectCellMagic("%time x = 1") // null (line magic, not cell magic)
 * detectCellMagic("print('hello')") // null
 */
export function detectCellMagic(content: string): string | null {
  const firstLine = content.split("\n")[0].trim();
  const match = firstLine.match(/^%%([a-zA-Z_]\w*)/);
  return match ? match[1] : null;
}

/**
 * Get the language identifier for a cell magic.
 *
 * @param magic - The cell magic name (without %%)
 * @returns The language identifier to use with getLanguageExtension(),
 *          or "plain" for unsupported magics
 *
 * @example
 * getCellMagicLanguage("html") // "html"
 * getCellMagicLanguage("bash") // "plain" (no shell support)
 * getCellMagicLanguage("unknown") // "plain"
 */
export function getCellMagicLanguage(magic: string): string {
  return CELL_MAGIC_LANGUAGES[magic] ?? "plain";
}

class IPythonHighlighter {
  decorations: DecorationSet;

  constructor(view: EditorView) {
    this.decorations = this.buildDecorations(view);
  }

  update(update: ViewUpdate) {
    if (update.docChanged || update.viewportChanged) {
      this.decorations = this.buildDecorations(update.view);
    }
  }

  buildDecorations(view: EditorView): DecorationSet {
    const builder = new RangeSetBuilder<Decoration>();
    const doc = view.state.doc;

    // Check if we're in cell magic mode (first line is %%)
    const firstLineText = doc.line(1).text.trim();
    const isInCellMagicMode = CELL_MAGIC_PATTERN.test(firstLineText);

    for (const { from, to } of view.visibleRanges) {
      const startLine = doc.lineAt(from).number;
      const endLine = doc.lineAt(to).number;

      for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
        const line = doc.line(lineNum);
        const lineText = line.text;
        const trimmedText = lineText.trimStart();
        const leadingSpaces = lineText.length - trimmedText.length;
        const lineStart = line.from + leadingSpaces;

        // Cell magic on first line - decorate it
        if (lineNum === 1) {
          const cellMagicMatch = trimmedText.match(CELL_MAGIC_PATTERN);
          if (cellMagicMatch) {
            builder.add(
              lineStart,
              lineStart + cellMagicMatch[1].length,
              cellMagicMark,
            );
            continue;
          }
        }

        // If we're in cell magic mode, don't apply IPython decorations
        // to subsequent lines (they belong to the cell magic's language)
        if (isInCellMagicMode && lineNum > 1) {
          continue;
        }

        // Line magic (%magic)
        const lineMagicMatch = trimmedText.match(LINE_MAGIC_PATTERN);
        if (lineMagicMatch) {
          builder.add(
            lineStart,
            lineStart + lineMagicMatch[1].length,
            magicMark,
          );
          continue;
        }

        // Shell command (!command)
        const shellMatch = trimmedText.match(SHELL_PATTERN);
        if (shellMatch) {
          builder.add(lineStart, line.to, shellMark);
          continue;
        }

        // Help operator (object? or object??)
        const helpMatch = lineText.match(HELP_PATTERN);
        if (helpMatch && helpMatch.index !== undefined) {
          const helpStart = line.from + helpMatch.index;
          const helpEnd = helpStart + helpMatch[1].length;
          builder.add(helpStart, helpEnd, helpMark);
        }
      }
    }

    return builder.finish();
  }
}

/**
 * CodeMirror extension that adds IPython syntax highlighting.
 *
 * For cells with cell magics (%%html, %%bash, etc.), only the first
 * line is decorated. Use detectCellMagic() and getLanguageExtension()
 * to set the appropriate language for the cell content.
 */
export function ipythonHighlighting() {
  return ViewPlugin.fromClass(IPythonHighlighter, {
    decorations: (v) => v.decorations,
  });
}

/**
 * Light theme styles for IPython syntax
 */
export const ipythonStyles = EditorView.theme({
  ".cm-ipython-shell": {
    color: "#0550ae",
  },
  ".cm-ipython-magic": {
    color: "#6639ba",
  },
  ".cm-ipython-cell-magic": {
    color: "#6639ba",
    fontWeight: "bold",
  },
  ".cm-ipython-help": {
    color: "#0969da",
  },
});

/**
 * Dark theme styles for IPython syntax
 */
export const ipythonStylesDark = EditorView.theme(
  {
    ".cm-ipython-shell": {
      color: "#79c0ff",
    },
    ".cm-ipython-magic": {
      color: "#d2a8ff",
    },
    ".cm-ipython-cell-magic": {
      color: "#d2a8ff",
      fontWeight: "bold",
    },
    ".cm-ipython-help": {
      color: "#58a6ff",
    },
  },
  { dark: true },
);
registry/editor/search-highlight.ts
/**
 * CodeMirror extension for highlighting search matches in the editor.
 *
 * Provides visual highlighting for find/search functionality with distinction
 * between regular matches (yellow) and the active/current match (orange).
 */

import { type Extension, RangeSetBuilder } from "@codemirror/state";
import {
  Decoration,
  type DecorationSet,
  EditorView,
  ViewPlugin,
  type ViewUpdate,
} from "@codemirror/view";

const searchMatchMark = Decoration.mark({ class: "cm-global-find-match" });
const activeMatchMark = Decoration.mark({
  class: "cm-global-find-match-active",
});

const searchHighlightTheme = EditorView.theme({
  ".cm-global-find-match": {
    background: "#fbbf24",
    color: "#000",
    borderRadius: "2px",
  },
  ".cm-global-find-match-active": {
    background: "#f97316",
    color: "#000",
    borderRadius: "2px",
  },
});

/**
 * Build decorations for all search matches in a document.
 * @param doc - The CodeMirror document text
 * @param query - Search query (case-insensitive)
 * @param activeOffset - Character offset of the active match (-1 for none)
 */
function buildDecorations(
  doc: string,
  query: string,
  activeOffset: number,
): DecorationSet {
  if (!query) return Decoration.none;

  const builder = new RangeSetBuilder<Decoration>();
  const lowerDoc = doc.toLowerCase();
  const lowerQuery = query.toLowerCase();
  let pos = lowerDoc.indexOf(lowerQuery, 0);

  while (pos !== -1) {
    const isActive = pos === activeOffset;
    builder.add(
      pos,
      pos + query.length,
      isActive ? activeMatchMark : searchMatchMark,
    );
    pos = lowerDoc.indexOf(lowerQuery, pos + query.length);
  }

  return builder.finish();
}

/**
 * Create a search highlight ViewPlugin for a given query.
 *
 * Note: Since ViewPlugin instances are static (cannot update config),
 * this returns a new extension each time the query changes. The parent
 * component should replace extensions when the query changes, which
 * is the standard pattern for react-codemirror.
 */
function createSearchHighlightPlugin(query: string, activeOffset: number) {
  return ViewPlugin.define(
    (view) => ({
      decorations: buildDecorations(
        view.state.doc.toString(),
        query,
        activeOffset,
      ),
      update(update: ViewUpdate) {
        if (update.docChanged) {
          this.decorations = buildDecorations(
            update.state.doc.toString(),
            query,
            activeOffset,
          );
        }
      },
    }),
    {
      decorations: (v) => v.decorations,
    },
  );
}

/**
 * Create a CodeMirror extension that highlights all matches of a search query.
 *
 * @param query - The search string to highlight (case-insensitive). Empty string = no highlights.
 * @param activeOffset - Character offset of the "active" match to highlight differently (-1 for none).
 * @returns A CodeMirror Extension array to pass to the editor.
 *
 * @example
 * ```tsx
 * import { searchHighlight } from "@/registry/editor/search-highlight";
 *
 * // Highlight all occurrences of "function"
 * <CodeMirrorEditor extensions={[searchHighlight("function")]} />
 *
 * // Highlight with active match at character offset 100
 * <CodeMirrorEditor extensions={[searchHighlight("function", 100)]} />
 * ```
 */
export function searchHighlight(query: string, activeOffset = -1): Extension[] {
  if (!query) return [];
  return [
    searchHighlightTheme,
    createSearchHighlightPlugin(query, activeOffset),
  ];
}
registry/editor/index.ts
export {
  CodeMirrorEditor,
  type CodeMirrorEditorProps,
  type CodeMirrorEditorRef,
} from "./codemirror-editor";
export {
  coreSetup,
  defaultExtensions,
  minimalExtensions,
  minimalSetup,
  notebookEditorTheme,
} from "./extensions";
export {
  CELL_MAGIC_LANGUAGES,
  detectCellMagic,
  getCellMagicLanguage,
  ipythonHighlighting,
  ipythonIndent,
  ipythonStyles,
  ipythonStylesDark,
} from "./ipython";
export {
  detectLanguage,
  fileExtensionToLanguage,
  getIPythonExtension,
  getLanguageExtension,
  languageDisplayNames,
  type SupportedLanguage,
} from "./languages";
export {
  darkTheme,
  documentHasDarkMode,
  getAutoTheme,
  getTheme,
  isDarkMode,
  lightTheme,
  prefersDarkMode,
  type ThemeMode,
} from "./themes";

More from nteract

Browse all components in this library →