@naatchaal/editor
v1.0.3
Published
First-party customizable rich-text / WYSIWYG editor by Naatchaal — document model, transactions, toolbar buttons & dropdowns.
Maintainers
Readme
@naatchaal/editor
First-party customizable rich-text / WYSIWYG engine by Naatchaal.
Playground & docs: https://naat.tools/editor
Highly customizable to the deep — but simple. Most apps need a few imports and props. Theme, toolbar chrome, icons, and layout composition are there when you go deeper — without a second package or breaking the simple path.
No TipTap, Lexical, or Quill — own document model, transactions, history, and DOM bridge.
Start here
Install, drop in <NaatEditor />, seed content, persist on change, validate. Defaults cover a full toolbar.
Install
npm install @naatchaal/editorPeer dependencies: react, react-dom (≥18).
Quick start (Next.js App Router)
NaatEditor is a Client Component — wrap it in a file with "use client" (or import from one).
"use client";
import { NaatEditor } from "@naatchaal/editor";
// Default styles load with the import above.
// Optional: import "@naatchaal/editor/styles.css" for explicit order / non-bundlers.
export function MyEditor() {
return (
<NaatEditor
initialContent="<p>Hello from Naat Editor</p>"
onChange={({ html, json }) => {
// persist html or json
}}
/>
);
}config is optional — omit it to use the built-in toolbar, marks, blocks, and theme.
Common wrapper
"use client";
export function ArticleEditor({
id,
html,
onHtmlChange,
}: {
id: string;
html?: string;
onHtmlChange: (html: string) => void;
}) {
return (
<NaatEditor
key={id}
initialContent={html ?? ""}
onChange={({ html }) => onHtmlChange(html)}
/>
);
}Prefill & edit from the DB
Uncontrolled after mount — seed once, then persist via onChange.
| Prop | Type | Notes |
|------|------|--------|
| initialContent | DocNode \| string | DocNode, JSON string, HTML, or plain text |
Edit an existing record: remount with a React key so the editor re-seeds.
<NaatEditor
key={questionId}
initialContent={savedHtmlOrJson}
onChange={({ html, json }) => {
// write to form state / DB
}}
/>Forms (RHF / Formik / native): store the string (or DocNode) from onChange. Prefer key={recordId} when switching rows — there is no controlled value prop (remount instead).
Drafts / browser storage: the package does not write localStorage / sessionStorage. Own drafts in the app — read storage into initialContent (pair with key), write from onChange. Only touch storage on the client / after mount (SSR will not have window).
View saved content
Two paths — pick one:
Path A — NaatEditorViewer (recommended for matching create/edit typography):
"use client";
import { NaatEditorViewer } from "@naatchaal/editor";
// Styles load with @naatchaal/editor; optional: import "@naatchaal/editor/styles.css"
export function AnswerView({ html }: { html: string }) {
return (
<NaatEditorViewer
content={html}
// optional chrome shortcuts → `--ne-viewer-*`
padding="1.25rem"
border="1px solid #e2e8f0"
borderRadius="12px"
// or className / style / CSS overrides
/>
);
}Client Component ("use client"). Same ContentInput as initialContent. No toolbar / provider. Links open in a new tab (target=_blank + rel). Chrome: padding / background / border / borderRadius / boxShadow, or className / style / .naat-editor-viewer / --ne-viewer-*. For async fetch, pass loading={isPending} (optional renderLoading); empty content alone does not imply loading.
Security (view path): NaatEditorViewer / contentToViewerHtml never inject raw HTML. DocNode/JSON/plain go through parseContent → docToHtml (schema tags + escaped text). HTML strings go through sanitizeHtmlForView (strips <script>, event handlers, javascript: / data: URLs, untrusted iframes; allows YouTube/Vimeo embeds). Prefer storing json/DocNode when you can.
Path B — render HTML yourself (e.g. from DB html column):
<div
className="prose"
dangerouslySetInnerHTML={{ __html: savedHtml }}
/>Path B is not hardened by the package. Prefer Path A. If you must inject HTML yourself, run sanitizeHtmlForView(savedHtml) first (or server-side sanitize). For multi-tenant UGC, also sanitize on the server before storage — client view hardening is a last line of defense, not a substitute for trust boundaries.
Editor height + placeholder (like <textarea>)
{/* Default: grows with content from minHeight */}
<NaatEditor
placeholder="Write something…"
config={{ theme: { surface: { minHeight: "12rem" } } }}
/>
{/* Cap + scroll inside when content exceeds maxHeight */}
<NaatEditor
config={{ theme: { surface: { minHeight: "12rem", maxHeight: "20rem" } } }}
/>Editor height (writing surface only; toolbar uncapped):
| Knob | Behavior |
|------|----------|
| theme.surface.minHeight | Floor height (CSS default 16rem → --ne-surface-min-height) |
| theme.surface.maxHeight | Cap; enables --ne-surface-overflow-y: auto so content scrolls inside. Unset → surface grows with content. |
Tailwind / className override on NaatSurface: e.g. min-h-48 max-h-80 overflow-y-auto. Placeholder CSS: --ne-placeholder-color.
Form essentials
import {
NaatEditor,
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) >= 20, "Too short");
const { ok, errors } = validateContent(html, {
required: true,
minLength: 10,
maxLength: 2000,
});
const doc = parseContent(savedHtmlOrJson);| Helper | Use |
|--------|-----|
| parseContent | Normalize DocNode / JSON / HTML / plain → DocNode |
| isEmptyContent | Required-field checks (media counts as content) |
| getPlainText / getTextLength | Min/max length |
| validateContent | One-liner { ok, errors } for required / min / max |
Validation UI
Presentational only — your app owns the rules (validateContent, Zod, RHF, …). Pass the result into the editor chrome.
Default — when error is set, red shell outline + message under the surface:
const v = validateContent(html, { required: true, minLength: 10 });
<NaatEditor
error={v.ok ? undefined : v.errors[0]}
onChange={({ html }) => setHtml(html)}
/>
// invalid without copy
<NaatEditor error />Off — keep aria-invalid, hide outline + message:
<NaatEditor error="Answer is required" showError={false} />Customize — CSS vars (--ne-error-border, --ne-error-color, …), theme tokens, renderError, invalidClassName:
<NaatEditor
error="Max 500 characters"
invalidClassName="ring-2 ring-red-500"
renderError={(msg) => <p className="text-sm text-red-600">{msg}</p>}
config={{ theme: { error: { border: "#b91c1c", color: "#b91c1c" } } }}
/>Philosophy for every feature: default out of the box → turn on/off → customize deeply.
Content preview (toolbar)
The default toolbar includes a Preview button (after media, before undo/redo). It opens a read-only modal of the current document as HTML (same export as onChange).
Default — on (included in DEFAULT_CONFIG / buildGroupedToolbar when "preview" is listed).
Off — omit "preview" from toolbar.items (or from the action list you pass to buildFlatToolbar / buildGroupedToolbar).
Customize — previewTitle="…", or style .naat-editor-preview-* / --ne-preview-*.
<NaatEditor previewTitle="Article preview" />
// without preview:
<NaatEditor
config={{
toolbar: {
items: buildGroupedToolbar([
"bold", "italic", "link", "heading1", "image", "undo", "redo",
// no "preview"
]),
},
}}
/>Link hover preview
While editing, plain clicks on links do not navigate (caret stays intact). Hover shows a small bubble with the URL and an Open button (target=_blank, rel=noopener noreferrer). Ctrl/Cmd+click also opens immediately.
Default — on (omit the prop or pass linkPreview).
Off — linkPreview={false}.
Customize — renderLinkPreview, or style .naat-editor-link-preview / --ne-link-preview-*.
<NaatEditor
// linkPreview // default true
// linkPreview={false}
renderLinkPreview={({ href, onOpen, onClose }) => (
<div className="my-link-chip">
<button type="button" onClick={onOpen}>{href}</button>
<button type="button" onClick={onClose}>×</button>
</div>
)}
/>Form helpers (hooks) — optional
You do not need hooks. <NaatEditor /> + validateContent / isEmptyContent is enough.
Optional extras only (no generic UI hooks like useDisclosure — use Mantine / your own):
| Hook | Role |
|------|------|
| useNaatEditor | Context inside provider (compose path) |
| useNaatEditorField | Form state + editorProps (remount via key on setValue / reset) |
| useNaatEditorValidation | Thin wrapper: validateContent → error for chrome |
const field = useNaatEditorField({ initialContent: savedHtml });
const v = useNaatEditorValidation({
content: field.value,
required: true,
minLength: 10,
});
<NaatEditor {...field.editorProps} error={v.error} />Customize deeply
Same package. Theme tokens, toolbar display/icons, custom items, and full layout control — without changing how the simple path works.
Theme + layout
import {
NaatEditor,
NaatEditorProvider,
NaatToolbar,
NaatSurface,
} from "@naatchaal/editor";
// Theme tokens + own chrome placement
<NaatEditor
config={{
theme: {
accent: "#16a34a",
radius: "16px",
toolbar: { background: "#0B1F3A", padding: "0.75rem" },
surface: { padding: "1.5rem", minHeight: "22rem" },
},
}}
renderLayout={({ toolbar, surface }) => (
<>
{surface}
{toolbar}
</>
)}
/>
// Or full composition
<NaatEditorProvider>
<div className="naat-editor">
<NaatToolbar />
<NaatSurface />
</div>
</NaatEditorProvider>Chrome with CSS / Tailwind
Editor chrome (shell, toolbar, surface) — not content marks — is styled three ways:
config.theme→ CSS vars (--ne-accent,--ne-toolbar-bg,--ne-btn-*,--ne-surface-min-height,--ne-surface-max-height, …) on.naat-editor- Plain CSS — override those vars or target
.naat-editor,.naat-editor-toolbar,.naat-editor-toolbtn,.naat-editor-surface classNameonNaatEditor/NaatToolbar/NaatSurface(merged onto the real chrome nodes)
<NaatEditor
className="rounded-2xl border shadow-sm [&_.naat-editor-toolbtn]:rounded-lg"
style={{ ["--ne-accent" as string]: "#0ea5e9" }}
/>
// Compose path — wrap with .naat-editor; height via theme or Tailwind on surface
<NaatEditorProvider
config={{ theme: { surface: { minHeight: "12rem", maxHeight: "20rem" } } }}
>
<div className="naat-editor rounded-2xl">
<NaatToolbar className="bg-slate-900 text-white" />
<NaatSurface className="p-6 min-h-48 max-h-80 overflow-y-auto" placeholder="Write…" />
</div>
</NaatEditorProvider>Height knobs: theme.surface.minHeight / maxHeight (or Tailwind min-h / max-h / overflow-y-auto on the surface). Use className / CSS for other chrome (radius, padding, colors).
Toolbar items, display, fonts
toolbar.items may be button | dropdown (custom option objects) | separator | custom (React render).
toolbar: {
display: "icon", // or "label" | "icon-label"
items: [
{ type: "button", id: "bold" },
{ type: "button", id: "italic", display: "icon-label", label: "Italic" },
{ type: "separator" },
{
type: "dropdown",
id: "heading",
label: "Heading",
items: ["heading1", "heading2", "heading3"],
},
{ type: "custom", id: "emoji", render: () => <button type="button">…</button> },
],
}
theme: {
toolbar: { fontFamily: "Georgia, serif", fontSize: "0.9rem", fontWeight: "600" },
button: { fontFamily: "Inter, sans-serif" },
}Helpers: buildFlatToolbar, buildGroupedToolbar, createEditorConfig.
Custom icons
Built-in SVGs are the default (no icon library required). Override per action:
import { NaatEditor, DEFAULT_TOOLBAR_ICONS } from "@naatchaal/editor";
<NaatEditor icons={{ bold: MyBold, image: MyImage }} />Or renderIcon={(id, props) => …} (return null to keep the default).
Built-ins
Marks: bold, italic, underline, strike, code, link, fontSize, textColor, highlight
Blocks: paragraph, heading, bulletList, orderedList, codeBlock, blockquote, image, video, horizontalRule
Actions: unlink, clearFormatting, preview (plus mark/block toggles and undo/redo)
AI assistants (Cursor / Claude)
| Resource | URL / path |
|----------|------------|
| Short AI index | llms.txt · https://naat.tools/editor/llms.txt |
| Full AI docs | llms-full.txt · https://naat.tools/editor/llms-full.txt |
| Agent rules | AGENTS.md |
| Cursor skill | skills/naat-editor/SKILL.md |
| MCP server | @naatchaal/editor-mcp |
{
"mcpServers": {
"naat-editor": {
"command": "npx",
"args": ["-y", "@naatchaal/editor-mcp"]
}
}
}License
MIT — Naatchaal
