Skip to content

plannotator/atomic-editor

 
 

Repository files navigation

Atomic Editor (Plannotator fork)

This is Plannotator's fork of kenforthewin/atomic-editor, published as @plannotator/atomic-editor. It tracks upstream closely and carries a small set of changes we need (currently: YAML frontmatter parsing), each also offered upstream as a PR. Unless you specifically want those changes, prefer the original @atomic-editor/editor.

Obsidian-style live preview for CodeMirror 6, in React.

npm version license

A markdown editor where formatting renders as you type — headings, bold, tables, images, task lists — while the text underneath stays plain markdown. The document you read is the document you edit: no split preview, no mode toggle, and copy / save / round-trip behave exactly like a plain textarea full of markdown.

It's the writing surface behind Atomic, a personal knowledge base — extracted to stand on its own, and hardened on real user documents.

Try the live demo →

Features

  • Live preview. Headings, emphasis, links, images, and tables render inline; the raw syntax appears only on the line your cursor is on, then tucks itself away when you move on.
  • Raw markdown is the source of truth. Every decoration is view-only, so copy, save, and round-trip through any other markdown tool are byte-for-byte identical to a plain textarea.
  • Virtualized and layout-stable. CM6 renders only the viewport, and lines never reflow when you click into them — open a 500-page document and scroll stays smooth, even on iOS.
  • WYSIWYG tables. Click a cell to edit in place; wide tables scroll horizontally inside a contained wrapper instead of stretching the page.
  • Wiki links. [[target]] / [[target|label]] with async resolution, autocomplete, and click-to-open — for knowledge-base-style cross-linking.
  • Smart lists. Enter continues tight bullets and task checkboxes, Enter on an empty item dedents, and - [ ] becomes a real, clickable checkbox.
  • Syntax-highlighted code for 20+ languages, each grammar lazy-loaded the first time a fence uses it so unused languages never hit the wire.
  • Themed with CSS variables — dark by default, light via a single data-theme="light" attribute, every color overridable.
  • Minimal find panel (Ctrl/Cmd+F) styled to match the editor.
  • Frozen inline diffs. Two revisions render as one review document with inline insertions/deletions, change navigation, gutter rails, and complete unchanged context.

Install

npm install @atomic-editor/editor \
  @codemirror/state @codemirror/view @codemirror/commands \
  @codemirror/autocomplete @codemirror/language @codemirror/search \
  @codemirror/lang-markdown @codemirror/merge \
  @lezer/common @lezer/highlight \
  react react-dom

The CodeMirror and React packages are declared as peer dependencies rather than regular deps. You install them alongside the editor so your bundler resolves a single shared copy — two copies of @codemirror/state in one bundle would silently break the editor's state-field identity checks.

Fenced-code language grammars (@codemirror/lang-javascript, @codemirror/lang-python, etc.) are optional peers — install only the ones you want highlighted. See Syntax highlighting below.

Use

import { AtomicCodeMirrorEditor } from '@atomic-editor/editor';
import '@atomic-editor/editor/styles.css';

function App() {
  return (
    <AtomicCodeMirrorEditor
      markdownSource={'# Hello\n\nA paragraph.'}
      onMarkdownChange={(md) => console.log(md)}
      onLinkClick={(url) => window.open(url, '_blank', 'noopener,noreferrer')}
    />
  );
}

The editor fills its parent — wrap it in a height-bounded flex or grid container.

Frozen inline diff

AtomicDiffEditor renders the newer revision as the document and projects deletions where they used to be. It is structurally separate from the editable component: the content DOM is not editable, task/table controls are static, and document-changing transactions are rejected at both the CodeMirror state and view-dispatch boundaries.

import { AtomicDiffEditor, wikiLinks } from '@atomic-editor/editor';
import '@atomic-editor/editor/styles.css';

<AtomicDiffEditor
  originalMarkdown={savedRevision}
  modifiedMarkdown={currentRevision}
  extensions={[
    wikiLinks({
      resolve: (target) => store.resolve(target),
      onOpen: (target) => router.open(target),
    }),
  ]}
/>;

The same extensions seam used by the editable surface is appended after the diff and built-in Markdown decorations. Unchanged frontmatter, tables, images, tasks, and wiki links keep their rich reader rendering. When a change intersects one of those atomic ranges, its source is shown so the diff cannot disappear behind a replacement widget.

The entire newer document remains visible, including every unchanged line. The overview rail on the right maps additions, deletions, and replacements across the file and jumps to the nearest change when clicked. It also supports arrow-key navigation when focused.

Useful options:

  • allowInlineDiffs, highlightChanges, and gutter — all default to true.
  • showOverview={false} — hide the clickable change overview rail.
  • diffConfig={{ scanLimit, timeout }} — override the bounded large-document diff policy when a host has different latency/precision needs.
  • showToolbar={false} — hide the built-in count and previous/next navigation when the host supplies its own controls through AtomicDiffEditorHandle.

The handle's getMarkdown() and getOriginalMarkdown() return the exact input strings, including CRLF line endings; they do not reconstruct text from CodeMirror's normalized rendering model.

