<img src=".github/eglador-logo.svg" alt="eglador-ui-react-rich-text" width="200" />

# eglador-ui-react-rich-text

[![npm version](https://img.shields.io/npm/v/eglador-ui-react-rich-text?style=flat-square&color=blue)](https://www.npmjs.com/package/eglador-ui-react-rich-text)
[![npm downloads](https://img.shields.io/npm/dm/eglador-ui-react-rich-text?style=flat-square&color=green)](https://www.npmjs.com/package/eglador-ui-react-rich-text)
[![license](https://img.shields.io/npm/l/eglador-ui-react-rich-text?style=flat-square)](https://github.com/eglador/eglador-ui-react-rich-text/blob/main/LICENSE)
![lexical v0.46](https://img.shields.io/badge/lexical-v0.46-8B5CF6?style=flat-square)
![tailwind v4](https://img.shields.io/badge/tailwindcss-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![react 19](https://img.shields.io/badge/react-19-61DAFB?style=flat-square&logo=react&logoColor=white)
![typescript](https://img.shields.io/badge/typescript-ready-3178C6?style=flat-square&logo=typescript&logoColor=white)

A modern, compound-component rich text editor for React — built on top of [Lexical](https://lexical.dev) and styled with **Tailwind CSS v4**.

## Features

- **Compound API** — `RichTextEditor`, `RichTextToolbar`, `RichTextContent`, `RichTextPageSize` (composition over configuration, shadcn/ui style)
- **20+ toolbar features** — undo/redo, headings (h1–h6), bold/italic/underline/strikethrough/code, text transform (case + sub/super/highlight), text & background color, lists, alignment (LTR/RTL + indent/outdent), quote, link, insert (HR/page break/table)
- **Notion-style drag handle** — block reorder + (+) insert menu (`<RichTextContent draggable />`)
- **Floating selection toolbar** — Medium/Notion pattern (`<RichTextContent floatingToolbar />`)
- **Auto-opening link editor** — cursor enters link → form appears anchored to the link with `url` / `target` / `rel`
- **Table support** — grid picker insert + cell-anchored action menu (insert/delete row/col, merge, unmerge, delete table)
- **Page size simulator** — DevTools-style bottom bar (Full / Desktop 1280 / Tablet 768 / Mobile 375 + custom)
- **Custom nodes** — official `HorizontalRuleNode` + custom `PageBreakNode` decorator (`break-after: page` for print/PDF)
- **HTML / JSON / Markdown** — round-trip serialization on `onChange` and via `initial*` props
- **Imperative API** — `useRichTextEditor()` hook + `editorRef` prop
- **TypeScript-first** — full type safety, exported feature unions
- **Viewport-aware popovers** — auto-flip / auto-shift, never clipped by ancestor `overflow: hidden`
- **Turkish spell checking** — Hunspell + Turkish rules in a Web Worker, with a pass/fail result for publish gates and error screens (`<RichTextSpellCheck />`)
- **Clean paste** — text from Word / Google Docs / the web arrives without the blank spacer paragraphs

## Installation

```bash
npm install eglador-ui-react-rich-text
```

**Peer dependencies:** `react ^19` · `react-dom ^19` · `tailwindcss ^4`

All Lexical packages ship as direct dependencies — no separate install needed.

## Setup

Add the following to your global stylesheet so Tailwind picks up the component classes:

```css
@import "tailwindcss";
@source "../node_modules/eglador-ui-react-rich-text";
```

The `@source` path is relative to the CSS file location:

| Framework | CSS file location | Path |
|---|---|---|
| Next.js (App Router) | `app/globals.css` | `../node_modules/eglador-ui-react-rich-text` |
| Next.js (`src/`) | `src/app/globals.css` | `../../node_modules/eglador-ui-react-rich-text` |
| Vite | `src/index.css` | `../node_modules/eglador-ui-react-rich-text` |

## Quick Start

```tsx
"use client";

import {
  RichTextEditor,
  RichTextToolbar,
  RichTextContent,
  RichTextPageSize,
} from "eglador-ui-react-rich-text";

export function MyEditor() {
  return (
    <RichTextEditor
      initialMarkdown="# Hello\n\nStart **typing**..."
      onChange={(value) => console.log(value.html)}
    >
      <RichTextToolbar />
      <RichTextContent draggable floatingToolbar />
      <RichTextPageSize />
    </RichTextEditor>
  );
}
```

## API

### Components

| Component | Purpose |
|---|---|
| `RichTextEditor` | Root provider. Lexical composer + theme + nodes + plugins. |
| `RichTextToolbar` | Top formatting bar. Customize via `features`, `headingItems`, `insertBlocks`. |
| `RichTextContent` | Editable content area. Opt-ins: `draggable`, `floatingToolbar`. |
| `RichTextPageSize` | Bottom DevTools-style device toolbar (presets + custom px). |
| `RichTextDraggableBlock` | Drag handle plugin (auto-rendered when `draggable` is on). |
| `RichTextFloatingToolbar` | Selection-anchored mini formatting bar. |
| `RichTextLinkEditor` | Auto-opening link edit form when cursor enters a link. |
| `RichTextTableActions` | Cell-anchored chevron with insert/delete/merge/unmerge actions. |
| `RichTextSpellCheck` | Turkish spell checking: underlines, suggestions, status bar, pass/fail output. |
| `RichTextBlockEdges` | Keeps a paragraph next to edge blocks so the caret can reach above/below (auto-mounted). |
| `RichTextPasteCleanup` | Strips the blank spacer blocks out of pasted content (auto-mounted). |
| `RichTextHeadingLimit` | Advisory character counter on headings, editor-only (auto-mounted). |
| `TextCaseMenu` | `Aa` dropdown that re-cases the selection, with Turkish-correct `i`/`İ`. |

### Hooks

| Export | Returns |
|---|---|
| `useRichTextEditor()` | `{ editor, getJson, setJson, getHtml, setHtml, getMarkdown, setMarkdown, getText, getCursorOffset, setCursorOffset, getLegacyShortcodes, importLegacyComponents, insertAllComponents, clear, focus }` — **must be called inside `<RichTextEditor>`** |
| `usePageSize()` | `{ size, setSize }` — read/write the active page-size from the context |
| `useRichTextSpellCheck()` | `{ result, api }` — the surrounding editor's spell-check state; `api` is `null` until `<RichTextSpellCheck>` mounts |
| `useResolvedSrc(id)` | `{ src, status }` — turn a media ID into a URL via `resolveImageSrc` (`status`: `idle` \| `loading` \| `resolved` \| `missing`) |

### `RichTextEditor` props

| Prop | Type | Description |
|---|---|---|
| `initialJson` | `string` | Initial state from a JSON-serialized Lexical editor state |
| `initialHtml` | `string` | Initial state from HTML (parsed via `$generateNodesFromDOM`) |
| `initialMarkdown` | `string` | Initial state from Markdown (via `TRANSFORMERS`). Priority: markdown > html > json |
| `onChange` | `(value: RichTextValue) => void` | Fires on every editor update with `{ json, html, text, markdown }` |
| `editable` | `boolean` | Default `true`. Reactive — toggles read-only mode imperatively |
| `autoFocus` | `boolean` | Focus on mount |
| `namespace` | `string` | Lexical namespace (default `"eglador-rich-text"`) |
| `maxLength` | `number` | Character limit. Overflow is wrapped in an `OverflowNode` and a counter is shown |
| `charset` | `"UTF-8" \| "UTF-16"` | Counting mode for `maxLength` (default `"UTF-16"` = `String.length`) |
| `resolveImageSrc` | `(id: string) => string \| null \| Promise<string \| null>` | Resolves CMS media IDs to URLs — see [Media by ID](#media-by-id-resolveimagesrc) |
| `editorRef` | `Ref<LexicalEditor>` | Raw Lexical instance (**not** the `useRichTextEditor()` API object) |

### `RichTextToolbar` props

| Prop | Type | Description |
|---|---|---|
| `features` | `RichTextToolbarFeature[]` | Buttons to show. Default = all 20+ features |
| `headingItems` | `HeadingMenuItem[]` | Items inside the heading dropdown (default: paragraph + h1–h6) |
| `insertBlocks` | `BlockSpec[]` | Blocks in the Insert dropdown. Defaults to `defaultBlocks` filtered to the `insert` surface |

### `RichTextContent` props

| Prop | Type | Description |
|---|---|---|
| `placeholder` | `ReactNode` | Empty-state placeholder (default `"Start writing..."`) |
| `minHeight` | `string` | Tailwind min-height class (default `"min-h-32"`) |
| `draggable` | `boolean` | Enable Notion-style drag handle + (+) insert menu |
| `draggableBlocks` | `BlockSpec[]` | Blocks in the drag-handle (+) menu |
| `floatingToolbar` | `boolean` | Enable selection-anchored mini formatting toolbar |
| `mode` | `"rich" \| "plain"` | `"plain"` swaps in `PlainTextPlugin` — text only, no formatting commands |

### Optional slots

Drop any of these inside `<RichTextEditor>`; each subscribes on its own.

| Component | Purpose |
|---|---|
| `RichTextSlashCommands` | `/` typeahead block menu. Takes the same `blocks` array as the toolbar |
| `RichTextAutoEmbed` | Offers to embed a recognized pasted URL (YouTube, .mp3, .mp4, images) |
| `RichTextStats` | Live word / character count, optional reading time |
| `RichTextFindReplace` | Find & replace panel (Cmd/Ctrl+F) |
| `RichTextOutput` | Tabbed HTML / Markdown / JSON / Text panel with copy + download |
| `RichTextDevGlobals` | Registers the `lexicalComponentAllSet()` console helper |

## Toolbar Features

```ts
type RichTextToolbarFeature =
  | "undo" | "redo"
  | "heading" | "paragraph"
  | "heading1" | "heading2" | "heading3"
  | "heading4" | "heading5" | "heading6"
  | "bold" | "italic" | "underline" | "strikethrough" | "code"
  | "textTransform"
  | "textColor" | "backgroundColor"
  | "bulletList" | "orderedList"
  | "alignment"
  | "quote" | "link"
  | "insert"
  | "separator";
```

The `heading` and `insert` features render dropdowns. The heading dropdown takes a list of block types; the insert dropdown takes `BlockSpec[]` from the shared registry:

```ts
type HeadingMenuItem = "paragraph" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";

// Insert dropdown — filter/extend the registry, don't pass string keys
import { defaultBlocks } from "eglador-ui-react-rich-text";
<RichTextToolbar insertBlocks={defaultBlocks.filter((b) => b.key !== "table")} />
```

## Custom Nodes

For custom Lexical configurations, the package exports node + plugin primitives:

```tsx
import {
  PageBreakNode,
  PageBreakPlugin,
  INSERT_PAGE_BREAK_COMMAND,
  $createPageBreakNode,
  $isPageBreakNode,
} from "eglador-ui-react-rich-text";
```

## Eglador CMS Blocks

The package ships one **real Lexical node per CMS component type**. They preview live in the editor (hover → gear to edit, drag handle to reorder) and serialize to ordinary Lexical JSON under their own `type` — *not* to a `#type#field#value#` text run:

```json
{ "type": "galeri", "version": 1, "format": "", "id": "345456" }
```

They are already in `defaultNodes` and `defaultBlocks`, so a plain `<RichTextEditor>` has them. Nothing to register.

### Types

`360resim` · `canliyayin` · `countdown` · `depomkacadolar` · `depremler` · `dikeyciftli` · `dikeylink` · `flourish` · `galeri` · `habericionecikanlar` · `habericireklam` · `havadurumu` · `kredihesaplama` · `kurcevirici` · `linkEmbed` · `mansethaberresim` · `ozelharf` · `piyasa` · `quato1` · `resimliquato` · `sabitlink` · `yatayciftli` · `yataytekli`

Social / media embeds, each its own type: `twitter` · `instagram` · `soundcloud` · `googlemap` — all `url` + optional `position`. These were previously one `linkEmbed` block with an `embed` select; that select is gone and `linkEmbed` is now the plain-link case (`url` + `position`). **YouTube and audio are not among them** — the built-in `youtube` and `audio` blocks already cover those, with real players in the editor.

…plus `newsMoment`, which is a different shape — see [News moment](#news-moment).

Read the authoritative field list at runtime instead of hardcoding it:

```ts
import { CMS_BLOCK_SCHEMA } from "eglador-ui-react-rich-text";

CMS_BLOCK_SCHEMA.map((s) => ({ type: s.type, fields: s.fields.map((f) => f.name) }));
```

Several types carry a fixed option list whose **values are the CMS IDs** that reach the JSON:

| Type | Field | Values |
|---|---|---|
| `canliyayin` | `channel` | `300` BHT TV · `100` HT TV · `10` SHOWTV |
| `piyasa` | `usd_eur` | `88` Dolar · `89` Euro · `1` BIST 100 |
| `havadurumu` | `widget` | `100` |
| `depomkacadolar`, `kredihesaplama` | `widget` | `hesaplayici` |
| `kurcevirici` | `widget` | `cevirici` |
| `depremler` | `view` | `harita` · `liste` |
| `sabitlink` | `color` | `kirmizi` · `mavi` · `yesil` |

**Two naming caveats when mapping back to legacy shortcodes:**

| Legacy | In this package | Why |
|---|---|---|
| `#link#...#` | type `linkEmbed` | `@lexical/link`'s `LinkNode` already owns the `link` node type; a second one would silently replace it and break every hyperlink |
| `type1` / `id1` / `type2` / `id2` | `firstContentType` / `firstContentId` / `secondContentType` / `secondContentId` | the originals don't say *what* type or *what* id |

Three legacy types map onto built-in blocks rather than getting their own: `resim` → the `image` block (ID mode), `video` → the `video` block (ID mode), `imagecompare` → the `image-comparison` block.

### Media by ID (`resolveImageSrc`)

Blocks that address media by ID never store the URL — the document holds the ID, and the page resolves it for preview. Wire the callback once on the root:

```tsx
<RichTextEditor
  resolveImageSrc={async (id) => {
    const res = await fetch(`/api/media/${id}`);
    return res.ok ? (await res.json()).url : null; // null → "not found" placeholder
  }}
>
```

Sync resolvers (an in-memory map) work too. This callback drives the `image` / `video` blocks in ID mode **and** every `image-ids` field (e.g. `newsMoment.images`, previewed as thumbnails).

With `options.imageId` set, `exportJSON()` omits `src` entirely:

```json
{ "type": "image", "version": 1, "format": "",
  "options": { "alt": "", "caption": "", "maxWidth": 0, "imageId": "345456" } }
```

Leave the ID blank and paste a URL to get the original URL behaviour back (`src` is serialized as before).

### News moment

`newsMoment` is the one CMS type that is **not** a decorator. It's an `ElementNode`, so its body is edited inline like any other text: typing, inline formatting, lists, links, undo/redo and selection all behave natively, and the body serializes as an ordinary Lexical `children` array rather than an opaque string.

```json
{
  "type": "newsMoment", "version": 1,
  "date": "2026-08-06", "time": "18:00",
  "title": "Canlı gelişme", "images": "345456, 345457",
  "children": [
    { "type": "paragraph", "children": [{ "type": "text", "text": "Gövde metni" }] }
  ]
}
```

The metadata (date / time / title / image IDs) is edited from the gear in the block's own header, which is rendered by `RichTextNewsMomentChrome` — mounted automatically by `RichTextContent`, so there is nothing to wire up.

Programmatically:

```tsx
import { $createNewsMomentNode, $createEmptyNewsMomentNode } from "eglador-ui-react-rich-text";

// with a starter paragraph (what the Insert menu uses)
$createNewsMomentNode({ date: "2026-08-06", time: "18:00", title: "…", images: "345456" });

// no children — when you supply the body yourself
const node = $createEmptyNewsMomentNode({ title: "…" });
node.append(myParagraph);
```

When mapping to the legacy shortcode, read the body from the node's children (`node.getTextContent()`, or serialize them to HTML) rather than from a `content` field — that field no longer exists.

### Creating blocks programmatically

```tsx
import { $createCmsNode } from "eglador-ui-react-rich-text";
import { $getRoot } from "lexical";

editor.update(() => {
  const node = $createCmsNode("galeri", { id: "345456" }); // null for unknown types
  if (node) $getRoot().append(node);
});
```

### Matching the Insert and slash menus

Both surfaces read the same registry, so pass one array to all of them and they cannot drift apart:

```tsx
const HIDDEN = ["check-list", "horizontal-rule", "page-break", "date-time", "code-block"];
const blocks = defaultBlocks.filter((b) => !HIDDEN.includes(b.key));

<RichTextEditor resolveImageSrc={resolveImageSrc}>
  <RichTextToolbar insertBlocks={blocks} />
  <RichTextContent draggable floatingToolbar draggableBlocks={blocks} />
  <RichTextSlashCommands blocks={blocks} />
</RichTextEditor>
```

`getBlocksForSurface("insert", blocks)` and `getBlocksForSurface("slash", blocks)` return identical lists in identical order, and a block with a `renderForm` opens that form on either surface.

### Adding a new CMS type

One entry in [`src/components/rich-text/cms/cms-schema.tsx`](src/components/rich-text/cms/cms-schema.tsx) — the node class, the Insert/slash menu entries and the form are all generated from it:

```ts
{
  type: "myblock",            // must be unique across ALL Lexical node types
  title: "Benim Bloğum",
  icon: <FrameIcon className="size-4" />,
  fields: [
    { name: "id", label: "ID", inputType: "text" },
    { name: "position", label: "Hizalama", inputType: "select", options: POSITION_LEFT_RIGHT },
  ],
  renderPreview: (fields) => <div>…</div>,  // optional; omit for the generic card
}
```

`inputType`: `text` · `url` · `number` · `select` · `textarea` · `date` · `time` · `image-ids`.

A duplicate `type` throws at import time (guard in [`nodes.ts`](src/components/rich-text/nodes.ts)) rather than silently shadowing another node.

### Inserting every component at once

For eyeballing an integration — no UI required.

```tsx
import { useRichTextEditor, RichTextDevGlobals } from "eglador-ui-react-rich-text";

function FillButton() {
  const { insertAllComponents } = useRichTextEditor(); // call inside <RichTextEditor>
  return (
    <>
      <button onClick={() => insertAllComponents()}>Clear + insert all</button>
      <button onClick={() => insertAllComponents({ mode: "append" })}>Append at end</button>
    </>
  );
}
```

Or from outside the tree: `insertAllComponents(editor, options)` — returns the number of blocks inserted.

Drop `<RichTextDevGlobals />` inside `<RichTextEditor>` to get a console escape hatch:

```js
lexicalComponentAllSet()                                   // clear, then insert all
lexicalComponentAllSet("append")                           // keep content, add at end
lexicalComponentAllSet({ mode: "append", cmsTypes: ["galeri", "newsMoment"] })
```

| Option | Default | Meaning |
|---|---|---|
| `mode` | `"replace"` | `"replace"` clears the document first; `"append"` adds at the end |
| `includeHeadings` | `true` | section headings + explanatory paragraphs |
| `includeBuiltIns` | `true` | text blocks, table, columns, media embeds |
| `cmsTypes` | all | restrict the CMS section; `[]` skips it |
| `sample` | public samples | placeholder media — see `ALL_COMPONENTS_DEFAULT_SAMPLE` |

Point it at your own media so previews actually resolve:

```tsx
insertAllComponents({ sample: { imageId: "345456", videoId: "913597", mediaIds: ["1", "2"] } });
```

The global is registered on mount and removed on unmount; pass `name` if several editors share a page. Gate it in production if you'd rather not ship it: `{process.env.NODE_ENV !== "production" && <RichTextDevGlobals />}`.

## Legacy Components

Embeds a legacy CMS shortcode — a generic `#type#field#value#field#value#` block — without the library having any built-in notion of what a "video" or "image" component is. The available types and their form fields are entirely defined by your own `LegacyComponentSpec[]` schema, passed at the call site.

```tsx
import {
  RichTextEditor,
  RichTextToolbar,
  RichTextContent,
  RichTextSlashCommands,
  defaultBlocks,
  createLegacyComponentBlocks,
  type LegacyComponentSpec,
} from "eglador-ui-react-rich-text";

const legacySchema: LegacyComponentSpec[] = [
  {
    type: "resim",
    title: "Image (legacy)",
    fields: [
      { name: "src", label: "URL", inputType: "url" },
      { name: "alt", label: "Alt text", inputType: "text", optional: true },
    ],
  },
];

const blocks = [...defaultBlocks, ...createLegacyComponentBlocks(legacySchema)];

export function MyEditor() {
  return (
    <RichTextEditor>
      <RichTextToolbar insertBlocks={blocks} />
      <RichTextContent draggable />
      <RichTextSlashCommands blocks={blocks} />
    </RichTextEditor>
  );
}
```

- `createLegacyComponentBlocks(schema)` turns each `LegacyComponentSpec` into a `BlockSpec` that opens a form (built from `spec.fields`) on the `"insert"` / `"slash"` surfaces — merge the result into `defaultBlocks` and pass it to `RichTextToolbar`, `RichTextSlashCommands`, and `RichTextDraggableBlock` via the `blocks` prop.
- Submitting the form inserts the shortcode string as plain, ordinary editable text — not a locked decorator — so the user can revise it by hand afterwards (fix a typo, tweak a value) like any other text in the document.
- By default a type renders as `#type#field#value#field#value#...#` (e.g. `#resim#src#https://...#alt#Cover#`). Give a `LegacyComponentSpec` its own `template` to control the exact output instead — `{type}` and `{fieldName}` placeholders are substituted with the submitted values, and any field not referenced is simply omitted:
  ```ts
  {
    type: "resim",
    title: "Image (legacy)",
    template: "#{type}#{src}#", // → "#resim#https://...#" (alt text dropped)
    fields: [
      { name: "src", label: "URL", inputType: "url" },
      { name: "alt", label: "Alt text", inputType: "text", optional: true },
    ],
  }
  ```
- `getHtml()` / `getMarkdown()` / `getText()` (from `useRichTextEditor()`) all preserve the shortcode string exactly as inserted, template or not.
- `editorRef.current?.getLegacyShortcodes()` returns every legacy shortcode line currently in the document as an array of strings, in document order — works regardless of which types use a custom `template`.
- `editorRef.current?.importLegacyComponents(items, schema)` appends typed `LegacyComponentInput` objects (`{ type, fields }`) programmatically — pass the same `schema` you gave `createLegacyComponentBlocks()` so each item's `template` (if any) is honored; omit it to always use the default layout.

## Language

The editor chrome is **Turkish by default**. Switch it, or override individual strings, on the root:

```tsx
<RichTextEditor locale="en">                                  {/* English */}
<RichTextEditor messages={{ cancel: "Vazgeç", save: "Kaydet" }} />  {/* per-string */}
```

`RichTextMessages` covers every string the chrome renders — form buttons, field labels, the block-picker search box, image-by-ID hints. Labels that come from a schema (`CmsBlockSpec.fields`) travel with the schema instead. Exported for reuse: `trMessages`, `enMessages`, `useMessages()`.

## Picking images from the page

Give the editor a library and the image block turns into a picker: the ID and URL fields become read-only readouts and the author selects a tile instead of typing.

```tsx
<RichTextEditor
  resolveImageSrc={(id) => fetchUrl(id)}
  imageLibrary={[{ id: "345456" }, { id: "345457", title: "Kapak" }]}
>
```

Only `id` is required — a missing `url` is resolved through `resolveImageSrc`, so a bare list of IDs renders thumbnails fine. Pass a function (sync or async) instead of an array to load lazily:

```tsx
imageLibrary={async () => (await fetch("/api/medya")).json()}
```

Omit the prop entirely and the form keeps its current behaviour: ID and URL stay editable.

## Pasting from Word, Google Docs and the web (`pasteCleanup`)

Word writes `<p class=MsoNormal><o:p>&nbsp;</o:p></p>` between paragraphs, Google Docs puts a bare `<br>` between them, and plain text carries blank lines. Each one imports as an empty paragraph, so pasted text used to arrive double-spaced. Those blank spacer blocks are now dropped — paragraph spacing is the stylesheet's job.

What is kept: a `<br>` inside a paragraph, an empty table cell, a block holding only an image, and all formatting, headings and lists. Code blocks are left alone entirely, because blank lines are content there.

```tsx
<RichTextEditor pasteCleanup={false}>   {/* paste verbatim instead */}
```

`cleanPastedHtml(html)` and `collapsePastedText(text)` are exported if you want to run the same tidy-up elsewhere.

## Heading length counter (`headingLimit`)

Headings show a `62/70` counter while they are being edited, turning red once they are over. It is **advisory** — nothing is blocked or truncated, and the author can keep typing.

```tsx
<RichTextEditor headingLimit={120} />                               {/* all levels */}
<RichTextEditor headingLimit={{ limit: { h2: 70, h3: 60 } }} />     {/* per level */}
<RichTextEditor headingLimit={{ limit: 70, always: true }} />       {/* show on every heading */}
<RichTextEditor headingLimit={false} />                             {/* off */}
```

- Default: 70 characters for every heading level. With an object, a level left out is not counted at all.
- The counter appears on the heading the caret is in, and stays visible on any heading that is over — so a too-long heading further up the article keeps warning you. `always: true` shows it on all of them.
- **It never reaches the document.** The counter is drawn in CSS from a `data-` attribute on the heading element, so `onChange`, `getJson()`, `getHtml()`, `getText()` and the output panel are all untouched, and the author cannot select, copy or delete it.

`registerHeadingLimit(editor, options)` is exported for use outside React.

## Alt text under the image

The image block shows its alt text under the image, tagged `ALT`, below the caption. It is an authoring aid only: `decorate()` draws the in-editor view, so the alt text stays out of the JSON's shape and out of the exported HTML — there it remains where it belongs, in the `<img alt>` attribute. Nothing is shown when the field is empty.

## Letter case from the selection toolbar

Select some text and the formatting bar's **Aa** button re-cases it: UPPERCASE, lowercase, or Capitalize Each Word. Only the selection changes, and each run keeps its own formatting — bold stays bold.

Casing follows the editor's `locale`, which matters in Turkish:

| | `locale="tr"` (default) | plain `toUpperCase()` |
|---|---|---|
| `ilgi` → upper | `İLGİ` | `ILGI` ✗ |
| `IRMAK` → lower | `ırmak` | `irmak` ✗ |
| `çocuk şarkısı` → title | `Çocuk Şarkısı` | `çocuk şarkısı` ✗ |

Capitalize keeps apostrophe suffixes down (`istanbul'da` → `İstanbul'da`, `2024'te` → `2024'te`) and does not break a word that formatting split in two.

The same three entries sit in the toolbar's own `Aa` menu. `transformSelectionCase(editor, mode, locale)` and the pure helpers `toUpperCaseText` / `toLowerCaseText` / `toTitleCaseText` are exported.

## Headings from the selection toolbar

Selecting text pops up the formatting bar; alongside bold/italic it has **H2** and **H3** buttons that turn the selected block into that heading. Pressing the same one again turns it back into a paragraph, and the button shows as pressed while the caret is inside a heading of that level.

```tsx
<RichTextContent floatingToolbar />                          {/* H2 + H3 (default) */}
<RichTextContent floatingToolbar floatingHeadings={["h2", "h3", "h4"]} />
<RichTextContent floatingToolbar floatingHeadings={[]} />    {/* none */}
```

## Typing above and below edge blocks (`blockEdges`)

An image, a CMS block, a table or a columns row at the very start or end of the document leaves the caret nowhere to go: clicking above the first block just lands in the text below it, and the only way past the last one is the "+" menu. The editor keeps an empty paragraph at those ends so both are reachable.

```
[galeri]                    →   [paragraph]   ← click here and type
[paragraph] "metin"             [galeri]
[canliyayin]                    [paragraph] "metin"
                                [canliyayin]
                                [paragraph]   ← and here
```

- Only blocks the caret can't be typed around count: block decorators (media, CMS blocks, page break, horizontal rule) and containers whose text sits in nested shadow roots (tables, columns, note bodies). A document that begins and ends with a paragraph, heading, quote or list is **left exactly as it is**.
- The paragraph is added in the same update as the insertion, so one Ctrl+Z undoes both.
- It becomes part of the document, so it shows up in `onChange` / `getJson()` output as an empty paragraph.

Turn it off if you would rather keep documents untouched, or control each end:

```tsx
<RichTextEditor blockEdges={false}>            {/* never modify the document */}
<RichTextEditor blockEdges={{ leading: false }}>  {/* only guarantee the bottom one */}
```

`registerBlockEdges(editor, options)` is exported for use outside React.

## Dropdown choices from your own CMS (`cmsFieldOptions`)

The CMS blocks ship with built-in lists — live-stream channels, market widgets, link colours, alignment. Replace any of them per project, keyed by block type and then field name:

```tsx
<RichTextEditor
  cmsFieldOptions={{
    canliyayin: { channel: [
      { value: "300", label: "BHT TV" },
      { value: "55",  label: "Eglador TV" },
    ]},
    piyasa: { usd_eur: async () => (await fetch("/api/piyasa")).json() },
    "*": { position: [{ value: "left", label: "Sola yaslı" }] },
  }}
>
```

- A value is either a list or a function (sync or async) returning one, so channels can come straight from your API.
- `"*"` applies to every block — handy for `position`, which many blocks share. A block-specific entry wins over `"*"`.
- Each loader runs **once per editor** and the result is cached, so reopening a form doesn't refetch. While a loader runs the select is disabled and shows a loading label; if it rejects, the built-in list comes back so the form stays usable (the error is logged).
- The `value` of each option is exactly what lands in the JSON — these are your CMS IDs, not display text.
- **A stored value missing from the new list is kept**, shown as its own `123 — listede yok` entry. Editing and saving an old block never silently switches it to another channel.

Both the insert form ("+" / "/" menus) and the block's own gear-icon edit form read the same lists.

## Pasted embed codes

Share dialogs hand out a whole `<iframe …>` tag rather than a URL. Paste one into a URL field and only its `src` is kept, with `&amp;`-style escapes decoded:

```
<iframe src="https://www.google.com/maps/embed?pb=!1m18…" width="600" …></iframe>
→ https://www.google.com/maps/embed?pb=!1m18…
```

This applies to the CMS blocks' URL fields (Google Maps, SoundCloud, X, Instagram, …) and to the built-in YouTube and iframe blocks. The helper is exported as `extractEmbedSrc(text)` if you need it elsewhere.

The YouTube field goes one step further: a pasted `watch?v=`, `youtu.be` or `/shorts/` link becomes its embeddable form, because YouTube refuses to be framed from a watch URL.

| Pasted | Stored |
|---|---|
| `https://www.youtube.com/watch?v=DCCN_T_VigY` | `https://www.youtube-nocookie.com/embed/DCCN_T_VigY` |
| `<iframe … src="https://www.youtube.com/embed/DCCN_T_VigY?si=61Jm…">` | `https://www.youtube.com/embed/DCCN_T_VigY?si=61Jm…` |
| `https://www.youtube.com/watch?v=DCCN_T_VigY&t=90s` | embed URL, and `90` fills the start-time field |

A URL that is already embeddable is stored exactly as pasted, params and all. Conversion happens on paste and when the field loses focus — never while you are still typing.

## Hiding fields

Drop fields you do not want authors to fill in — they disappear from the block's form **and** from the serialized payload:

```tsx
<RichTextEditor
  hiddenFields={{
    image: ["caption", "maxWidth"],
    "*": ["position"],           // applies to every block
  }}
>
```

The key is the block type (`image`, `galeri`, `newsMoment`, …) as it appears in the JSON; `*` covers all of them. A field name matches whether the block stores it flat (CMS blocks) or nested under `options` (the built-in media blocks) — you name the field, not its nesting.

Stripping applies to `onChange`, `getJson()` and the output panel. `getRawJson()` stays untouched, and Lexical's own structural keys (`type`, `children`, `format`, …) are never removed even if named.

A hidden field is not validated either, so hiding a required one will not block submission.

## Spell checking (Turkish)

```tsx
<RichTextEditor>
  <RichTextToolbar />
  <RichTextContent />
  <RichTextSpellCheck onResult={(result) => setCanPublish(result.passed)} />
</RichTextEditor>
```

Misspelled words get a wavy red underline. Clicking one (or right-clicking) opens a small menu with suggestions plus **Yoksay** (ignore) and **Sözlüğe ekle** (add to dictionary). A status bar under the editor shows the issue count and a list that jumps to each word. The editor turns off the browser's own spellcheck so you don't get two sets of underlines.

### What it catches

Checking happens in three layers:

| Layer | Catches | `kind` |
|---|---|---|
| Hunspell + `dictionary-tr` | Words the dictionary doesn't know, including suffixes (`kitaplarımızdan` passes, `yanlş` → `yanlış`) and casing (`istanbul'da` → `İstanbul'da`) | `misspelling` |
| Rule list (TDK) | Common wrong forms whose dictionary suggestion is poor: `herkez`, `yanlız`, `şöför`, `orjinal`, `malesef`… | `common-mistake` |
| Split rules | Words written together that should be apart: `değilmi` → `değil mi`, `belkide` → `belki de`, `herşeyi` → `her şeyi` | `separate-words` |

Suggestions are re-ranked for letters Turkish writers often mix up (`a/e`, `ı/i`, `s/ş`…), so `yazarkan` offers `yazarken` first.

**Skipped on purpose:** capitalised words mid-sentence (names like `Kılıçdaroğlu`, `Eglador'un`), all-caps abbreviations (`TBMM`), `iPhone`-style inner capitals, URLs, e-mail addresses, `@mentions`, `#hashtags`, domain names, numbers with suffixes (`2024'te`), inline code and code blocks. Both name rules can be turned off: `ignoreProperNouns={false}`, `ignoreAllCaps={false}`.

### Getting the result out

Every surface returns the same `SpellCheckResult`:

```ts
{
  status: "idle" | "loading" | "checking" | "passed" | "failed" | "error" | "disabled",
  passed: boolean,        // true ONLY when a check finished and found nothing
  issueCount: number,
  issues: SpellIssue[],   // { id, word, kind, message, suggestions, context, blockIndex, … }
  checkedWordCount: number,
  checkedAt: number | null,
  error?: string,
}
```

`passed` is `false` while the dictionary is loading, while a check is running, on errors and when the checker is disabled. It never reports "not checked yet" as a pass.

Use whichever of these reaches the code that needs the result:

| Need | Use |
|---|---|
| React to every change | `onResult={(result) => …}` |
| Code outside the editor (publish button, form submit) | `apiRef` → `await api.check({ suggestions: true })` |
| A component inside `<RichTextEditor>` | `const { result, api } = useRichTextSpellCheck()` |
| Non-React code | `root.addEventListener("eglador:spellcheck", (e) => e.detail)` |
| Server / API route / CI (no editor) | `await checkSpelling(json, { checker })` |

A publish gate that checks the text as it is right now (skipping the debounce) and waits for suggestions:

```tsx
const spell = useRef<RichTextSpellCheckApi>(null);

async function publish() {
  const result = await spell.current!.check({ suggestions: true });
  if (!result.passed) return showErrors(result.issues);
  await save();
}

<RichTextSpellCheck apiRef={spell} showStatus={false} />
```

`api` also provides `focusIssue(id)` (select the word, scroll to it and open its menu), `applySuggestion(id, text)`, `ignoreWord(word)` and `addWord(word)`. That's everything a custom error screen needs.

To replace the status bar with your own UI, pass `children` as a function: `<RichTextSpellCheck>{({ result, api }) => …}</RichTextSpellCheck>`. The exported `SpellCheckStatusBar` is the default UI if you want to place it somewhere else.

### Options

| Prop | Default | |
|---|---|---|
| `checker` | page-wide Turkish checker | Any `SpellChecker`. Memoise it; a new instance restarts checking. |
| `enabled` | `true` | Turns checking off without unmounting. Status becomes `disabled`. |
| `debounceMs` | `500` | Quiet period after typing before a check runs. |
| `suggestions` | `true` | Look up suggestions in the background so `onResult` carries them. With `false`, suggestions load only when a word is opened. |
| `ignoreWords` | — | Always accepted (brand names, bylines). Case-insensitive with Turkish rules. |
| `commonMistakes` | — | `{ wrong: "right" }` added to the rule list; `{ wrong: null }` removes a built-in rule. |
| `ignoreProperNouns` / `ignoreAllCaps` | `true` | See "Skipped on purpose" above. |
| `onAddWord` | — | Called on "Sözlüğe ekle". Save the word, then pass it back through `ignoreWords` on the next load. |
| `underline` / `showStatus` | `true` | Visual parts. |

### The dictionary and the engine

- Hunspell (WASM) runs in a **Web Worker** that ships inside the package. No bundler setup is needed. With ESM it loads as a separate ~750 KB chunk, and only when the spell checker first starts.
- The dictionary (`dictionary-tr`, ~9 MB) downloads from jsDelivr by default. **Only the dictionary is downloaded — the text being checked never leaves the browser.** Once loaded it uses ~45 MB of memory in the worker. All editors on a page share one worker.
- To serve the dictionary yourself, copy `index.aff` / `index.dic` from the `dictionary-tr` package and point to them:

  ```ts
  const checker = createTurkishSpellChecker({
    dictionaryUrls: { aff: "/dict/tr.aff", dic: "/dict/tr.dic" },
  });
  <RichTextSpellCheck checker={checker} />
  ```

- On a server, run the engine in-process and read the files from disk:

  ```ts
  const checker = createTurkishSpellChecker({
    worker: false,
    loadDictionary: () => ({
      aff: fs.readFileSync("node_modules/dictionary-tr/index.aff"),
      dic: fs.readFileSync("node_modules/dictionary-tr/index.dic"),
    }),
  });
  const result = await checkSpelling(editorJson, { checker });
  if (!result.passed) return Response.json(result.issues, { status: 422 });
  ```

- **Bring your own engine:** implement `SpellChecker` (`ready`, `check(words) → boolean[]`, `suggest(word) → string[]`) to route checks to an API, for example a Zemberek service. Tokenising, the Turkish rules, underlines and the result format stay the same.

If the dictionary can't load (offline, CDN blocked), status becomes `error` with the reason in `result.error`, and the next check tries again.

**Current limits:** text inside CMS block form fields (a quote's title, a news moment's heading) isn't checked, because those fields are form inputs rather than editor text. Note bodies and column contents are checked. Hunspell only checks individual words, so grammar and context errors (`de`/`da` written attached when the word is also valid, e.g. `bende`) are not flagged.

## Inline text styling (`format` bitmask)

Lexical splits inline formatting across two fields on every text node:

| Field | Holds | Written by |
|---|---|---|
| `format` | a **bitmask** of the toggles | `FORMAT_TEXT_COMMAND` (bold, italic, …) |
| `style` | a CSS string | `$patchStyleText` (colour, background, font size) |

So `{"format": 9, "style": "color: #ef4444;"}` means bold + underline + red. The bits:

| Format | Bit | Format | Bit |
|---|---|---|---|
| `bold` | 1 | `superscript` | 64 |
| `italic` | 2 | `highlight` | 128 |
| `strikethrough` | 4 | `lowercase` | 256 |
| `underline` | 8 | `uppercase` | 512 |
| `code` | 16 | `capitalize` | 1024 |
| `subscript` | 32 | | |

### You get one CSS string for free

**Every text node already carries a ready-to-use `css` key** — no opt-in, no post-processing. It merges both halves, so whatever you send to your API is directly renderable:

```ts
const { json } = value;            // from onChange
// or useRichTextEditor().getJson()

// { type: "text", text: "Merhaba", format: 9, style: "color: #ef4444;",
//   css: "font-weight: 700; text-decoration: underline; color: #ef4444" }
```

This applies to `onChange`'s `json`, `getJson()` and the `RichTextOutput` JSON tab.

| Need | Use |
|---|---|
| The default — `css` included | `getJson()` / `onChange` |
| The raw Lexical shape | `getRawJson()`, or `<RichTextEditor inlineTextStyles={false}>` |
| `css` forced on regardless of the setting | `getStyledJson()` |
| Transform a state you already have | `withInlineTextStyles(json)` |

Tune the colours used for `highlight` / `code` with `<RichTextEditor inlineTextStyles={{ highlightColor: "#ffe08a" }}>`.

The key is **additive**: the JSON still feeds back into `initialJson` / `setJson` unchanged, and importing it never writes the derived declarations into the node's real `style`.

Lower-level helpers are exported too: `decodeTextFormat(9)` → `["bold","underline"]`, `hasTextFormat(format, "bold")`, `textFormatToCss(format)`, `textNodeCss(format, style)`.

Two details this handles that hand-rolled decoding usually gets wrong:

- **`underline` + `strikethrough` collapse into one `text-decoration`.** Emitting them as two declarations makes the second overwrite the first and silently drop one.
- **An explicit `style` beats a format-implied value** — a custom background colour wins over `highlight`'s default yellow.

### Why it isn't written into `style`

The computed CSS goes under a separate `css` key, not into Lexical's own `style`. If it were merged into `style`, that JSON would still re-import — and the derived declarations would become *real* ones. Toggling bold off afterwards would no longer clear `font-weight: 700`, because that toggle only touches `format`. Text would be permanently bold with no way back.

Keeping a separate key means the output stays safe to feed back into `initialJson` / `setJson`. You can override it with `withInlineTextStyles(json, { key: "style" })` if you're sure the JSON is write-only, but the round-trip breaks.

### Colours are dropped by `initialHtml`

Lexical's HTML importer only reads `font-weight`, `text-decoration`, `font-style` and `vertical-align` off a `<span style>`. **`color` and `background-color` are dropped on import**, so `<span style="color:#ef4444">bold</span>` comes back as plain text.

Unaffected: colours picked in the toolbar (they write the node's `style` directly) and content loaded via `initialJson`. If you migrate existing HTML articles and need their colours, convert them to Lexical JSON rather than passing them through `initialHtml`.

**If your frontend can consume HTML, you may not need any of this** — `getHtml()` already emits `<strong>`, `<em>`, `<span style="color: …">` with the formats resolved.

## Imperative API

There are two ways in, and they are not interchangeable.

**`useRichTextEditor()` — from inside the tree.** It is a hook: it reads the Lexical composer context, so it only works in a component rendered *within* `<RichTextEditor>`. It returns the full `RichTextEditorApi` object.

```tsx
import { RichTextEditor, RichTextContent, RichTextToolbar, useRichTextEditor }
  from "eglador-ui-react-rich-text";

function SaveBar() {
  const { getHtml, clear, focus } = useRichTextEditor();
  return (
    <div>
      <button onClick={() => console.log(getHtml())}>Save</button>
      <button onClick={clear}>Clear</button>
      <button onClick={focus}>Focus</button>
    </div>
  );
}

<RichTextEditor>
  <RichTextToolbar />
  <SaveBar />          {/* must be a child */}
  <RichTextContent />
</RichTextEditor>
```

**`editorRef` prop — from outside.** This takes a `Ref<LexicalEditor>` (the raw Lexical instance), *not* the API object above. Use it when the caller lives outside the editor, e.g. a form submit handler.

```tsx
import { useRef } from "react";
import type { LexicalEditor } from "lexical";
import { $generateHtmlFromNodes } from "@lexical/html";

const editorRef = useRef<LexicalEditor | null>(null);

<RichTextEditor editorRef={editorRef}>
  <RichTextToolbar />
  <RichTextContent />
</RichTextEditor>

<button
  onClick={() =>
    editorRef.current?.read(() => console.log($generateHtmlFromNodes(editorRef.current!)))
  }
>
  Save
</button>
```

## Read-Only Mode

```tsx
<RichTextEditor editable={false} initialJson={savedState}>
  <RichTextContent />
</RichTextEditor>
```

`editable` is fully reactive — toggling it after mount switches between editable and read-only via Lexical's `setEditable()` API.

## Compatibility

Works with any React-based framework: **Next.js**, **Remix**, **Vite + React**, **Gatsby**, etc.

All editor components are marked `"use client"` (Lexical requires the DOM).

## Development

```bash
npm install
npm run dev               # tsup watch mode
npm run build             # production build to dist/
npm run typecheck         # tsc --noEmit
npm run storybook         # Storybook dev (http://localhost:6006)
npm run build-storybook   # static Storybook export
```

## Publishing

Publishing is automated via GitHub Actions. When a GitHub Release is created, the package is published to npm.

1. Update `version` in `package.json`
2. Commit and push
3. Create a GitHub Release with a matching tag (e.g. `v1.0.0`)

## Author

Kenan Gündoğan — [https://github.com/kenangundogan](https://github.com/kenangundogan)

Maintained under [Eglador](https://github.com/eglador)

## License

MIT
