# yjd

[![npm](https://img.shields.io/npm/v/@oix1987/yjd?color=6d5efc)](https://www.npmjs.com/package/@oix1987/yjd) [![CI](https://github.com/nampick/yjd/actions/workflows/ci.yml/badge.svg)](https://github.com/nampick/yjd/actions/workflows/ci.yml) [![npm downloads](https://img.shields.io/npm/dm/@oix1987/yjd?color=6d5efc)](https://www.npmjs.com/package/@oix1987/yjd) [![license](https://img.shields.io/npm/l/@oix1987/yjd)](./LICENSE)

**Rich text, without the weight.** A dependency-free, tree-shakeable rich text editor for the web. Compose it from a `/core` entry and ship **17 KB**, not the whole library.

🔗 **[yjd.io](https://yjd.io)** · [Live playground](https://yjd.io/demos/) · [Docs](https://yjd.io/site/docs.html) · [Changelog](./CHANGELOG.md)

```js
import yjd from '@oix1987/yjd';
new yjd('#editor', { placeholder: 'Start writing…' });
```

---

## Why yjd

- **Tree-shakeable core** — register only the formats/modules you use; the rest is dropped.
- **Zero runtime dependencies** — plain DOM, ESM + UMD builds.
- **Framework-agnostic** — drop it into React, Vue, Svelte, or a static page.
- **Responsive** — toolbar fills the width on desktop, a single swipe-row on mobile.
- **XSS-safe paste** — sanitises pasted HTML (scripts/handlers/unsafe URLs stripped; only trusted embeds survive).
- **Accessible** — keyboard navigable, WCAG-AA contrast (Lighthouse a11y 100).

## New in 2.15

- **Selection bubble, grown up** — coexists with the top toolbar, overhangs
  input-sized editors instead of hiding, stays pinned through page scroll,
  takes **custom action buttons** (`{ id, title, icon|text, onClick }`), a
  filterable ⋮ sheet, and a **`showOnFocus`** mode for input-like fields.
- **Mention tokens survive every text path** — `getText()` and plain-text
  flattening emit a chip's serialized `data-token`, not its visible label.
- **AI diff-edit works on whole-field selections** — `selectNodeContents(root)`
  before `ai.run()` now gets the word-level diff.

## Earlier: 2.14

- **Plain-text mode** — `plainText: true` for chat/prompt/comment hosts that
  store text: every formatting surface is off, paste is forced plain, and
  `getContent()` / `onChange` / `submit` deliver newline-separated plain text.
  Mentions and AI keep working.
- **TypeScript under Bundler resolution** — the `exports` map now carries a
  `types` condition, so Vite-style `moduleResolution: "Bundler"` resolves
  `index.d.ts` instead of silently `any`.
- **A flat `toolbar` array is a true allow-list** — formats outside the list
  are never instantiated, so no hidden picker UI mounts behind a restricted
  toolbar.
- **`width` defaults to `'100%'`** — the editor fills its container (was a
  fixed 800px). `prompt.format` (alias of `tools`) with `[]` renders no format
  buttons, and the prompt bar's Send button renders only when a `submit`
  handler is configured.

## Earlier: 2.13

- **Comment threads** — select text and press ⌘⌥M (or call
  `openCommentComposer()`): a quote-header composer marks the range; the mark
  opens an anchored thread popover (replies, resolve/unresolve, relative
  times; a bottom sheet on touch). The side rail filters Open/Resolved,
  shows reply counts, and keeps threads readable even after the anchor text
  is deleted. `addReply()` / `resolveComment()` / `get/setComments()` +
  `comment:*` events for persistence.
- **Side panel** — opt-in right rail (`sidePanel: true`): live **Outline**
  (H1–H3, click-to-scroll), **Comments**, and **Versions** (manual snapshots,
  one-click restore).
- **Editor surface (UI 2.0 pt. 2)** — block **drag handles** (⠿ reorder + ➕
  insert-below with slash menu), a redesigned **slash menu**, selection
  **bubble** with a ✦ AI entry and a ⋮ overflow sheet, labelled list/align
  picker menus, and IBM Plex type across the chrome.
- **Code blocks** — header strip with **automatic language detection**
  (14 languages, `detectLanguage` exported), `data-filename` labels and
  one-click Copy; Enter stays in the block, double-Enter exits.
- **Drag & drop** — dashed drop frame with a live insertion caret, multi-file
  drops insert every matching file, in-popup drop zones for image/video, and
  a toast (`editor.showToast()`) when a type isn't enabled.
- **Attachment tray v2** — 48px media tiles / 32px file chips, spinner while
  the upload hook runs, danger chip + **retry** on failure.
- **Sticky chrome** — the toolbar never shrinks under a tall document and
  pins to the viewport top while the page scrolls.

Earlier highlights (2.12): Editor UI 2.0 token pass — indigo accent, semantic
state tokens, `--rte-ctl` density knob, redrawn icon family. (2.7): prompt /
chat layout, attachment tray, `submit.enterToSend`. (2.3): checklists,
full-screen, image align & live resize, richer tables, `--rte-*` theming.
Full history in the [changelog](./CHANGELOG.md).

## Bundle size (gzipped JS)

Every preset is built from the same `/core` entry — pick a profile, tree-shake the rest.

| Preset | Includes | Size |
|---|---|---|
| Minimal | bold · italic · underline · link | **~17 KB** |
| Bubble | + strike · headings · lists · font · bubble bar | **~23 KB** |
| Basic | + strike · headings · lists · align | **~28 KB** |
| Standard | + colour · image · table · find · code view · resize | **~46 KB** |
| + AI assistant | any preset + `ai` module (BYO model, no SDK bundled) | **+~2 KB** |
| Full (all-in-one) | everything (comments, side panel, block handles, …), CSS inlined | **~108 KB** |

> All figures are measured gzip (gzip -9). Tree-shake from the `/core` entry to land
> near the top of the table; the all-in-one default (`import yjd from '@oix1987/yjd'`)
> is the ~108 KB row because it registers every format/module and inlines the CSS.
> Icons and the optional Editor methods (see below) tree-shake per feature, so a
> Minimal build ships ~5 icons, not all 64. The standalone stylesheet is ~19 KB gzip
> — link it once (and skip `StylesLoader`) to keep it out of the JS.

## Install

```bash
npm i @oix1987/yjd
```

Or via CDN (all-in-one UMD):

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@oix1987/yjd/lib/styles.min.css">
<script src="https://cdn.jsdelivr.net/npm/@oix1987/yjd"></script>
<script> new yjd('#editor'); </script>
```

## Quick start (all-in-one)

The default build registers everything and injects its CSS:

```js
import yjd from '@oix1987/yjd';   // `RichEditor` is kept as an alias

const editor = new yjd('#editor', {
  placeholder: 'Start writing…',
  onChange: (html) => console.log(html),
});
```

Via `<script>` (UMD) the global is `yjd` (and `window.RichEditor` still works):

```html
<script src="https://unpkg.com/@oix1987/yjd"></script>
<script>const editor = new yjd('#editor');</script>
```

## React, Vue & Angular

yjd has no framework baked in — the constructor takes a DOM element, so you wrap
it in a small component. Guides with a live editor:
**[React](https://yjd.io/site/react)** ·
**[Vue 3](https://yjd.io/site/vue)** ·
**[Vue 2](https://yjd.io/site/vue2)** ·
**[Angular](https://yjd.io/site/angular)** ·
**[AngularJS](https://yjd.io/site/angularjs)**. Runnable demos:
[React](https://yjd.io/examples/react.html) ·
[Vue 3](https://yjd.io/examples/vue.html) ·
[Vue 2](https://yjd.io/examples/vue2.html) ·
[AngularJS](https://yjd.io/examples/angularjs.html).

The React and Vue 3 wrappers are below; Vue 2 (Options API), Angular (standalone
component with `[(value)]`) and AngularJS (an `ng-model` directive) are on their
pages above.

**React** — a hook wrapper (StrictMode-safe; `destroy()` on cleanup, `setContent`
only when the value differs so typing never resets the caret):

```jsx
import { useEffect, useRef } from 'react';
import yjd from '@oix1987/yjd';
import '@oix1987/yjd/styles.css';

export function Editor({ value, onChange, placeholder }) {
  const host = useRef(null), ed = useRef(null);
  useEffect(() => {
    ed.current = new yjd(host.current, { content: value ?? '', placeholder,
      onChange: (html) => onChange?.(html) });
    return () => ed.current.destroy();
  }, []);
  useEffect(() => {
    const e = ed.current;
    if (e && value != null && value !== e.getContent()) e.setContent(value);
  }, [value]);
  return <div ref={host} />;
}
```

**Vue 3** — a composition-API component with `v-model`:

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import yjd from '@oix1987/yjd';
import '@oix1987/yjd/styles.css';

const props = defineProps({ modelValue: String, placeholder: String });
const emit = defineEmits(['update:modelValue']);
const host = ref(null); let ed;
onMounted(() => { ed = new yjd(host.value, { content: props.modelValue ?? '',
  placeholder: props.placeholder, onChange: (h) => emit('update:modelValue', h) }); });
onBeforeUnmount(() => ed?.destroy());
watch(() => props.modelValue, (v) => {
  if (ed && v != null && v !== ed.getContent()) ed.setContent(v);
});
</script>
<template><div ref="host" /></template>
```

> **SSR (Next.js / Nuxt):** the editor touches the DOM, so mount it client-side —
> React `useEffect` already does; for a whole page use `dynamic(() => import('./Editor'), { ssr: false })`, and in Nuxt wrap the component in `<client-only>`.

## Tree-shakeable core

For the smallest bundle, import from `@oix1987/yjd/core` (side-effect-free) and register only what you need. Link the stylesheet once.

```js
import { Editor, registry, Bold, Italic, Underline, Link, Toolbar, History }
  from '@oix1987/yjd/core';

registry.register('formats/bold', Bold);
registry.register('formats/italic', Italic);
registry.register('formats/underline', Underline);
registry.register('formats/link', Link);
registry.register('modules/toolbar', Toolbar);
registry.register('modules/history', History);

new Editor('#editor', {
  formats: ['bold', 'italic', 'underline', 'link'],
  modules: ['toolbar', 'history'],
});
```

```html
<link rel="stylesheet" href="@oix1987/yjd/lib/styles.min.css">
```

### Lightweight comment box (~26 KB)

`Editor.fromTextarea`, `renderStatic` and the Markdown/JSON helpers all live in
`/core`, so a comment box pulls only the formats/modules you register — not the
whole editor. Link the stylesheet (don't import `StylesLoader`) to keep CSS out
of the JS.

```js
import { Editor, registry, Bold, Italic, Link, List, Image, Mention, Toolbar, History }
  from '@oix1987/yjd/core';

[['formats/bold', Bold], ['formats/italic', Italic], ['formats/link', Link],
 ['formats/list', List], ['formats/image', Image],
 ['modules/mention', Mention], ['modules/toolbar', Toolbar], ['modules/history', History]]
  .forEach(([k, v]) => registry.register(k, v));

const ed = Editor.fromTextarea('#comment', {
  format: 'markdown',
  toolbar1: [{ group: 'insert', items: ['bold', 'italic', 'link', 'list', 'image', 'emoji'] }],
  mention: { source: (q) => fetchUsers(q) },
  submit: { onEnter: (html) => post(html) },
});
```

## Options

| Option | Type | Description |
|---|---|---|
| `placeholder` | string | Empty-state text. |
| `content` | string | Initial HTML. |
| `width` / `maxWidth` | number｜string | Number = px; string (e.g. `'100%'`) = responsive. |
| `height` | number｜`'auto'` | px, or `'auto'` to grow with content (no cap). |
| `minHeight` / `maxHeight` | number | Explicit bounds (override the height defaults). |
| `theme` | `'inherit'`｜`'light'`｜`'dark'`｜`'auto'` | `'inherit'` (default) follows ancestor `[data-theme]`. |
| `onChange` | fn(html) | Called on every content change. |
| `autoFocus` | boolean | Focus the editor after mount (default `true`). Set `false` for editors rendered on page load — avoids scroll-jump and mobile keyboard pop. |
| `list.types` | string[] | Restrict the list picker, e.g. `list: { types: ['bullet', 'ordered'] }`. Valid: `checklist`, `bullet`, `ordered`, `roman`, `alpha`. |
| `toolbar1` / `toolbar2` | array | Toolbar groups: `{ group, items: [] }`. |
| `formats` / `modules` | string[] | Which registered features to activate. |
| `features` | object | `{ wordCount, breadcrumb, … }` — toggle the status bar. |
| `maxLength` | number | Hard character limit. |

## Formats & modules

**Formats** — `bold` · `italic` · `underline` · `strike` · `subscript` · `superscript` · `color` · `background` · `link` · `heading` · `font-family` · `text-size` · `line-height` · `capitalization` · `text-align` · `list` · `indent-increase` · `indent-decrease` · `image` · `video` · `table` · `emoji` · `tag`

**Modules** — `toolbar` · `history` · `slash-menu` · `mention` · `ai` (BYO-model assistant) · `block-toolbar` (bubble bar) · `table-toolbar` · `find-replace` · `code-view` · `resize-handles`

> `list` includes an interactive **checklist** variant; `table-toolbar` adds cell
> **merge/split**, **header-row** toggle and per-cell **background/alignment**;
> `resize-handles` drives image **resize + alignment**. Full-screen mode and
> auto-linkify ship with the default toolbar/build.

## Methods

`getHTML()` · `getText()` · `insertHTML(html)` · `insertText(t)` · `clear()` · `isEmpty()` · `focus()` · `setReadOnly(bool)` · `undo()` · `redo()`

### Optional methods (tree-shaken from `/core`)

The default build (`import yjd from '@oix1987/yjd'`) ships everything below — **no
setup needed**. They're split out only so a hand-tree-shaken **`/core`** build can
drop the ones it doesn't use (that's how a Minimal build lands at ~17 KB). If you
build from `/core` and want these, opt in once, per group:

```js
import { Editor, applySerializeMethods, applyEditorCommands, applyEditorInput } from '@oix1987/yjd/core';

applySerializeMethods(Editor); // getJSON/setJSON · getMarkdown/setMarkdown · fromTextarea({format:'markdown'})
applyEditorCommands(Editor);   // toggleFullscreen · text direction · clearFormatting
applyEditorInput(Editor);      // image & file insert (drop/paste/picker) · auto-linkify · markdown shortcuts
```

> These affect **only** hand-rolled `/core` builds. Every preset/CDN/all-in-one
> import is unchanged and fully backward compatible.

## Integration API

Drop yjd into an existing app — upload images to your storage, tag people, store
content in whatever format you already use, and progressively enhance `<textarea>`s.

### Export / import (HTML · JSON · Markdown)

```js
editor.getHTML();      editor.setHTML(html);
editor.getJSON();      editor.setJSON(json);     // { type: 'doc', content: [...] }
editor.getMarkdown();  editor.setMarkdown(md);   // mention ids survive round-trips
```

Also exported standalone: `htmlToMarkdown`, `markdownToHtml`, `domToJson`, `jsonToHtml`.

### Image upload hook

Provide `image.upload` to send files to your server/CDN instead of inlining base64.
Applies to every insert path — toolbar, paste, and drag-drop. A placeholder shows
while uploading; the `src` is swapped on success, or the image is removed on failure.

```js
new yjd('#editor', {
  image: {
    upload: async (file) => (await api.upload(file)).url,   // return the URL
    accept: 'image/png,image/jpeg,image/webp',
    maxSize: 8 * 1024 * 1024,
  },
});
// events: editor.on('image:upload'|'image:uploaded'|'image:error', cb)
```

Omit `upload` to keep the base64 fallback.

### Video

Insert video from the toolbar (`video` button → URL / YouTube / file upload), or
just **drag-and-drop or paste a video file** onto the editor — it inserts an inline
`<video controls>` player (parallel to images). Provide `video.upload` to send the
file to your server/CDN instead of inlining a data URL; omit it for the data-URL
fallback. All three file paths — toolbar popup, drag-drop, and paste — go through
the same hook. Videos serialize to Markdown as `![video](src)` and round-trip
back to an inline player (or a YouTube/Vimeo embed iframe). A tall/portrait clip is capped to `maxHeight` (default **480px**, matching typical read-view caps so composing looks like viewing) on
insert so it doesn't blow out the frame — the user can still drag-resize larger.

```js
new yjd('#editor', {
  video: {
    upload: async (file) => (await api.upload(file)).url,   // return the URL
    accept: 'video/mp4,video/webm',
    maxSize: 50 * 1024 * 1024,
    maxHeight: 480,                                          // display cap (px or CSS length)
  },
});
// events: editor.on('video:upload'|'video:uploaded'|'video:error', cb)
```

In the **prompt layout**, a video added via the `+` menu rides along as an
attachment chip (with a first-frame preview + play badge) and uploads via
`video.upload` (falling back to `image.upload`); read the uploaded URL via
`getAttachments()[].src`.

### File attachments

Like `image`, but for any file. Uploads via `file.upload` and inserts a **file chip**
(icon + name + size) that serializes to a Markdown link `[name (size)](url)`. Works
from the toolbar (`file` button), paste, and drag-drop.

```js
new yjd('#editor', {
  toolbar1: [{ group: 'insert', items: ['image', 'file'] }],   // add the paperclip
  file: {
    upload: async (f) => ({ url: (await api.upload(f)).url, name: f.name }),
    accept: '.pdf,.zip,.docx',
    maxSize: 25 * 1024 * 1024,
  },
});
// events: editor.on('file:upload' | 'file:uploaded' | 'file:error', cb)
```

Cap how big inserted images *display* (so a large upload doesn't blow out the
frame) with `image.maxHeight` / `image.maxWidth` (px or any CSS length):

```js
new yjd('#editor', { image: { upload, maxHeight: '60vh' } });
```

**Migrate legacy base64 media.** If you have saved drafts from before you wired
an upload hook, they still carry inline `data:` images/videos. Opt into
`media.migrateDataUrls` and yjd re-uploads each one through the matching hook on
load and swaps the src in place — so old bodies stop re-embedding megabytes:

```js
new yjd('#editor', {
  media: { migrateDataUrls: true },
  image: { upload },   // (video.upload used for <video>, falling back to image.upload)
});
// events: editor.on('media:migrate' | 'media:migrated' | 'media:migrate-error', cb)
```

### Localisation (`strings`)

Every built-in UI string — toolbar tooltips, popup titles/buttons, add-menu
labels, the "Uploading" chip — resolves through one `strings` option, so you can
translate the editor without forking it. Pass a flat map or a function; anything
missing keeps the English default.

```js
new yjd('#editor', {
  strings: {
    'toolbar.bold': 'Đậm', 'toolbar.link': 'Chèn liên kết',
    'popup.insertLink': 'Chèn liên kết', 'apply': 'Áp dụng', 'cancel': 'Huỷ',
    'addMenu.image': 'Thêm ảnh', 'uploading': 'Đang tải…',
  },
});
// or hook your own i18n: strings: (key, fallback) => t('yjd.' + key) ?? fallback
```

Every built-in surface goes through this lookup — toolbar tooltips
(`toolbar.*`), the slash menu (`slash.*`), side panel (`panel.*`), find &
replace (`find.*`), the AI bar (`ai.*`), block/table toolbars (`block.*` /
`table.*`), all popups (`popup.*`, `tag.*`, `import.*`), format dropdowns
(`heading.*`, `size.*`, `case.*`, `spacing.*`, `lineHeight.*`, `align.*`,
`list.*`, `color.*`), the emoji picker (`emoji.*`), the status bar (`status.*`),
and the editor placeholder (`editor.placeholder`). A composite string takes
`{name}` placeholders: `t('toolbar.hideNTools', 'Hide {n} more tools', { n })`.
`editor.t(key, fallback, params?)` is the same lookup for custom chrome.

> The emoji **search index** stays keyed on the built-in English emoji names
> (`grinning`, `thumbsup`, …); only the category labels and picker chrome
> localise. Font-family names are proper nouns and are left as-is.

### Popover placement in host apps (`popup`)

Toolbar popovers (link/image/video/tag/table/import/emoji/list/align, format
dropdowns) default to `popup: 'auto'`: they sit inside the editor as usual, but
if a host ancestor between the editor and `<body>` **clips** (`overflow` other
than `visible`) or **re-roots** (`transform`/`filter`/`perspective`/`contain`)
them — a card with `overflow:hidden`, a scroll pane, a CSS-transform panel — the
editor portals the popover to `<body>` with `position: fixed` so it can't be cut
off or buried under host chrome. Force it with `popup: 'fixed'` (always portal)
or opt out with `popup: 'wrapper'` (legacy in-editor placement):

```js
new yjd('#editor', { popup: 'fixed' });   // e.g. editor lives inside a modal/card
```

Portaled popovers carry the editor's theme class + `--rte-*` tokens, so they
render identically; they reposition on scroll/resize and are removed on
`destroy()`.

### Enter-to-submit (comment boxes)

```js
new yjd('#comment', {
  submit: {
    onEnter: (html, editor) => post(html),   // Enter sends; Shift+Enter = newline
  },
});
```

When a mention/slash/emoji popup is open, Enter is left for the popup (it picks the
item, doesn't submit). Check it yourself with `editor.isMenuOpen()`.

### Prompt / chat layout

`layout: 'prompt'` turns the editor into a chat-style input: a rounded pill with
the toolbar as a bottom action bar — `[ + add ]  [ format tools ]  …  [ send ]` —
that grows with its content (`height:'auto'`). Enter and the send button both call
your `submit` handler. The **send button renders only when a `submit` handler is
configured** — a prompt-styled field with nothing to send gets no dead button.

> **Leave `toolbar` unset** with `layout:'prompt'` (or pass `toolbar:'prompt'`).
> Passing an explicit toolbar **array** or `toolbar1/toolbar2` opts out of the
> prompt bar and renders that layout instead. A plain `{ overflow, exclude }`
> object keeps the prompt bar (its flags are ignored — configure tools via
> `prompt.format`). Bar buttons never steal focus, so the mobile soft keyboard
> stays up across sends.

```js
new yjd('#prompt', {
  layout: 'prompt',
  placeholder: 'Message…',
  submit: { onSubmit: (html, editor) => send(html) },  // Enter + send button
});
```

Defaults are mobile-first: **"+" adds an image** straight away (opens the file
picker — no popover), plus **bold / italic** and the send button. Configure more:

```js
prompt: {
  add:    ['image', 'file', 'video', 'table'],  // 2+ items → "+" opens a popover menu
  format: ['bold', 'italic', 'link'],           // format buttons next to "+" ([] = none; `tools` is an alias)
}
```

`prompt.add` accepts built-in keys (`image` · `file` · `video` · `table`),
`'separator'`, or custom items `{ label, icon?, onSelect(editor) }`. On mobile (or
with a single item) `+` adds an image directly; on desktop with two or more items
it opens a popover menu (portaled so it's never clipped, loaded lazily).

**Attachments.** In the prompt layout, `image` / `file` / `video` attach as
chat-style thumbnails with a remove button — above the bar, not inserted into the
message text. Read them in your submit handler and clear happens automatically:

```js
submit: {
  onSubmit: (html, ed) => send(html, ed.getAttachments()),
}
```

`table` still inserts inline. `getAttachments()` returns
`{ id, kind, file, src, status, meta }` per item — `src` is the upload-hook URL
when `image.upload` is set (otherwise the image data URL); `status` is
`'pending' | 'done' | 'error'`; `meta` is an open bag you can fill.

For apps with their own attachment model, drive the tray by events instead:

```js
prompt: {
  deferUpload: true,              // skip the built-in upload hook on add
  serializeAttachments: true,     // or (att) => `![](${att.src})` — append to submit content
}
editor.on('attachment:add', (att) => {          // att is the live item
  uploadToMyServer(att.file).then((r) => {       // write results back onto it
    att.src = r.url; att.meta = r; att.status = 'done';
  });
});
editor.on('attachment:remove', (att) => cancelUpload(att.id));
```

`serializeAttachments` appends attachments to the content (`true` = default
`<img>`/`<a>` HTML — or Markdown `![](src)` when the store is markdown; a function
returns a custom string per attachment). It flows into **both** the submit
handler's content arg **and** `Editor.fromTextarea`'s synced `ta.value` /
`getMarkdown()` (in the textarea's format), so a `<textarea>`-backed form that
posts `ta.value` gets the attachments without a manual flush. Adding/removing an
attachment re-syncs the value immediately. Call `editor.clearAttachments()` (or
`editor.clear()`, which also empties the tray) to reset after posting.

**For AI chat inputs**, the prompt bar can show a live **token/cost meter** and
carry **context chips** (`@file` / `@selection` references):

```js
prompt: { tokens: { costPer1k: 0.01 } }   // "~1.2k tokens · $0.012" by the send button

editor.addContext({ label: '@report.md', value: fileId });  // a removable chip in the tray
submit: { onSubmit: (html, ed) => send(html, ed.getContext()) }  // [{ id, label, value }]
```

### Plain-text mode

Chat, prompt and comment hosts usually store **text**, not HTML. `plainText:
true` makes the editor honest about that: every formatting surface is off
(toolbar/prompt-bar format buttons, ⌘B/I/U/K, markdown input rules,
auto-linkify), paste is forced plain, and `getContent()`, `onChange` and the
`submit` handler all deliver **plain text** — blocks separated by `\n`.
`setContent()` flattens any markup it is given.

```js
new yjd('#chat', {
  layout: 'prompt',
  plainText: true,                      // text in, text out — nothing to strip on save
  submit: { onSubmit: (text) => send(text) },
});
```

Mentions and the AI module keep working — both round-trip plain text. With
`prompt.serializeAttachments`, attachment tails use the markdown shape
(`![](src)` / `[name](url)`) instead of HTML tags.

### Selection bubble

The bubble (block-toolbar module) floats above the selection, coexists with the
top toolbar, freely **overhangs short editors** (an input-sized field still gets
its tools), and stays pinned to its field through page scrolling. Configure it
via the `'block-toolbar'` key:

```js
new yjd('#field', {
  'block-toolbar': {
    showOnFocus: true,                     // input-like: appears on focus, hides on blur
    buttons: [
      'bold', 'italic',
      { id: 'emoji', title: 'Emoji', text: '😀',    // custom action (#74)
        onClick: (editor, selection) => openEmojiPicker(editor) },
    ],
    sheet: false,                          // '⋮' overflow: true | false | ['copy', …]
  },
});
```

`showOnSelection` (default) and `showOnEnter` still exist; `showOnFocus` keeps
the bubble through a collapsed caret — it is the field's toolbar, not a
selection affordance.

### @mention / #task

```js
new yjd('#editor', {
  mention: {
    trigger: '@',
    source: async (q) => fetchUsers(q),         // [{ id, name, avatar_url }]
    renderItem: (u) => `<img src="${u.avatar_url}"> ${u.name}`,
    triggers: [{ char: '#', source: (q) => fetchTasks(q) }],   // optional extra
  },
});
editor.on('mention:select', (item) => { /* … */ });
```

Inserts a token that serializes with its id:
`<span class="mention" data-id="u_123">@Ann</span>` → Markdown `@[Ann](u_123)`.
Pass `serialize(item) → string` (per trigger, or on the primary config) to control
the exact token — e.g. `{ char: '#', source, serialize: (t) => '#' + t.id }` stores
a bare `#id` with no regex post-processing. If a `source` item has no `avatar_url`,
pass `icon` (inline SVG) for special entries like “@all”. Menus are portaled to
`<body>` but inherit the editor's `--rte-*` theme.

Both Enter-to-send and the send button run `submit`; use `submit.enterToSend`
(`'auto'` · `'always'` · `'never'`) to decouple them — e.g. `'never'` keeps the
send button while making Enter a newline everywhere. Format tools with popovers
(`list`, `emoji`, `link`, `heading`, …) work in the prompt bar too — their
popovers open above it and are portaled so they're never clipped.

### AI assistant (bring your own model)

Turn yjd into a "write-with-AI" surface **without bundling any model**. Like
`mention.source`, you supply a `complete` hook that calls whatever LLM you like
(Claude, your own endpoint, anything). The module is **inert until you do**, and
**tree-shakes to 0** when unused — so the AI code never reaches users who don't
opt in.

```js
new yjd('#editor', {
  ai: {
    // REQUIRED. Resolve to the generated text. Stream by calling onToken with
    // each chunk; if you only stream, return undefined and chunks are joined.
    complete: async ({ action, prompt, text, signal }, onToken) => {
      const res = await fetch('/api/ai', {
        method: 'POST', signal,
        body: JSON.stringify({ action, prompt, text }),
      });
      return (await res.json()).text;
    },
    autocomplete: true,   // optional: inline ghost-text, Tab to accept
    diff: true,           // default: accept AI edits as an inline word diff
  },
});
```

**What the user gets:**

- **Selection toolbar** — select text and a floating bar offers *Improve · Fix
  spelling & grammar · Shorten · Lengthen · Simplify · Summarize* plus a free-form
  **Ask AI…** box. The result is previewed with **Accept / Retry / Discard** — the
  user always stays in control (nothing overwrites their text until they accept).
- **Diff-edit** (on by default) — when you Accept an edit of selected text, the
  change lands as an inline **word-level diff** (green additions, struck-through
  removals). Click any word to keep/drop it, then **Accept** or **Reject** the
  whole thing. Set `ai.diff:false` for the old replace-on-accept behaviour.
- **Ghost-text autocomplete** (opt-in) — a greyed inline suggestion as they type;
  **Tab** accepts, any other key dismisses. Debounced and request-cancelling, so it
  never blocks typing.

Customise the actions, or drive it programmatically:

```js
ai: { complete, actions: [{ id: 'tr', label: 'Translate → FR', prompt: 'Translate to French.' }] }

editor.ai.run('Make this sound friendlier');   // run on the current selection
editor.on('ai:accept', ({ result }) => {});     // ai:start · ai:done · ai:error · ai:accept · ai:discard
```

Building your own agent UI? The same primitives are public:

```js
editor.getSelection();                 // { text, html, isEmpty, range }
editor.replaceSelection(text, { asText: true });   // sanitized, undo-aware
const s = editor.streamInto();         // token-by-token plain-text sink
s.append('Hel'); s.append('lo'); s.commit();       // or s.cancel() to undo the stream

// Render an LLM reply as it streams, formatted + partial-safe (needs the
// serialize methods / all-in-one build):
const md = editor.streamMarkdown();
for await (const chunk of res) md.append(chunk);   // **bold**, ```code```, lists…
md.commit();                                       // finalize the formatted output
```

Nothing the selection toolbar renders lives in the editable DOM, so
`getContent()` / `getJSON()` / `onChange` stay clean. Menus portal to `<body>`
but inherit the editor's `--rte-*` theme.

**More AI-era hooks:**

```js
ai: { trackAuthorship: true }          // tag AI-written spans (data-ai)
editor.showAiMarks(true);              // highlight them · getAiRanges() · stripAiMarks()

// Slash → AI: with the slash-menu module, "/" offers "Ask AI…" — it selects the
// current block and opens the ask bar, so the edit lands as a diff.
```

**On-device (privacy-first).** `complete` is just a function, so you can run the
model in the browser — no server, nothing leaves the device:

```js
import { pipeline } from '@huggingface/transformers';   // WebGPU
const gen = await pipeline('text-generation', 'onnx-community/Qwen2.5-0.5B-Instruct');
new yjd('#editor', {
  ai: {
    complete: async ({ prompt, text }, onToken) => {
      const out = await gen(`${prompt}\n\n${text}`, { max_new_tokens: 200 });
      return out[0].generated_text;
    },
  },
});
```

### Toolbar presets

```js
toolbar: 'full'                    // the default set
toolbar: 'compact'                 // bold/italic/underline · link · list · image · emoji
toolbar: { exclude: ['table','video','color'] }   // defaults minus these
toolbar: { overflow: false }       // never split into a "⋯" second row; wraps instead
toolbar1: [{ group, items: [...] }]               // or full custom groups
```

### fromTextarea (two-way + controller)

Enhance an existing form field. Binding is **two-way**: editor edits update
`textarea.value` (firing native `input`/`change`), and `textarea.value = …` from app
code updates the editor. The returned editor carries a controller.

```js
const ed = yjd.fromTextarea('#body', { format: 'markdown' }); // or 'html'
ed.setValue(md);   // load content
ed.getValue();     // current content (per format)
ed.destroy();      // remove editor, restore textarea + last value
```

### renderStatic

Render stored HTML into a read-only view that matches the editor exactly (sanitized,
tagged `.yjd-content`) — no editor instance needed. Load the stylesheet on the page.

```js
import { renderStatic } from '@oix1987/yjd';
renderStatic(post.body_html, document.querySelector('#post'));
```

### Events & API notes

- **Events** (via `editor.on(name, cb)` / `editor.off(name, cb)`): `change`,
  `image:upload` · `image:uploaded` · `image:error`, `video:upload` · `video:uploaded`
  · `video:error`, `file:upload` · `file:uploaded`
  · `file:error`, `media:migrate` · `media:migrated` · `media:migrate-error`
  (with `media.migrateDataUrls`), `mention:select`, `ai:start` · `ai:done` ·
  `ai:accept` · `ai:discard` · `ai:error`, `content:overflow` (when
  `maxContentSize` exceeded).
- `editor.editor` is the public contentEditable element (attach your own listeners).
- **Markdown dialect** — GFM-ish: headings `#`–`######`, `**bold**`, `*italic*`,
  `~~strike~~`, `` `code` ``, fenced ``` ``` ```, `>` quotes, `-`/`1.` lists,
  pipe tables, `![alt](src)` images, `[text](url)` links, mentions `@[Name](id)` /
  `#[Name](id)`, file chips `[name (size)](url)`. Checklists round-trip as GFM task
  lists — `- [x]` / `- [ ]`.

## Styling & theming

The whole UI — editor, toolbar, popups, and the body-portaled mention/slash menus —
is driven by `--rte-*` CSS custom properties. For the full token reference and
recipes, see **[docs/THEMING.md](docs/THEMING.md)**.

**Match your app** — override any token at `:root` (or any ancestor, or
`.yjd-rich-editor`); the editor follows your colours, no dark class needed. All
of yjd's CSS lives in an `@layer yjd` cascade layer, so **any unlayered rule of
yours always wins** — even `:root { --rte-bg: … }` against the built-in dark
theme — with **no `!important` and no specificity battles**:

```css
:root {
  --rte-accent: #e0488b;   /* brand */
  --rte-bg:     #fffdf7;   /* surface */
  --rte-ink:    #2a2320;   /* text */
  --rte-border: #ece4d6;
  --rte-radius: 10px;
}
```

**Icons.** Every UI glyph shares one size token, `--rte-icon-size` (default
`16px`) — set it at `:root`/`.yjd-rich-editor`, or per-editor via `iconSize`. Swap
any glyph (or add your own) with `icons`, the `registerIcons` export, or the static
`RichEditor.registerIcons` — pass `{ name: '<svg …>' }` (the registry is global):

```js
new yjd('#editor', {
  iconSize: 18,                                   // one knob → toolbar, menus, chips
  icons: { bold: '<svg …>★</svg>' },              // replace a built-in, or add new names
});
```

> **Token reference** — surfaces: `--rte-bg` (editor/popups), `--rte-bg-2`
> (canvas), `--rte-chrome`(`-2`/`-3`) (toolbar / hovers+statusbar / pressed).
> Text: `--rte-ink`(`-2`), `--rte-muted`, `--rte-faint`. Lines: `--rte-border`,
> `--rte-border-strong` (popover frames), `--rte-hairline`. Accent:
> `--rte-accent`, `--rte-accent-ink` (text on light), `--rte-accent-weak`
> (tints/active), `--rte-accent-ink-on` (text on an accent fill), `--rte-accent-ring`
> (focus), `--rte-sel` (text selection). State: `--rte-danger`/`-success`/
> `-warning`/`-info` (each with a `-weak` wash). Content: `--rte-link`,
> `--rte-code-bg`/`-ink`, `--rte-code-block-bg`/`-ink`,
> `--rte-quote-bg`/`-border`/`-ink`, `--rte-table-border`.
> Shape/metrics: `--rte-radius`(`-md`/`-sm`/`-xs`), `--rte-shadow`(`-sm`/`-lg`),
> `--rte-ctl` (control height / density: 26 compact · 30 default · 34 roomy),
> `--rte-ui`/`--rte-mono` (chrome fonts). Full tables: [docs/THEMING.md](docs/THEMING.md).

**Dark mode** — built in. By default `theme: 'inherit'` follows the nearest
ancestor `[data-theme]`, so toggling **one** attribute on `<html>` themes every
editor *and* `renderStatic` read-view with zero per-editor config:

```html
<html data-theme="dark">   <!-- your app's theme switch — editors follow it -->
```

```js
new yjd('#editor');                      // theme:'inherit' (default) → follows <html>
new yjd('#editor', { theme: 'dark' });   // force: 'light' | 'dark' | 'auto' (OS) | 'inherit'
editor.setTheme('dark');                 // at runtime; editor.getTheme() reads it
```

CSS-only alternatives: `yjd-theme-dark` class, or `data-theme="dark"` on the
element / any ancestor. A forced `theme:'light'` editor stays light even inside a
dark page.

## Development

```bash
npm install
npm run build     # dist/ (UMD + ESM) + bundled /core; regenerates CSS
npm test          # unit tests (node:test + jsdom)
npm run size      # size-limit — bundles stay within budget
```

Releasing: `npm run release -- <x.y.z>` runs the gates and prints the git → tag
→ publish steps. Pushing the tag runs `.github/workflows/release.yml` (npm
publish with provenance + GitHub Release). See [CONTRIBUTING.md](./CONTRIBUTING.md).

The website (landing, docs, playground, comparisons at **[yjd.io](https://yjd.io)**)
lives in a separate repo, **[`nampick/yjd-site`](https://github.com/nampick/yjd-site)**,
which consumes this package.

## License

MIT © Nguyen Tuan Nam — see [LICENSE](./LICENSE).
