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'],
});
@oix1987/yjd/lib/styles.min.css (~10 KB gzip, cached) and skip
StylesLoader to keep CSS out of the JS.Editor.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.
| Preset | Includes | JS (gzip) |
|---|---|---|
| Minimal | bold · italic · underline · link | ~16 KB |
| Comment box | bold · 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 assistant | any 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)
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.
| Event | Payload |
|---|---|
change | html — 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:select | the 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
| Option | Type | Description |
|---|---|---|
placeholder | string | Empty-state text. |
content | string | Initial HTML. |
width / maxWidth | number|string | Number = px; string (e.g. '100%') for responsive. |
height / maxHeight | number | Editor body height in px. |
onChange | fn(html) | Called on every content change. |
toolbar1 / toolbar2 | array | Toolbar groups, e.g. { group, items: [] }. |
formats / modules | string[] | Which registered features to activate. |
features | object | { wordCount, breadcrumb, … } — toggle the status bar. |
image | object | { upload, accept, maxSize } — upload hook (see above). |
file | object | { upload, accept, maxSize } — attachment hook → file chip. |
mention | object | { trigger, source, renderItem, triggers } — @mention. |
ai | object | { complete, actions?, autocomplete? } — BYO-model assistant (see above). |
submit | object | { onEnter } — Enter-to-submit (see above). |
toolbar | string|object | 'full'|'compact'|{ exclude } preset, or array. |
maxContentSize | number | Emit content:overflow past this many chars. |
maxLength | number | Hard 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
| Method | Description |
|---|---|
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
--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 });