Docs

A dependency-free, tree-shakeable rich text editor. Compose only what you need.

Install

npm i @oix1987/yjd

Or use it straight from a CDN with the all-in-one UMD build:

<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

The all-in-one build registers everything and injects its CSS — one line to a full editor. The class is yjd (RichEditor still works as an alias):

import yjd from '@oix1987/yjd';

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

Tree-shakeable core

For the smallest bundle, import from @oix1987/yjd/core (side-effect-free) and register only the formats/modules you want. Anything you don't register is tree-shaken out.

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

// register only what you use
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'],
});
💡 The stylesheet stays out of the JS bundle. Link it once: @oix1987/yjd/lib/styles.min.css (~10 KB gzip, cached) and skip StylesLoader to keep CSS out of the JS.
Lightweight by designEditor.fromTextarea, renderStatic and the Markdown/JSON helpers live in /core, so a comment box (~26 KB) pulls only what you register, not the whole editor. Use Editor.fromTextarea(el, opts) from /core.

Presets

Common profiles, all built from /core. Try and edit them live in the playground.

PresetIncludesJS (gzip)
Minimalbold · italic · underline · link~16 KB
Comment boxbold · italic · link · list · image · mention + fromTextarea~26 KB
Basic+ strike · headings · lists · align~25 KB
Standard+ colour · image · table · find · code view~38 KB
+ AI assistantany preset + ai module (BYO model, no SDK bundled)+~2 KB
Full (all-in-one)everything, CSS inlined~66 KB

Export & import

Store content as HTML, a JSON tree, or Markdown — whatever your app already uses. The same converters are exported standalone (htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml).

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

Image upload

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

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 built-in base64 fallback.

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.

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)

Enter-to-submit

