Naat Editor Engine
Naat Editor — highly customizable rich text.
Own document model, transactions, history, and DOM bridge. Customize toolbar buttons and dropdowns, enabled marks/blocks, and theme tokens — then export HTML, JSON, Markdown, or an embed snippet. Install into your project.
Own WYSIWYG engine — not TipTap, Lexical, or Quill. This demo runs in your browser; ship it with npm i @naatchaal/editor in your Client Components.
Start typing to export…
Install & integrate
Simple to drop in. Deep when you need it.
Most apps use a few imports and props — create/edit content, save to the DB, validate. Theme, toolbar chrome, icons, and layout composition stay available for the 10% that need full control. Copy a playground snippet or embed <NaatEditor /> — no TipTap, Lexical, or Quill.
Start here
- Install
@naatchaal/editor(npm i @naatchaal/editor). Peers: React 18+. - Import
NaatEditorfrom@naatchaal/editorin a Client Component ("use client"). Default styles load with that import; optional@naatchaal/editor/styles.cssfor explicit order or non-bundler setups. - Pass
initialContent(and a Reactkeywhen editing another row), wireonChange, validate with the form helpers.configis optional.
"use client";
import { NaatEditor } from "@naatchaal/editor";
// Styles load automatically. Optional: import "@naatchaal/editor/styles.css"
export function MyEditor() {
return (
<NaatEditor
initialContent="<p>Hello Naat</p>"
onChange={({ html, json }) => {
// persist
}}
/>
);
}Textarea-like knobs: placeholder, and Editor height via config.theme.surface.minHeight (default 16rem → --ne-surface-min-height; surface grows with content) and optional maxHeight (cap + scroll inside → --ne-surface-max-height + overflow-y: auto). Tailwind override on the surface: min-h-* / max-h-* / overflow-y-auto.
<NaatEditor
placeholder="Write something…"
config={{ theme: { surface: { minHeight: "12rem", maxHeight: "20rem" } } }}
/>Typical wrapper: Client Component with a record key, initialContent, and onChange (html / json / doc). Hooks are optional.
Prefill & edit from the DB
The editor is uncontrolled after mount. Pass any saved payload as initialContent — DocNode, JSON string, HTML, or plain text. When the edited record changes, remount with a React key (for example key={questionId}). Persist with onChange into form state or your API.
import {
NaatEditor,
isEmptyContent,
getTextLength,
type DocNode,
} from "@naatchaal/editor";
// Create or edit — remount when the record id changes
<NaatEditor
key={questionId}
initialContent={savedHtmlOrJson}
onChange={({ html, json, doc }) => {
// write html / json / doc into your form field
}}
/>
// Also valid seeds
<NaatEditor initialContent={doc as DocNode} />
<NaatEditor initialContent="<p>From <strong>HTML</strong></p>" />
<NaatEditor initialContent={"Title\n\nBody paragraph"} />View saved content
After you persist onChange HTML / JSON / DocNode, render it on a read-only page (Q&A answer, article view, etc.) in one of two ways.
Path A — NaatEditorViewer — Client Component with the same surface typography as the editor. No toolbar, focus trap, or provider. Links open in a new tab.
"use client";
import { NaatEditorViewer } from "@naatchaal/editor";
// Styles load with @naatchaal/editor; optional styles.css import
<NaatEditorViewer
content={savedHtmlOrJsonOrDoc}
padding="1.25rem"
border="1px solid #e2e8f0"
borderRadius="12px"
className="my-answer"
/>Chrome shortcuts map to --ne-viewer-*: padding, background, border, borderRadius, boxShadow. Or style via className / style / .naat-editor-viewer. For async fetch, pass loading={isPending} (optional renderLoading); empty content alone does not imply loading.
Path B — render HTML yourself — if you already store the html string from onChange:
<div
dangerouslySetInnerHTML={{ __html: savedHtml }}
/>No DOMPurify in the package — trust your saved export or sanitize upstream before either path.
Form essentials
Pure, framework-agnostic helpers for Zod, React Hook Form, Yup, or hand-rolled checks. Accept the same inputs as initialContent, or a DocNode from onChange.
The editor does not persist to localStorage / sessionStorage. Your app owns drafts: read storage into initialContent (with a key), write in onChange. Only touch storage in a Client Component / after mount — not during SSR.
import {
isEmptyContent,
getTextLength,
getPlainText,
validateContent,
parseContent,
} from "@naatchaal/editor";
// Zod — field stores HTML (or JSON) from onChange
z.string().refine((html) => !isEmptyContent(html), "Required");
z.string().refine((html) => getTextLength(html) <= 500, "Too long");
// One-liner
const { ok, errors } = validateContent(html, {
required: true,
minLength: 10,
maxLength: 2000,
});
// Optional: normalize any saved blob before use
const doc = parseContent(savedHtmlOrJson);Validation UI
Chrome only — you own the rules with validateContent / Zod / RHF. Same product pattern as everywhere else: default → toggle → customize.
- Default — pass
error(trueor a string); red outline on the shell and the message under the surface. - Off —
showError={false}keepsaria-invalidwithout chrome. - Customize —
--ne-error-*/theme.error,renderError,invalidClassName.
const v = validateContent(html, { required: true, minLength: 10 });
<NaatEditor
error={v.ok ? undefined : v.errors[0]}
onChange={({ html }) => setHtml(html)}
/>
// outline only (no message): error
// a11y only (no chrome): error="…" showError={false}
// custom message UI: renderError={(msg) => <p>{msg}</p>}Content preview
The default toolbar includes a Preview action (after media, before undo/redo) that opens a read-only HTML modal. Omit preview from toolbar.items to turn it off; optional previewTitle (default "Preview"). Style with .naat-editor-preview-* / --ne-preview-*.
Link hover preview
Same pattern: default → toggle → customize. While editing, plain clicks on links do not navigate (caret stays put). Hover shows the URL with an Open control (target=_blank, rel=noopener noreferrer). Ctrl/Cmd+click opens immediately.
- Default — on (omit or
linkPreview/linkPreview={true}). - Off —
linkPreview={false}. - Customize —
renderLinkPreview, or.naat-editor-link-preview/--ne-link-preview-*.
<NaatEditor
// linkPreview — default true
// linkPreview={false}
renderLinkPreview={({ href, onOpen, onClose }) => (
<div>
<button type="button" onClick={onOpen}>{href}</button>
<button type="button" onClick={onClose}>Close</button>
</div>
)}
/>Form helpers (hooks) — optional
You do not need hooks. NaatEditor + validateContent is enough. These are optional extras only — no generic UI hooks like useDisclosure (use Mantine / your own).
useNaatEditor— context (compose path)useNaatEditorField— form state +editorPropsuseNaatEditorValidation— thinvalidateContent→error
const field = useNaatEditorField({ initialContent: savedHtml });
const v = useNaatEditorValidation({ content: field.value, required: true });
<NaatEditor {...field.editorProps} error={v.error} />Customize deeply
Same package. Theme every visual token, invent dropdown rows, inject custom controls, own the layout, or compose Provider + Toolbar + Surface — the simple path above still works.
import {
NaatEditor,
NaatEditorProvider,
NaatToolbar,
NaatSurface,
} from "@naatchaal/editor";
// 1) Theme to the dot
<NaatEditor
config={{
theme: {
accent: "#16a34a",
radius: "16px",
shell: { background: "#fff", border: "#e2e8f0", shadow: "0 8px 30px rgba(0,0,0,.08)" },
toolbar: { background: "#0B1F3A", padding: "0.75rem", gap: "0.4rem" },
button: { color: "#fff", hoverColor: "#86efac", borderRadius: "10px" },
surface: { padding: "1.5rem", minHeight: "20rem", fontSize: "1.05rem" },
},
toolbar: {
items: [
{ type: "button", id: "bold" },
{ type: "separator" },
{
type: "dropdown",
id: "insert",
label: "Insert",
items: [
{ id: "image", label: "Image", action: "image" },
{ id: "video", label: "Video", action: "video" },
],
},
{
type: "custom",
id: "emoji",
render: () => <button type="button">😀</button>,
},
],
},
}}
/>
// 2) Own layout (toolbar anywhere)
<NaatEditor
renderLayout={({ toolbar, surface }) => (
<div style={{ display: "grid", gridTemplateColumns: "220px 1fr" }}>
<aside>{toolbar}</aside>
<main>{surface}</main>
</div>
)}
/>
// 3) Full composition
<NaatEditorProvider config={...}>
<div className="naat-editor">
<NaatSurface />
<NaatToolbar />
</div>
</NaatEditorProvider>Style chrome (shell / toolbar / surface), not content marks: config.theme writes --ne-* vars on .naat-editor. Editor height (surface only): theme.surface.minHeight / maxHeight (--ne-surface-min-height / --ne-surface-max-height; unset maxHeight → grow with content). For other chrome, override vars in CSS or pass className on NaatEditor / NaatToolbar / NaatSurface. Tailwind works via descendants, e.g. [&_.naat-editor-toolbtn]:rounded-lg, or surface min-h / max-h / overflow-y-auto.
<NaatEditor
className="rounded-2xl shadow-sm [&_.naat-editor-toolbtn]:rounded-lg"
style={{ ["--ne-accent" as string]: "#0ea5e9" }}
/>
/* or plain CSS */
.naat-editor {
--ne-toolbar-bg: #0b1f3a;
--ne-btn-color: #fff;
}Toolbar icons ship as built-in SVGs (no lucide-react required). Override any action with icons and/or renderIcon.
import { NaatEditor } from "@naatchaal/editor";
// Partial swaps
<NaatEditor icons={{ bold: MyBold, image: MyImage }} />
// Full control — return null to keep the default SVG
<NaatEditor
renderIcon={(id, props) =>
id === "bold" ? <span className={props.className}>B</span> : null
}
/>Toolbar display & fonts
Show icon (default), label, or icon-label for every control via toolbar.display, or override per item. Toolbar / button / menu fonts live on theme.toolbar, theme.button, and theme.menu.
toolbar: {
display: "icon",
items: [
{ type: "button", id: "bold" },
{ type: "button", id: "italic", display: "icon-label" },
{ type: "button", id: "link", display: "label", label: "Link" },
],
}
theme: {
toolbar: { fontFamily: "Georgia, serif", fontSize: "0.9rem" },
button: { fontWeight: "600" },
}Toolbar config (buttons + dropdowns)
config.toolbar.items is an array of button, dropdown, separator, and custom entries. Omit config entirely for the default toolbar.
toolbar: {
display: "icon",
items: [
{ type: "button", id: "bold" },
{ type: "button", id: "italic" },
{
type: "dropdown",
id: "heading",
label: "Heading",
items: ["heading1", "heading2", "heading3", "paragraph"],
},
{
type: "dropdown",
id: "fontSize",
label: "Size",
items: ["sm", "base", "lg", "xl"], // or "14px"
},
{
type: "dropdown",
id: "textColor",
label: "Color",
items: ["#0B1F3A", "#dc2626", "#2563eb"],
},
{
type: "dropdown",
id: "highlight",
label: "Highlight",
items: ["#fef08a", "#bbf7d0", "transparent"],
},
{ type: "button", id: "image" },
{ type: "button", id: "video" },
],
}- button — single toolbar action (
bold,bulletList,image, …) - dropdown — menu of related actions, or a value menu when
idisfontSize,textColor, orhighlight
Marks & blocks
enabledMarks—bold,italic,underline,strike,code,link,fontSize,textColor,highlightenabledBlocks—paragraph,heading,bulletList,orderedList,codeBlock,blockquote,image,video,horizontalRuleconfig.theme— accent, radius, and nested chrome tokens
Image / video toolbar buttons prompt for a URL (https://). Video accepts YouTube, Vimeo, or a direct media URL; iframe embeds use safe attributes only. Blob URLs are allowed for local demos.
Document JSON shape
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello ", "marks": [{ "type": "bold" }] },
{
"type": "text",
"text": "world",
"marks": [
{ "type": "fontSize", "size": "lg" },
{ "type": "textColor", "color": "#2563eb" }
]
}
]
},
{
"type": "image",
"attrs": { "src": "https://example.com/photo.jpg", "alt": "Demo" }
},
{
"type": "video",
"attrs": {
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"provider": "youtube"
}
}
]
}Architecture (short)
- Document model — immutable JSON tree (
doc → blocks → inlines + marks). - Transactions — every edit is a pure transform with selection updates.
- History — undo/redo stacks of document snapshots.
- DOM bridge — model ↔
contenteditablesync viabeforeinputand controlled renders.
Package layout
packages/naat-editor— publishable@naatchaal/editor(core, dom, react, theme, extensions)- Import from
@naatchaal/editor(styles included). Optional export:@naatchaal/editor/styles.css
AI assistants (Cursor / Claude)
Machine-readable docs so agents can implement and customize the editor without guessing TipTap APIs.
- /editor/llms.txt — short overview
- /editor/llms-full.txt — full API + recipes
- /editor/AGENTS.md — hard rules for agents
- MCP:
npx -y @naatchaal/editor-mcp— tools for search, toolbar build, snippet generation, config validation
{
"mcpServers": {
"naat-editor": {
"command": "npx",
"args": ["-y", "@naatchaal/editor-mcp"]
}
}
}Privacy
This playground edits in your browser. In your app, you own persistence via onChange (API/DB) — the package does not send content to Naatchaal.