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'],
});
💡 The stylesheet stays out of the JS bundle. Link it once: @oix1987/yjd/lib/styles.min.css (~11 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~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 assistantany 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)
💡 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'));

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.

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? }
comment:add / comment:reply / comment:resolve / comment:unresolve / comment:remove / comment:clickthread data — persist comments from these.
version:save / version:restore{ v, label?, words, time }
attachment:add / attachment:remove / context:addprompt-layout tray items (status: pending|done|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?, diff?, openOnSelect? } — BYO-model assistant (see above).
submitobject{ onEnter } — Enter-to-submit (see above).
toolbarstring|object'full'|'compact'|{ exclude } preset, or array.
sidePanelbool|objectOutline / Comments / Versions rail ({ tabs?, user? }).
videoobject{ upload, accept, maxSize, maxHeight } — inline video player.
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 · 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.13comment 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.3list 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

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.
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
🌙 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