@do-md/core-react
v0.6.0
Published
A Markdown WYSIWYG editor for React
Readme
@do-md/core-react
A from-scratch Markdown WYSIWYG editor kernel for React.
The Markdown document itself is the editing source of truth — there is no separate
internal model to keep in sync. The kernel handles parsing, rendering, editing,
undo/redo, and streaming injection directly against the text. It is platform-agnostic:
the host application supplies platform capabilities (image resolution, syntax
highlighting) through injection points — and can extend the inline syntax itself
declaratively, from a [[wikilink]] to a fully custom React-rendered
<{.mention id=11}Name>, without touching the parse pipeline (see
"Custom inline syntax").
Install
npm i @do-md/core-reactPeer dependencies (you provide these):
react>=18— requiredreact-dom>=18— requiredimmer^10— required; the store is Immer-based and shares your app's Immer instance
Quick start
Wrap the editor surface in a DOMDProvider and render <DOMD /> inside it. Import the
stylesheet once.
import { DOMDProvider, DOMD } from "@do-md/core-react";
import "@do-md/core-react/style.css";
export function MyEditor() {
return (
<DOMDProvider initMd={"# Title\n\nStart writing…"}>
<DOMD />
</DOMDProvider>
);
}Render read-only by passing editable={false} — the caret is suppressed and links
navigate on click.
Reading and writing Markdown
Use useEditorStoreApi() for imperative one-off reads/writes:
import { useEditorStoreApi } from "@do-md/core-react";
function ExportButton() {
const store = useEditorStoreApi();
return (
<button onClick={() => console.log(store?.toMarkdown())}>Export Markdown</button>
);
}Use useEditorStore(selector) for reactive reads — the component re-renders only when
the selected slice changes:
import { useEditorStore } from "@do-md/core-react";
function CursorReadout() {
const cursor = useEditorStore((s) => s.startCursorInfo);
return <span>{cursor ? `block ${cursor.uuid} @ ${cursor.offset}` : "no cursor"}</span>;
}Streaming injection
Feed model output into the document chunk by chunk. Partial constructs — open code fences, half-built tables, unfinished lists — render correctly as they arrive.
import { useEditor } from "@do-md/core-react";
function useStreamIntoEditor() {
const editor = useEditor();
return async (stream: AsyncIterable<string>) => {
for await (const chunk of stream) {
editor?.aiInsertInCursor(chunk);
}
};
}Injection points
The core ships no platform assumptions. Wire host capabilities through provider props:
<DOMDProvider
imageLoader={async (src) => toDisplayableUrl(src)} // resolve an app-specific ref to a URL the browser can render
codeTokenizer={(code, lang) => highlight(code, lang)} // supply your own syntax-highlight tokens for code blocks
>
<DOMD />
</DOMDProvider>Custom inline syntax (inlineRules)
The kernel hardcodes only standard-Markdown inline syntax. Everything beyond it —
including the built-in ==highlight== — is a declarative rule. Register your own
delimiters through the inlineRules prop; rules run inside the editor's single parse
pipeline on every keystroke, so Markdown round-trip, undo, cursor behavior and
collaboration all work with no extra wiring.
import { DOMDProvider, defaultInlineRules } from "@do-md/core-react";
<DOMDProvider
inlineRules={[
...defaultInlineRules, // keep ==highlight== (omit to replace it)
// ^superscript^
{ open: "^", close: "^", exactLen: true, allowSpace: false, tagName: "sup" },
// ~subscript~ (~~strikethrough~~ still works)
{ open: "~", close: "~", exactLen: true, allowSpace: false, tagName: "sub" },
// [[wikilink]] (`[links](…)` are unaffected)
{ open: "[[", close: "]]", tagName: "span", className: "wikilink" },
]}
>
<DOMD />
</DOMDProvider>A rule is open + close (they may differ — asymmetric delimiters work), plus how
the span renders. Delimiters are punctuation strings; ` \ { } can never
be part of one.
Passing inlineRules replaces the default set — spread defaultInlineRules to
keep ==. Rules are compiled once at mount (remount the provider to change them),
and collaborative peers must be configured with the same set (same contract as
codeTokenizer).
{…} parameters
Every rule accepts an optional Pandoc/Djot-style parameter block right after the
opening delimiter: {.variant .class #id key=value key2="quoted value"}. Params
drive rendering through declarative attrs templates ({key} = named param,
{} = first positional):
// %%{bg=red}text%% → translucent red highlight, no pre-provisioned CSS needed
{
open: "%%", close: "%%", tagName: "mark",
attrs: { style: "background-color: color-mix(in srgb, {bg} 80%, transparent)" },
}- Attr targets are whitelisted:
class,style,href,title,id,data-*,aria-*. Event handlers (on*) are unreachable,javascript:hrefs are blocked, and style text compiles into a structured style object. - An attr referencing a missing param is voided entirely — never rendered half-substituted.
- Every
.wordlands as a class, so%%{.warn}text%%is stylable from plain CSS. - The raw
{…}text stays verbatim in the document — params affect presentation only and can never corrupt the Markdown.
Variants — one delimiter, many meanings
The first .word selects a variant: the same == syntax can highlight by
default and become a comment, a mention, anything — per occurrence:
{
open: "==", close: "==", tagName: "mark",
variants: {
comment: { tagName: "q", className: "comment" },
},
}
// ==plain highlight== → <mark>
// =={.comment author=w}text== → <q class="comment …">Unregistered variants degrade gracefully: the .word is just a class, and other
Pandoc-family tools still recognize the attribute syntax.
Custom render components
For rendering that CSS can't express (icons, click handlers, tooltips), a rule or
variant may declare a React component — antd-style, replacing the kernel element.
Components bind to variants (semantics), and variants are plain data you can
attach to any delimiter — syntax and semantics stay orthogonal:
import { viewOnlyProps, type InlineRuleComponentProps } from "@do-md/core-react";
function MentionSpan({ domProps, children, params }: InlineRuleComponentProps) {
return (
<span {...domProps} onClick={() => openProfile(params.named.id)}>
<span {...viewOnlyProps} className="badge">@</span>
{children}
</span>
);
}
const mention = { tagName: "span", className: "mention", component: MentionSpan };
// The same semantic on two syntaxes:
inlineRules={[
{ open: "==", close: "==", tagName: "mark", variants: { mention } },
{ open: "<", close: ">", tagName: "span", variants: { mention } },
]}
// =={.mention id=11}Jintao Wang== ≡ <{.mention id=11}Jintao Wang>Components receive the full occurrence context: params (named / positional /
classes / id), variant, rawCapture, contentText and the dispatched tagName.
Overlays (popovers, tooltips) should render through a portal to document.body so
they never enter the editable DOM.
Hard contract (violations break cursor mapping): spread domProps on your root
element, render children verbatim, and spread viewOnlyProps on every
decoration element you add (badges, icons — anything that is not document
text). The marker keeps decorations out of the DOM→model text pipeline;
without it a text-bearing badge would be read back as typed input and
duplicate on every reparse. A throwing component falls back to the kernel's
default rendering — the document is never at risk.
Reserved delimiters
Structural Markdown delimiters (<, >, [, *, ~~) can be registered too.
Such rules only fire on shapes the builtin never produces, so existing documents
keep parsing:
{…}disambiguation —<{.mention id=1}Name>fires your</>rule;<https://x.com>and<u>…</u>still parse as before.- Longest-prefix precedence —
[[wikilink]]beats the builtin[link parse;[link](url)is untouched.
Precedence is always: your rule → builtin syntax → literal text.
Credits
This API grew out of do-md/domd#13. Thanks to Paul Hammant for the concrete, well-argued feature request — it pushed on exactly the right boundary and made the extension surface better than what either side first proposed. The example syntaxes follow kotaindah55/extended-markdown-syntax.
Embed / submit-on-Enter mode
For chat-style inputs, rebind the "real newline" key so a bare Enter yields to the host
and fires onEnter:
<DOMDProvider
newlineKey="Shift+Enter" // Shift+Enter inserts a newline; bare Enter is freed
onEnter={(store) => submit(store.toMarkdown())}
>
<DOMD />
</DOMDProvider>API reference
Components
DOMDProvider— context provider; configures the editor and hosts the surface.DOMD— the editor surface. Render inside aDOMDProvider.
DOMDProvider props
childrenReactNode— editor surface; render<DOMD />here.initMdstring— initial Markdown document.editableboolean— whenfalse, renders read-only (no caret; links navigate).placeholderstring— shown when the document is empty.imageLoader(src: string) => Promise<string>— resolve an image reference to a displayable URL.codeTokenizer(code: string, lang?: string) => unknown[]— provide syntax-highlight tokens for code blocks.codeBeautify(code: string, lang?: string) => string | undefined— optional code formatter, invoked on Enter inside a code block; returnundefinedto skip.inlineRulesInlineRule[]— declarative custom inline syntax (see "Custom inline syntax"); replaces the default set (defaultInlineRules).newlineKey"Shift+Enter"or"Mod+Enter"— rebind the newline key so bare Enter firesonEnter.onEnter(store: EditorStoreApi, event: KeyboardEvent) => void— called on bare Enter whennewlineKeyis set.
Hooks
useEditor()→Editorornull— imperative handle:focus(),aiInsertInCursor(text),editorStore.useEditorStore(selector)→T— reactive selector; re-renders when the selected slice changes.useEditorStoreApi()→EditorStoreApiornull— imperative store handle for one-off reads/writes.useRenderData()→RenderData— current parsed-document handle (opaque).useEditorDom()→{ textAreaDomRef }— ref to the editable DOM node.
EditorStoreApi (selected members)
toMarkdown()string— serialize the document to Markdown.resetMD(md)void— replace the whole document.insertText(text)void— insert text at the cursor.insertImage(url, altText?)void— insert an image at the cursor.getTitle()string— plain-text title derived from the first block.getSelectionState(contextChars?)SelectionState— selection / cursor snapshot.startCursorInfoCursorInfoornull— reactive cursor position.isEditableboolean— reactive editable flag.duringCompositionboolean— reactive IME-composition flag.subscribe(listener)() => void— subscribe to any store change; returns an unsubscribe fn.
Utilities
toMarkdown(data: RenderData)→string(ornull) — serialize aRenderDatahandle (fromuseRenderData()) to Markdown.defaultInlineRulesInlineRule[]— the shipped rule set (==highlight); spread it to compose.viewOnlyProps— prop bag for decoration elements inside inline-rulecomponentrenders (marks the subtree invisible to the text pipeline +contentEditable={false}+aria-hidden).DATA_VIEW_ONLYstring— the underlying decoration-marker attribute name, for non-JSX consumers.- Types:
InlineRule,InlineRuleVariant,InlineRuleParams,InlineRuleComponentProps.
License
PolyForm Noncommercial 1.0.0. Distributed as a prebuilt build artifact. Free for personal, educational, and non-commercial open-source use. Commercial use requires prior written authorization from the author.
