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),
});
React, Vue & Angular
yjd has no framework baked in — the constructor takes a DOM element, so you wrap it in a small component. Create it once, destroy() on unmount, and sync value only when it differs (so typing never resets the caret). Dedicated guides with a live editor: React · Vue 3 · Vue 2 · Angular · AngularJS. Runnable: React · Vue 3 · Vue 2 · AngularJS. The React and Vue 3 wrappers are below; the rest are on their pages.
React (hook, StrictMode-safe):
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(); // StrictMode-safe
}, []);
useEffect(() => { const e = ed.current;
if (e && value != null && value !== e.getContent()) e.setContent(value);
}, [value]);
return <div ref={host} />;
}
Vue 3 (composition API, v-model):
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import yjd from '@oix1987/yjd';
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: (html) => emit('update:modelValue', html) }); });
onBeforeUnmount(() => ed?.destroy());
watch(() => props.modelValue, (v) => {
if (ed && v != null && v !== ed.getContent()) ed.setContent(v);
});
// <template><div ref="host" /></template>
SSR (Next.js / Nuxt): the editor touches the DOM, so mount it client-side (React useEffect already does; Nuxt: wrap in <client-only>).
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 (~11 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 | ~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, CSS inlined | ~75 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'));
Comments, outline & versions
Opt-in review layer (new in 2.13): pass sidePanel: true and the editor grows a right rail with Outline (live H1–H3 tree, click-to-scroll), Comments and Versions tabs. A comment is a mark on a range — it survives edits inside the anchor, and if the anchored text is deleted the thread stays readable in the rail ("Anchor text was deleted").
new yjd('#editor', { sidePanel: true }); // or { tabs: ['outline','comments','versions'], user: { name: 'Ann' } }
// Select text and press ⌘⌥M — or:
editor.openCommentComposer(); // quote header · ⌘⏎ submits · Esc discards
const id = editor.addComment('Is this still true?', 'Linh');
editor.addReply(id, 'Yes — see the exporter.', 'Duc');
editor.resolveComment(id); // clears the highlight, keeps the thread (Undo in the rail)
editor.getComments(); // full thread model → persist anywhere
editor.saveVersion('Before AI pass'); // snapshots in the Versions tab, one-click restore
editor.getOutline(); // [{ level, text, el }]
Clicking a mark opens an anchored thread popover (replies, Resolve, relative times) — it renders as a bottom sheet on touch. The rail filters Open / Resolved and shows reply counts. Only the marks serialize (span.yjd-comment-mark[data-comment-id]) — thread data stays in memory, yours to persist via get/setComments() and the comment:* events.
Editor surface
Block handles — hover any block for the left-gutter pair: ⠿ drag to reorder (one undo step) and + insert below (opens the slash menu). Slash menu — type / for blocks: headings, lists, quote, code block, callout, toggle, divider, table. Selection bubble — select text for the floating format bar; it leads with a ✦ AI entry when a model is wired and ends with a ⋮ sheet (Highlight · Comment ⌘⌥M · Copy · Clear formatting).
Code blocks — a header strip shows the language and a one-click Copy. With no data-lang, the language is auto-detected from the content (14 languages; import { detectLanguage } from '@oix1987/yjd/lib/modules/code-block-tools.js'). Add data-filename for the javascript · word-diff.js form. Enter stays inside the block; Enter on an empty trailing line exits.
Drag & drop — dropping files shows a dashed frame, a "Drop to insert here" pill and a live insertion caret; every matching file inserts (images/videos inline, other files as chips when file.upload is set), and unhandled types raise a toast (editor.showToast(msg) is public). The image/video popups accept drops too.
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? } |
comment:add / comment:reply / comment:resolve / comment:unresolve / comment:remove / comment:click | thread data — persist comments from these. |
version:save / version:restore | { v, label?, words, time } |
attachment:add / attachment:remove / context:add | prompt-layout tray items (status: pending|done|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?, diff?, openOnSelect? } — BYO-model assistant (see above). |
submit | object | { onEnter } — Enter-to-submit (see above). |
toolbar | string|object | 'full'|'compact'|{ exclude } preset, or array. |
sidePanel | bool|object | Outline / Comments / Versions rail ({ tabs?, user? }). |
video | object | { upload, accept, maxSize, maxHeight } — inline video player. |
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 · code (inline) · letter-spacing
Modules
toolbar · history · slash-menu · mention · ai (BYO-model assistant) · block-toolbar (bubble bar) · table-toolbar · find-replace · code-view · resize-handles · side-panel (comments/outline/versions) · block-handles (⠿ drag + +) · code-block-tools (language label + Copy)
New in 2.13 — comment threads (⌘⌥M, thread popover, resolve, Open/Resolved rail), the side panel, block drag handles, a redesigned slash menu and selection bubble (✦ AI + ⋮ sheet), code-block language detection, full-fidelity drag & drop (multi-file, live insertion caret, in-popup drop zones) and the Editor UI 2.0 visual system (IBM Plex, 30px controls, ink-fill active states).
New in 2.3 — list includes an interactive checklist variant; table-toolbar adds cell merge/split, a 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. Theming is fully tokenised via --rte-* — see THEMING.md.
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. |
addComment(b,a?) / addReply(id,b,a?) / resolveComment(id) | Comment threads (needs sidePanel). |
openCommentComposer() / openCommentThread(id) | Composer on the selection (⌘⌥M) / anchored thread popover. |
getComments() / setComments(list) | Full thread model round-trip for persistence. |
saveVersion(label?) / getVersions() / getOutline() | Snapshots + live heading tree. |
printContent() / downloadContent('html'|'md') | Print / save with read-view styles. |
showToast(msg) | Transient status toast (used by unhandled drops). |
getAttachments() / getContext() | Prompt-layout tray files / @context chips. |
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 });