@oix1987/yjd

Dependency-free, tree-shakeable rich text editor & WYSIWYG for the web — comment threads, outline/versions rail, block drag handles, slash menu, code blocks with language detection, chat/prompt input layout with first-class plain-text mode, bring-your-own
13,509 3
Install
npm install @oix1987/yjd
Latest Version:2.15.0
License:MIT
Last Updated:Aug 21, 2026
Links: GitHub  ·  npm
Maintainer: hahaotnmc

yjd

npm CI npm downloads 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 · Live playground · Docs · Changelog

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.

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

npm i @oix1987/yjd

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

<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:

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):

<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 · Vue 3 · Vue 2 · Angular · AngularJS. Runnable demos: React · Vue 3 · Vue 2 · AngularJS.

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):

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:

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

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'],
});
<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.

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:

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)

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.

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](https://raw.githubusercontent.com/nampick/yjd/HEAD/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.

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)](https://github.com/nampick/yjd/blob/HEAD/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)

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):

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:

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.

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):

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)

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.

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:

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:

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:

prompt: {
  deferUpload: true,              // skip the built-in upload hook on add
  serializeAttachments: true,     // or (att) => `![](https://raw.githubusercontent.com/nampick/yjd/HEAD/${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 ![](https://raw.githubusercontent.com/nampick/yjd/HEAD/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):

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.

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 (![](https://raw.githubusercontent.com/nampick/yjd/HEAD/src) / [name](https://github.com/nampick/yjd/blob/HEAD/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:

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

new yjd('#editor', {
  mention: {
    trigger: '@',
    source: async (q) => fetchUsers(q),         // [{ id, name, avatar_url }]
    renderItem: (u) => `<img src="https://raw.githubusercontent.com/nampick/yjd/HEAD/${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](https://github.com/nampick/yjd/blob/HEAD/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.

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:

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:

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:

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:

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

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.

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.

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](https://raw.githubusercontent.com/nampick/yjd/HEAD/src) images, [text](https://github.com/nampick/yjd/blob/HEAD/url) links, mentions @[Name](https://github.com/nampick/yjd/blob/HEAD/id) / #[Name](https://github.com/nampick/yjd/blob/HEAD/id), file chips [name (size)](https://github.com/nampick/yjd/blob/HEAD/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.

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:

: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):

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.

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 data-theme="dark">   <!-- your app's theme switch — editors follow it -->
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

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.

The website (landing, docs, playground, comparisons at yjd.io) lives in a separate repo, nampick/yjd-site, which consumes this package.

License

MIT © Nguyen Tuan Nam — see LICENSE.

Related Packages

stilo-editor

A complete rich text editor. Everything included, ready in minutes. Open-source...

234 2
@lilac-wysiwyg/core

Lilac - a smooth, modern WYSIWYG text editor built with pure TypeScript. Zero de...

1,851 15
@richscripts/richtexteditor

A full-featured JavaScript rich text editor (WYSIWYG) with toolbar, plugins, i18...

6,160 2
lextrix

Modular TypeScript rich-text editor engine with themes, plugins, ChangeSet OT, a...

1,900 1
quill-tablekit

Tables for Quill 2. Merge cells, resize columns, and paste tables from Word, Exc...

631 0

Version History

Version Released License
2.15.0 MIT
2.14.0 MIT
2.13.6 MIT
2.13.5 ISC
2.13.4 ISC