Imperative handle

Pass a ref if you need to drive the editor from outside — e.g. wire your own toolbar buttons, or open the search panel from a global keybinding:

import { useRef } from 'react';
import {
  AtomicCodeMirrorEditor,
  type AtomicCodeMirrorEditorHandle,
} from '@atomic-editor/editor';

function App() {
  const editor = useRef<AtomicCodeMirrorEditorHandle | null>(null);
  return (
    <>
      <button onClick={() => editor.current?.openSearch()}>Search</button>
      <AtomicCodeMirrorEditor
        markdownSource={'…'}
        editorHandleRef={editor}
      />
    </>
  );
}

Methods: focus, undo, redo, openSearch(query?), closeSearch, revealText(query), isSearchOpen, getMarkdown, getContentDOM.

Arriving from a search result

Two props drop the user near a relevant paragraph on mount:

  • initialSearchText opens the search panel pre-filled with the query. Full navigation surface — arrow keys to step through matches, close to dismiss. Good when the user explicitly invoked find.
  • initialRevealText does a less intrusive scroll-into-view with a 3.2 s fade-out highlight on the first match — no panel, no cursor move. Good for "I clicked a search result, take me to the paragraph it came from".

Both accept string | null. The reveal matcher falls back progressively — exact, whitespace-collapsed, individual lines, then truncated prefixes (140 and 80 chars) — so hits still resolve when the query came from an LLM-massaged snippet that doesn't match the source byte-for-byte. For post-mount reveals, call editorHandle.revealText(query) via the imperative handle.

The fade highlight uses CSS variables --atomic-editor-initial-reveal-bg and --atomic-editor-initial-reveal-bg-strong; override to theme the peak and settled colors independently of the main search-match palette.

Syntax highlighting

Fenced code blocks are plain monospace by default. To enable highlighting, pass a codeLanguages array. @codemirror/lang-markdown dynamically imports each grammar the first time a fence uses it, so large lists don't bloat the initial bundle.

Option 1: use the curated list (~20 languages)

# Install the lang-* peers you want highlighted.
npm install \
  @codemirror/lang-javascript @codemirror/lang-python \
  @codemirror/lang-rust @codemirror/lang-go @codemirror/lang-html \
  @codemirror/lang-css @codemirror/lang-json @codemirror/lang-yaml \
  @codemirror/legacy-modes  # ruby/swift/shell/toml/dockerfile
import { AtomicCodeMirrorEditor } from '@atomic-editor/editor';
import { ATOMIC_CODE_LANGUAGES } from '@atomic-editor/editor/code-languages';

<AtomicCodeMirrorEditor
  markdownSource={'…'}
  codeLanguages={ATOMIC_CODE_LANGUAGES}
/>

See src/code-languages.ts for the full list (JavaScript, TypeScript, Python, Go, Rust, Ruby, Java, C, C++, PHP, Swift, Shell, SQL, HTML, CSS, XML, JSON, YAML, TOML, Dockerfile, Markdown).

Option 2: bring your own

import { LanguageDescription } from '@codemirror/language';
import { python } from '@codemirror/lang-python';

const codeLanguages = [
  LanguageDescription.of({
    name: 'Python',
    alias: ['py'],
    extensions: ['py'],
    load: () => Promise.resolve(python()),
  }),
];

<AtomicCodeMirrorEditor markdownSource={'…'} codeLanguages={codeLanguages} />

Wiki links

