A standalone, TipTap-based rich text editor for React. Extracted from Inscript, a self-hosted markdown CMS, so it can be reused across other projects.
- Rich text editing built on TipTap v3 — bold/italic/underline/strike, headings, lists, blockquotes, code blocks, sub/superscript, text alignment, font size, text/highlight color, and links.
- Images with inline resizing, alignment, and a media-library modal (bring your own upload/list backend).
- Tables with per-cell/row/column controls, header row/column toggles, and layout alignment.
- YouTube embeds by URL or search, with strict ID validation and a placeholder for legacy/corrupt content instead of a broken embed.
- Version history with a visual/text/source diff view and one-click restore.
- Responsive toolbar that collapses overflowing tools into a "More" menu based on measured width.
- i18n-ready: English out of the box (safe even if you never touch i18next), and add any language from your own app with one call.
- Ships as ESM + CJS, with hand-written TypeScript types.
npm install inscript-editorPeer dependencies (install alongside, matching the ranges in package.json):
npm install react react-dom \
@tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/suggestion \
@tiptap/extension-code-block-lowlight @tiptap/extension-color @tiptap/extension-highlight \
@tiptap/extension-image @tiptap/extension-link @tiptap/extension-subscript \
@tiptap/extension-superscript @tiptap/extension-table @tiptap/extension-table-cell \
@tiptap/extension-table-header @tiptap/extension-table-row @tiptap/extension-task-item \
@tiptap/extension-task-list @tiptap/extension-text-align @tiptap/extension-text-style \
@tiptap/extension-underline lowlight lucide-react diff react-i18next i18nextMath (KaTeX) and Mermaid load their heavy libraries via a dynamic import() only
when a math/diagram node actually renders, so they are declared as optional
peers and kept out of this package's bundle.
# Math (KaTeX). Also import its stylesheet once in your app (see below).
npm install katex
# Mermaid diagrams.
npm install mermaidIf you use math, import the KaTeX stylesheet yourself — it is intentionally not
bundled into inscript-editor/styles:
import 'katex/dist/katex.min.css';If you use a bundler (Vite, webpack, etc.) you must install katex/mermaid
whenever those features are enabled — even though the import() is dynamic, the
bundler still resolves the specifier at build time, so a missing package is a
build error, not a graceful runtime fallback. The at-render fallback (the node
degrades to showing its raw source) only applies in non-bundled contexts such as
Node/SSR. To ship without them, either disable the features so they aren't part of
your build's intent (see below) and alias/externalize the specifiers in your
bundler, or simply install the packages.
Disable either feature entirely via useInscriptEditor options (math: false,
mermaid: false) so the extension is never registered.
import { useInscriptEditor, InscriptEditor } from 'inscript-editor';
import 'inscript-editor/styles'; // already running Tailwind? see "Styling" first
function PostEditor({ post }) {
const api = useInscriptEditor({
contentKey: post.id, // one editor per document...
initialContent: post.html, // ...created with its content (the "Opened" version)
title: post.title,
editorOptions: {
onShowMediaLibrary: () => {/* open your <ImageSelectorModal> */},
onAddYoutube: () => {/* open your <YoutubeEmbedModal> */},
},
onContentChange: (entry, { reason }) => {
// reason: 'edit' | 'undo' | 'redo' | 'restore'. Persist entry.html here.
},
});
// Spread the hook's result in: the toolbar's Undo/Redo and the history panel work as is.
return <InscriptEditor {...api} />;
}useInscriptEditor owns the TipTap editor instance and a client-side version history; InscriptEditor renders the toolbar, bubble menus, content area, and (when showDiff is true) the history diff view. Everything else (saving, uploads, YouTube search) stays in your app, wired in through props and callbacks.
A load is not an edit: it must not create a version, mark the document dirty, or trigger a save. Two ways to do it right:
initialContent(above): the editor is created with the document's content, which becomes its baseline version ("Opened"). Best when you create one editor per document (contentKey).loadContent(html, options): load into the existing editor, for example when the user opens another file, or when the file changed on disk.
const { loadContent } = api;
loadContent(file.html, { title: file.title, kind: 'opened' }); // a document is opened
loadContent(changedHtml, { keepHistory: true }); // same document, changed elsewhere
loadContent(savedStack[savedIdx].html, { history: savedStack, historyIndex: savedIdx }); // reopen a persisted stackReopening a persisted stack adds no version when you load its active entry's HTML (or HTML that parses to the same document, such as a file whose Markdown renders it), so the pointer and redo stay where the user left them. Different content is recorded as "Changed outside the app".
Switching documents in one editor? Typing from the last second is still waiting for its
debounced commit, and a document switch drops it (so it can't land in the next document). Call
flush() first: it commits that typing to the current document and returns the stack
synchronously, ready to save before you switch.
const { history, historyIndex } = api.flush(); // commit the last keystrokes to the outgoing document
saveDraft(currentId, { history, historyIndex });
setCurrentId(nextId); // then switch (documentKey changes on render)loadContent seeds the baseline, clears the dirty flag, never calls onContentChange, drops a
pending edit (so a keystroke just before a document switch can't land in the next document) and
starts keyboard undo fresh (so Cmd/Ctrl+Z can't undo the load). Don't use editor.commands.setContent
or the ref handle's setContent to open a document: those record an edit.
Pass documentKey (the document's id) when the editor can be recreated without the document
changing (a contentKey or editorOptions change): history then belongs to the document and
survives the recreation, content and pending edits included. A documentKey change resets the
history when it renders, so load the new document after that render (in an effect keyed on the
document, deferred as below), not in the same event handler that switches the key.
Calling
loadContentfromuseEffect? Nodes rendered by React (embeds, admonitions, footnotes, math, mermaid, wikilinks) mount throughflushSync, which React refuses (and logs "flushSync was called from inside a lifecycle method") during its commit phase. Load from an event handler, useinitialContent, or defer the call:useEffect(() => { queueMicrotask(() => loadContent(html)); }, [docId]).
Versions are an append-only list with a pointer:
- Edits append a version (debounced 1s), and so does an edit made after an undo, so undone versions are never lost (redo is disabled after it, as users expect).
- Toolbar Undo/Redo move the pointer (
undo()/redo()). Undo commits pending typing first, so it can be redone. Keyboard undo (Cmd/Ctrl+Z) is finer-grained: it undoes typing within the current version. - Restore in the history panel appends a
restoredversion (restoreVersion(i, { reason: 'restore' })). onContentChange(entry, { reason })fires for every one of these ('edit','undo','redo','restore'), so saving from it saves them all. Loads never fire it.- Each entry is plain JSON:
{ id, kind, html, title, tags, categories, timestamp }, withkindone ofopened,imported,edited,restored,external(plusrestoredFrom/parentIdlinks), so you can persist a document's stack and hand it back toloadContent. - A cap keeps memory bounded:
maxHistory(default 200 versions) andmaxHistoryBytes(default about 20 MB of HTML). The oldest versions go first; the baseline and the active version never do.
The editor's own strings (toolbar tooltips, modal copy, etc.) ship in English, under a dedicated inscript-editor i18next namespace separate from your app's own translations. English is the single source of truth (it is also the built-in defaultValue on every string), so adding a feature never means translating it into a dozen languages. Bring any other language yourself, from your own app.
Zero-config: if your app already initializes i18next and renders <InscriptEditor> (or any of the individually-exported modals/bubble-menus), the library auto-registers English into your active i18next instance on mount. Nothing else to do.
No i18next at all? Every string carries an English defaultValue, so the editor renders correctly in English even if your app never sets up i18next.
Adding a language, or rewording the defaults, from the inscript-editor/locales subpath:
import i18n from 'i18next';
import { registerInscriptEditorTranslations } from 'inscript-editor/locales';
registerInscriptEditorTranslations(i18n, {
overrides: {
de: { undo: 'Rückgängig', redo: 'Wiederholen' }, // add a language (any keys from en.json)
en: { insertImage: 'Add Image' }, // ...or reword an English default
},
overwrite: false, // default: resources you registered first always win
});
i18n.changeLanguage('de');Call this before mounting the editor if you want your overrides to apply from the first render — the library's own auto-registration uses overwrite: false, so anything you've already registered under the inscript-editor namespace is left alone.
You can also reach for plain i18next APIs post-hoc:
i18n.addResourceBundle('en', 'inscript-editor', { insertImage: 'Add Image' }, true, true);inscriptEditorTranslations (also exported from inscript-editor/locales) gives you the raw { [lang]: bundle } map if you want to inspect or reuse it directly.
| Option | Type | Default | Description |
|---|---|---|---|
contentKey |
string |
'' |
Changing this recreates the TipTap editor. Without a documentKey it also names the document, so history and dirty state reset when it changes. Pass a filename or document id. |
documentKey |
string | number |
Names the document. When set, history resets only when this changes (or on loadContent without keepHistory); recreating the editor (contentKey, editorOptions) keeps the content and history. |
|
initialContent |
string |
The document's HTML, set when an editor is created and seeded as its baseline version. See Loading a document. | |
title |
string |
'' |
Live title, read inside the debounced update handler without recreating the editor. |
tags |
string[] |
[] |
Live tags, same live-ref treatment as title. |
categories |
string[] |
[] |
Live categories, same live-ref treatment as title. |
isReadonly |
boolean |
false |
Disables editing without recreating the editor. |
onContentChange |
(entry, { reason }) => void |
null |
Called for every change the user causes: an edit (~1s after typing settles), an undo, a redo or a restore, with the now-active entry and reason ('edit', 'undo', 'redo', 'restore'). Never called for a load. |
editorOptions |
EditorBuildOptions |
{} |
Per-editor feature toggles plus the host handlers onAddCitation / onAddWikilink / onAddAbbreviation / onShowMediaLibrary / onAddYoutube. Pass each host handler once here and it drives both the slash menu and the toolbar/bubble buttons (the matching <InscriptEditor> props are optional per-call overrides). Options apply at construction: changing their serializable part recreates the editor (keeping the document); functions are read live, so a new function identity never recreates it. |
maxHistory |
number |
200 |
Most versions kept. |
maxHistoryBytes |
number |
~20 MB | Most HTML kept across versions (in characters). |
Returns { editor, history, historyIndex, isDirty, canUndo, canRedo, loadContent, flush, undo, redo, restoreVersion, markSaved, setIsDirty, titleRef, historyRef, historyDebounceRef, isSyncingRef, isLoadingRef }, plus the deprecated setHistory / setHistoryIndex (use loadContent instead; they still work).
loadContent(html, { title, tags, categories, kind, keepHistory, history, historyIndex }): see Loading a document.flush(): commit pending typing now and return{ history, historyIndex }synchronously (before switching documents).undo()/redo(): step through versions; each notifies the host.restoreVersion(index, { reason }):'restore'appends arestoredversion (the history panel);'undo'/'redo', or no reason, move the pointer.
| Prop | Type | Description |
|---|---|---|
editor |
Editor | null |
From useInscriptEditor. Component renders null until this is set. |
isReadonly |
boolean |
Hides the toolbar and disables bubble menus. |
showDiff |
boolean |
Swaps the content area for <HistoryView>. |
history, historyIndex |
Passed through to <HistoryView>. |
|
originalContent |
{ html, title, tags, categories } |
The diff view's reference. Defaults to the first version (the document as opened). Its html is rendered as markup, so it must be editor-serialized HTML (from getHTML() or a history entry). |
canUndo, canRedo |
boolean |
Drive the toolbar's undo/redo buttons. |
onHistoryUndo, onHistoryRedo |
() => void |
Undo/redo handlers. Default to the hook's undo / redo when you spread its result in. |
onShowMetadataModal, hasMetadata, showMetadataActive |
Tags/categories entry point — bring your own modal. | |
onShowMediaLibrary |
() => void |
Opens your <ImageSelectorModal>. |
onAddYoutube |
() => void |
Opens your <YoutubeEmbedModal>. |
onAddCitation, onAddWikilink, onAddAbbreviation |
() => void |
Optional per-call overrides of the matching editorOptions handlers for the Citation / Wikilink / Abbreviation toolbar/bubble buttons. Prefer supplying each handler once via useInscriptEditor({ editorOptions }) (see the editorOptions row above) so it also drives the slash menu; a prop here wins when both are set. When neither is given, a built-in native prompt is used. |
onHistorySelect |
(index, entry) => void |
Called when a version is chosen for restore in <HistoryView>. Defaults to restoreVersion(index, { reason: 'restore' }). |
restoreVersion, loadContent, flush, undo, redo, markSaved |
From useInscriptEditor, exposed through the imperative ref too. |
|
theme |
InscriptEditorTheme |
Colors, surfaces, borders, radii, spacing and fonts for the whole editor. See Theming. |
Imperative ref: ref.current.getHTML(), .getText(), .loadContent(html, options), .flush(), .undo(), .redo(), .restoreVersion(index, options), .markSaved(), and .setContent(html), which records an edit (use loadContent to open a document).
The history panel's previews are inert: embeds show a placeholder naming their source instead of loading (the YouTube node serializes a live iframe), and nothing in a version can run.
Youtube, FontSize, CustomTable, CustomImage — TipTap extensions used internally, exported so you can build a headless editor with the same schema (e.g. for tests) via @tiptap/core's Editor.
getTableNode(state),isHeaderRowActive(editor),isHeaderColumnActive(editor),setTableLayout(editor, attrs)— table introspection/commands used byTableBubbleMenu.getTextContent(html)— strips HTML to plain text; SSR-safe (falls back to a regex strip whenDOMParseris unavailable).extractYoutubeId(input)— extracts an 11-character YouTube video ID from a known URL shape or a bare ID string; returnsnullotherwise.
Every piece is also exported standalone if you want to compose your own layout instead of <InscriptEditor>: ToolbarButton, ColorSelector, FontSizeSelector, LinkSelector, ResponsiveToolbar, ImageSelectorModal, YoutubeEmbedModal, HistoryView, TextBubbleMenu, TableBubbleMenu, ImageBubbleMenu, YoutubeBubbleMenu.
Built with Tailwind CSS (v4) utility classes baked into the compiled dist/styles/inscript-editor.css, so you don't need Tailwind in your own app to use it. If your app does not run Tailwind, import the stylesheet once:
import 'inscript-editor/styles';The full bundle is a complete Tailwind build (reset, theme, utilities). Next to your own Tailwind
build it silently breaks your layout: the two utilities layers merge, and the bundle's plain
.hidden, .fixed, .flex, ... come later in the cascade than your md: / dark: variants, so
they win (a fixed md:sticky sidebar stays fixed on desktop). Nothing errors; things just move.
Import only the editor's content and token rules, and let your Tailwind generate the utilities the editor's components use, by scanning the package:
/* Tailwind v4 (CSS-first). The @source path is relative to this CSS file. */
@import "tailwindcss";
@import "inscript-editor/styles/content";
@source "../node_modules/inscript-editor/dist";// Tailwind v3 (tailwind.config.js): contentGlob resolves the package wherever it is installed.
import { contentGlob } from 'inscript-editor/tailwind-content';
export default { content: ['./src/**/*.{js,jsx,ts,tsx}', contentGlob] };Dark mode follows Tailwind's dark: class strategy — add/remove a dark class on an ancestor element (e.g. <html>) to toggle it.
Every color, surface, border, radius, spacing knob and font in the editor is a CSS custom property (--inscript-*) with a default that reproduces the built-in look. Restyle the whole editor with the theme prop: pass any subset, and each field accepts any CSS value (hex, rgb(), a var() reference, a length, a font stack):
<InscriptEditor
editor={editor}
theme={{
accent: '#7c3aed', // links, active toolbar buttons, table selection
surface: '#ffffff', // editor background
surfaceRaised: '#f7f7f8', // toolbar, popovers, menus
text: '#1a1a1a',
fontFamily: 'Georgia, serif',
headingFont: '"Playfair Display", serif',
fontSize: '1.2rem',
maxWidth: '68ch',
radius: '0.75rem',
blockGap: '1.25rem',
}}
/>A token you set applies in both light and dark (you own that value). A token you don't set keeps its default and still swaps automatically for dark mode. For distinct dark values, set the variables yourself under your own .dark selector instead of (or alongside) the prop:
.dark .inscript-editor { --inscript-color-accent: #a78bfa; }That works because the prop just sets the same --inscript-* variables inline on the editor scope, so any ancestor CSS can override them too.
Available tokens (theme key → CSS variable):
| Group | Keys |
|---|---|
| Text | text, heading, muted |
| Surfaces & borders | surface, surfaceRaised, border, borderStrong, hover, active |
| Accent & links | accent, onAccent, link, linkHover |
| Code | codeBg, codeText, codeBorder |
| Quotes | quoteBorder, quoteText |
| Tables | tableBorder, tableHeaderBg |
| Marks & selection | markBg, markText, selection |
| Typography | fontFamily, fontSize, lineHeight, headingFont, monoFont, headingWeight, h1Size, h2Size, h3Size |
| Layout & shape | maxWidth, focusMaxWidth, blockGap, radius, radiusSm, minHeight, focusDimOpacity |
Each key fooBar maps to the CSS variable --inscript-… (e.g. surfaceRaised → --inscript-color-surface-raised, radius → --inscript-radius). Interactive hover/active overlays are semi-transparent so they read correctly on any surface color you pick. onAccent is the text/icon color on accent-filled buttons; its default clears WCAG AA against the default accent, so if you pick a dark accent set onAccent too (e.g. '#fff').
The token map is exported as THEME_VAR_MAP (with buildThemeVars) from the package root, so a host can translate its own design tokens to inscript's programmatically.
Content-semantic palettes are intentionally not themed: syntax highlighting, admonition types (note/tip/warning/…), and the <MiniMap>'s content colors identify a kind of content, not your brand.
Running your own Tailwind? Use
inscript-editor/styles/contentinstead of the full bundle; see Already using Tailwind?.
<MiniMap> is a spatial outline, not a shrunk photo of the text. Prose has weak
silhouette, so instead of near-identical grey blocks it makes the picture a function of the
content and keeps the whole document in view:
- Readable heading landmarks — headings render as real, truncated text at their true position, sized and indented by level (an H1 gets a full-width rule). Click a label to jump to that heading. This is what lets you actually follow the page.
- Content-derived body texture — paragraphs are per-line bars with a ragged last line and colored inline runs (links, inline code); lists, tables, code and quotes keep their real counts/shape.
- Real image thumbnails — images draw their actual bitmap, the strongest anchor in prose.
- Fit to panel — the entire document is scaled into the available height, so the labels never scroll out of reach; a draggable viewport marker shows where you are.
Pass showHeadingText={false} to collapse headings to level ticks (labels off).
<MiniMap> reads CSS custom properties (with fallbacks equal to its default zinc/emerald
look), so a themed host restyles it by setting variables on any ancestor — no CSS overrides:
.my-right-rail {
--im-minimap-bg: var(--surface); /* panel background */
--im-minimap-border: var(--line); /* panel + header border */
--im-minimap-label: var(--ink); /* header label text + heading landmarks + ticks */
--im-minimap-line: var(--ink-dim); /* neutral body lines / list markers */
--im-minimap-viewport: var(--brand); /* draggable viewport marker */
}Content-semantic colors (image sky, code emerald, table violet, link blue, …) are
intentionally not themed — they identify content types. Sizing: pass width (inline style,
beats the default 9rem) and/or className (appended to the root, e.g. border-l-0 to drop
the built-in border when the minimap lives inside your own bordered panel). The minimap fits
the document to its own height, so give it a bounded-height container (it falls back to a
natural scale with internal scroll if the height is unbounded).
<MiniMap editor={editor} width="6rem" className="border-l-0" /><DocumentOutline> and <MiniMap> come with their own panel chrome (a header with a collapse
button, a border, a fixed width). To put either inside a panel of your own, pass chrome={false}:
it renders just the content, fills its container, and ignores any stored collapsed state (there is
no header to expand it from). No CSS overrides or localStorage clearing needed.
<aside className="my-panel">
<header>Outline</header>
<DocumentOutline editor={editor} chrome={false} />
</aside>collapsible={false} keeps the header but removes the collapse button. Both components also take
width and className. Match the minimap's label backing to your panel with --im-minimap-bg.
The settings-gear customizer drawer covers the viewport (position: fixed) by
default. To scope it to your editor panel instead, pass that panel's element as
customizerContainer — the drawer renders into it and positions absolute
within it. The element must be position: relative; overflow: hidden.
const panelRef = useRef(null);
// ...
<div ref={panelRef} className="relative overflow-hidden">
<InscriptEditor editor={editor} customizerContainer={panelRef.current} … />
</div>npm run build # one-off build (dist/inscript-editor.{es,cjs}.js + dist/styles, content.css included)
npm run dev # watch mode: rebuilds all of dist/, styles/content.css included, on every change
npm test # vitest
npm run test:dist # build, then check the built output (also exercises watch-mode rebuilds)
npm run coverage # vitest with coverage report
npm run clean # empty dist/ (prepublishOnly does this before its build)To try unpublished changes in an app, point it at this checkout's dist/ (a bundler alias,
npm link, or a file: dependency) and run npm run dev here. Builds never empty dist/ and
write every file atomically, so the host never reads a missing or half-written bundle mid-rebuild,
and styles/content.css stays current for hosts on the
content-only route (point their @source
at the same dist/). The host still reloads after a rebuild: a library bundle that exports
constants next to components can't be Fast Refreshed.
Unit and component tests run under Vitest + Testing Library + jsdom (npm test). Deliberately deferred: end-to-end/browser tests — the only genuinely E2E-only surfaces are bubble-menu positioning (floating-ui), table column-resize drag, and the YouTube iframe click-overlay, all low-churn. Everything else is covered at the unit/component level.
MIT © Harsh Ankur