Skip to main content
naat.tools
0uses

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

  1. Install @naatchaal/editor (npm i @naatchaal/editor). Peers: React 18+.
  2. Import NaatEditor from @naatchaal/editor in a Client Component ("use client"). Default styles load with that import; optional @naatchaal/editor/styles.css for explicit order or non-bundler setups.
  3. Pass initialContent (and a React key when editing another row), wire onChange, validate with the form helpers. config is 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 initialContentDocNode, 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 (true or a string); red outline on the shell and the message under the surface.
  • Off showError={false} keeps aria-invalid without 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 + editorProps
  • useNaatEditorValidation — thin validateContenterror
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 id is fontSize, textColor, or highlight

Marks & blocks

  • enabledMarks bold, italic, underline, strike, code, link, fontSize, textColor, highlight
  • enabledBlocks paragraph, heading, bulletList, orderedList, codeBlock, blockquote, image, video, horizontalRule
  • config.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)

  1. Document model — immutable JSON tree (doc → blocks → inlines + marks).
  2. Transactions — every edit is a pure transform with selection updates.
  3. History — undo/redo stacks of document snapshots.
  4. DOM bridge — model ↔ contenteditable sync via beforeinput and 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.

{
  "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.