[[target]] and [[target|label]] links — the way Atomic and Obsidian cross-link notes — ship as a composable extension. It renders labeled links, resolves bare targets asynchronously (to show a real title and a resolved / missing state), opens links on click, and offers autocomplete as soon as you type [[:

import { AtomicCodeMirrorEditor, wikiLinks } from '@atomic-editor/editor';

<AtomicCodeMirrorEditor
  markdownSource={'See [[atom-42|the design doc]] for details.'}
  extensions={[
    wikiLinks({
      suggest: async (query) => store.search(query),     // autocomplete source
      resolve: async (target) => store.resolve(target),  // label + status for bare links
      onOpen: (target) => router.open(target),           // click / Cmd-click to navigate
    }),
  ]}
/>;

Draft links stay editable while the cursor is inside them; resolution is debounced and cached. See src/wiki-links.ts for the full config — custom serialization, resolver policies, suggestion limits, and the WikiLinkSuggestion / WikiLinkResolvedTarget types.

Theming

Every color, font, and size reads from a CSS custom property with an inline fallback. Override on any ancestor of the editor.

The package ships a light variant that activates whenever data-theme="light" is set on an ancestor — including <html> or <body>. The dark defaults remain unchanged; the light block just re-maps the same variables.

<html data-theme="light"></html>
Variable Dark default (auto-light on [data-theme="light"])
--atomic-editor-font system sans
--atomic-editor-font-mono system mono
--atomic-editor-body-size 1.0625rem
--atomic-editor-body-leading 1.7
--atomic-editor-measure 70ch
--atomic-editor-fg #dcddde
--atomic-editor-fg-muted #888
--atomic-editor-fg-faint #666
--atomic-editor-bg #1e1e1e
--atomic-editor-bg-panel #252525
--atomic-editor-bg-surface #2d2d2d
--atomic-editor-border #3d3d3d
--atomic-editor-accent #7c3aed
--atomic-editor-accent-bright #a78bfa
--atomic-editor-accent-soft blockquote rail / reveal tint
--atomic-editor-link #818cf8
--atomic-editor-link-hover #a5b4fc
--atomic-editor-code-bg subtle dark panel
--atomic-editor-selection-bg accent-tinted 28%
--atomic-editor-search-bg accent-tinted 28%
--atomic-editor-search-bg-active accent-tinted 60%
Code-token colors (Palenight)
--atomic-editor-hl-keyword #c792ea
--atomic-editor-hl-string #c3e88d
--atomic-editor-hl-number #f78c6c
--atomic-editor-hl-comment #6a7a82
--atomic-editor-hl-type #ffcb6b
--atomic-editor-hl-function #82aaff
--atomic-editor-hl-property #82aaff
--atomic-editor-hl-regexp #f07178
--atomic-editor-hl-escape #89ddff
--atomic-editor-hl-tag #f07178
--atomic-editor-hl-variable #eeffff
--atomic-editor-hl-operator #89ddff
--atomic-editor-hl-invalid #ff5370

Extending with plugins

CodeMirror 6 is extension-based, and so is this package. Pass any number of CM6 extensions via the extensions prop to layer in autocomplete sources, custom decorations, domain-specific keymaps, collaboration (yjs), vim mode, or anything else. (The wiki-links extension above is built with exactly this hook.)

import { autocompletion, type CompletionContext } from '@codemirror/autocomplete';

const hashtags = autocompletion({
  override: [(ctx: CompletionContext) => {
    const match = ctx.matchBefore(/#\w*$/);
    if (!match) return null;
    return {
      from: match.from + 1,
      options: myTagStore.list().map((tag) => ({ label: tag })),
    };
  }],
});

<AtomicCodeMirrorEditor
  markdownSource={'…'}
  extensions={[hashtags]}
/>

Consumer extensions are appended after the built-ins, so wrap a custom keymap in Prec.high (from @codemirror/state) if it needs to beat the default bindings. The array is captured at mount — pass a stable reference unless you want a remount.

Low-level composition

If the React wrapper's extension set is too opinionated, every piece is exported individually so you can assemble a fully custom editor:

import {
  inlinePreview, // live preview decorations
  imageBlocks,   // rendered image widgets
  tables,        // WYSIWYG table widget
  wikiLinks,     // [[...]] links
  atomicEditorTheme,
  atomicMarkdownSyntax,
  extendEmphasisPair,
} from '@atomic-editor/editor';

You could build an editor that includes inlinePreview() + tables() but skips atomicEditorTheme for your own EditorView.theme({...}), or swap atomicMarkdownSyntax for a custom syntaxHighlighting(HighlightStyle.define([...])). At that point you're outside the React wrapper and in plain CM6 territory.

Design notes

See docs/architecture.md for the full design rationale. Short version:

  • Raw markdown is the source of truth. All decorations are view-only — copy, save, and round-trip to any markdown parser are identical to what you'd expect from a plain textarea.
  • No layout shifts. Every line has a stable height regardless of cursor position. Inline decorations hide syntax tokens on inactive lines without changing line heights.
  • Narrow invalidation. Decoration rebuilds only touch lines whose content (or surrounding trigger characters) changed, so editing a paragraph in a 50KB doc costs O(change size), not O(doc).
  • Mouse-freeze guard. Clicks don't trigger a decoration rebuild mid-interaction — eliminates a class of cursor-drift bugs.
  • iOS-aware. Momentum-scroll halts (image remount jank, heightmap drift, anchor conflicts) were tracked down and fixed; the demo's sample-size picker doubles as a stress harness for spotting any regressions.

Contributing

git clone https://github.com/kenforthewin/atomic-editor
cd atomic-editor
npm install
npm run dev        # demo dev server at http://localhost:5173
npm test           # vitest unit tests
npm run build      # tsc emit to dist/
npm run test:e2e   # Playwright probe suite against the demo

The Playwright suite (scripts/test-editor.mjs) is the primary regression-catching tool — around 50 probes covering CLS during idle / scroll / typing, click-freeze timing, every block-type decoration (headings, lists, tasks, tables, images, fences, HRs, wiki links), cursor-scoped link reveal, copy-as-raw-markdown, tight-list continuation, escape handling, and late-doc rendering via the parser-progress mechanic. Run after any change to the editor's extensions.

Because the editor ships inside Atomic, real user documents are its de-facto fuzz corpus — odd inputs (multi-line link titles, over-escaped RSS imports, wide tables) tend to surface there first, and fixes land here. Issues and PRs welcome.

License

MIT. See LICENSE.

About

Plannotator fork of atomic-editor — CodeMirror 6 markdown live-preview editor, published as @plannotator/atomic-editor (adds YAML frontmatter parsing)

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages

  • TypeScript 74.8%
  • JavaScript 17.0%
  • CSS 8.2%