For comment boxes: Enter sends, Shift+Enter inserts a newline. When a mention/slash/emoji popup is open, Enter is left for the popup (picks the item, doesn't submit). Check the state yourself with editor.isMenuOpen().

new yjd('#comment', {
  submit: { onEnter: (html, editor) => post(html) },
});

@mention & #task

Type the trigger to autocomplete people or tasks from your own async source. The inserted token carries an id, so serialized content tells your server who was tagged.

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 <span class="mention" data-id="u_123">@Ann</span> → Markdown @[Ann](u_123). Default rows show avatar + name; pass icon on a source item for special entries (e.g. “@all”). Menus are portaled to <body> but inherit the editor's --rte-* theme.

AI assistant

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 — the module is inert until you do and tree-shakes to 0 when unused.

new yjd('#editor', {
  ai: {
    // REQUIRED. Resolve to the text. Stream via onToken; if you only
    // stream, return undefined and the chunks are joined.
    complete: async ({ action, prompt, text, signal }, onToken) => {
      const r = await fetch('/api/ai', { method: 'POST', signal,
        body: JSON.stringify({ action, prompt, text }) });
      return (await r.json()).text;
    },
    autocomplete: true,   // optional: inline ghost-text, Tab to accept
  },
});
editor.ai.run('Make this friendlier');   // run on the current selection
editor.on('ai:accept', ({ result }) => {});

Selection toolbar — select text → a floating bar offers Improve · Fix spelling & grammar · Shorten · Lengthen · Simplify · Summarize plus a free-form Ask AI… box. Every result is previewed with Accept / Retry / Discard, so nothing overwrites the user's text until they accept. Ghost-text autocomplete (opt-in) shows a greyed inline suggestion as they type — Tab accepts; it's debounced and request-cancelling, so it never blocks typing.

Building your own agent UI? The same primitives are public: editor.getSelection(){ text, html, isEmpty, range }, editor.replaceSelection(text, { asText: true }) (sanitized, undo-aware), and editor.streamInto() for a token-by-token sink. Events: ai:start · ai:done · ai:accept · ai:discard · ai:error.

Toolbar presets

toolbar: 'full'                  // the default set
toolbar: 'compact'               // bold/italic/underline · link · list · image · emoji
toolbar: { exclude: ['table', 'video', 'color'] }  // defaults minus these
toolbar1: [{ group, items: [...] }]      // or full custom groups

fromTextarea

Progressively enhance an existing form field with two-way sync: editor edits update textarea.value (firing native input/change), and writing textarea.value from app code updates the editor. The returned editor carries a controller.

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 and tagged .yjd-content. No editor instance needed; just load the stylesheet on the page.

import { renderStatic } from '@oix1987/yjd';

renderStatic(post.body_html, document.querySelector('#post'));

Events

Subscribe with editor.on(name, cb) and remove with editor.off(name, cb). editor.editor is the public contentEditable element.

EventPayload
changehtml — fired on every content change.
image:upload / image:uploaded / image:error{ file, url?, reason? }
file:upload / file:uploaded / file:error{ file, url?, name?, size?, reason? }
mention:selectthe chosen source item.
ai:start / ai:done / ai:accept / ai:discard / ai:error{ action?, result?, error? }
content:overflow{ size, max } — when maxContentSize is exceeded.

Options

OptionTypeDescription
placeholderstringEmpty-state text.
contentstringInitial HTML.
width / maxWidthnumber|stringNumber = px; string (e.g. '100%') for responsive.
height / maxHeightnumberEditor body height in px.
onChangefn(html)Called on every content change.
toolbar1 / toolbar2arrayToolbar groups, e.g. { group, items: [] }.
formats / modulesstring[]Which registered features to activate.
featuresobject{ wordCount, breadcrumb, … } — toggle the status bar.
imageobject{ upload, accept, maxSize } — upload hook (see above).
fileobject{ upload, accept, maxSize } — attachment hook → file chip.
mentionobject{ trigger, source, renderItem, triggers } — @mention.
aiobject{ complete, actions?, autocomplete? } — BYO-model assistant (see above).
submitobject{ onEnter } — Enter-to-submit (see above).
toolbarstring|object'full'|'compact'|{ exclude } preset, or array.
maxContentSizenumberEmit content:overflow past this many chars.
maxLengthnumberHard character limit.

Formats & modules

Register with registry.register('formats/<name>', Class) or 'modules/<name>'.

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

Methods

MethodDescription
getHTML() / getText()Read current content.
getJSON() / getMarkdown()Export as JSON tree / Markdown.
setHTML() / setJSON() / setMarkdown()Load content in any format.
insertHTML(html) / insertText(t)Insert at the caret.
getSelection() / replaceSelection(t, opts)Read / replace the selection (sanitized, undo-aware).
streamInto()Token-by-token sink for AI output (append/commit/cancel).
insertFileAttachment(file)Insert a file chip (uses file.upload).
isMenuOpen()True when a mention/slash/emoji popup is open.
on(name,cb) / off(name,cb)Subscribe / unsubscribe to events.
clear() / isEmpty()Reset / check empty.
focus()Focus the editor.
setReadOnly(bool)Toggle read-only.
undo() / redo()History control.

Styling & theming

The entire UI — editor, toolbar, popups, even the body-portaled mention/slash menus — is driven by --rte-* CSS custom properties.

Match your app

Override any token at :root (or any ancestor, or .yjd-rich-editor). 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.

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

Tokens: --rte-bg · --rte-chrome · --rte-chrome-2 · --rte-ink · --rte-muted · --rte-border · --rte-border-strong · --rte-accent · --rte-accent-ink · --rte-accent-weak · --rte-accent-ink-on · --rte-danger · --rte-link · --rte-code-bg · --rte-quote-* · --rte-table-border · --rte-radius · --rte-shadow.

Dark mode

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

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

new yjd('#editor');                    // theme:'inherit' (default) → follows <html>
new yjd('#editor', { theme: 'dark' });   // force: 'light' | 'dark' | 'auto' | 'inherit'
editor.setTheme('dark');                // at runtime; editor.getTheme() reads it
🌙 Dark mode just redefines the --rte-* tokens, so your custom overrides still apply on top. A forced theme:'light' editor stays light even inside a dark page. CSS-only: yjd-theme-dark class or data-theme="dark" on the element / any ancestor.

Height

height is px, or 'auto' to grow with content like a textarea (small min-height, no cap). Use minHeight / maxHeight for explicit bounds.

new yjd('#comment', { height: 'auto', minHeight: 80 });

→ Try the dark toggle in the integration demo