nol-editor
v1.5.0
Published
의존성 없는 가벼운 HTML 리치 텍스트(WYSIWYG) 에디터 - A tiny dependency-free HTML rich text editor with drag & drop upload, tables and server upload API
Maintainers
Readme
NolEditor
A small, free HTML rich text (WYSIWYG) editor. Written in plain JavaScript with zero dependencies, and released under the MIT license for anyone to use, modify, and redistribute.
22.6KB JS + 3.2KB CSS, gzipped. No jQuery, no bundler, no build step required.

What's in it
Writing — bold, italic, underline, strikethrough, superscript and subscript; font family, size and line height; text color and highlight; headings and code blocks; blockquotes; four alignments; indentation; bullet lists, numbered lists, and checklists
Inserting — links, images, video (YouTube, Vimeo, mp4), tables, horizontal rules, emoji and special characters
Tables — pick the size by dragging across a grid, or type the numbers.
Put the caret in a table and a toolbar appears with row and column insert/delete,
cell merge and split, header row toggle, and cell background color.
Drag across cells to select a region, then merge it with one click.
Tab moves between cells; pressing it in the last cell appends a row.
| Drag to pick a size | Merge cells |
| --- | --- |
|
|
|
Images — drop files straight into the editor, paste screenshots from the clipboard, or pick them from a dialog. Inserted images resize by dragging a corner, and a bubble menu handles alignment, width presets, and alt text.
Uploads — a single upload option wires all of that to your own server API,
with a progress indicator built in. Unusual flows like S3 presigned URLs can replace the
whole transfer via handler. Leave it unset and files are inlined as base64.
| Drop files in | Upload progress |
| --- | --- |
|
|
|
Languages — the interface ships in 14 languages. English is built in; the rest load as separate ~2KB files, so you never pay for languages you don't use. Any string can be overridden. See Languages.
Tools — find and replace, print, direct HTML source editing, fullscreen, dark mode,
character count, a character limit, and markdown shortcuts (## , - , 1. , > ,
[] ) that format as you type
Undo that covers everything — the browser's own stack only sees execCommand, so
merging cells or resizing an image was invisible to it. NolEditor keeps its own
snapshots, groups a burst of typing into one step, and puts the caret back where the
edit happened.
Works without a mouse — the toolbar is one tab stop with arrow keys inside it,
toggles report aria-pressed, dialogs trap Tab and restore focus on Escape, and the
colour, emoji and table-size grids are all keyboard-reachable.
Works on touch — the table size picker, cell merge drag and image resize handles run on pointer events, so a phone or tablet gets the same features a desktop does.
Also — pasting from Word, Hangul, or Google Docs strips the junk automatically;
attaching to an existing <textarea> keeps its form value in sync; Korean and other
IME input works correctly; TypeScript definitions are included.
Install
CDN
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/nol-editor@1/dist/nol-editor.min.css">
<script src="https://cdn.jsdelivr.net/npm/nol-editor@1/dist/nol-editor.min.js"></script>npm
npm install nol-editorimport NolEditor from 'nol-editor';
import 'nol-editor/style.css';Plain files
Copy dist/nol-editor.min.js and dist/nol-editor.min.css into your project.
Quick start
<div id="editor"></div>
<script>
const editor = new NolEditor('#editor', {
placeholder: 'Start writing...',
height: 320,
upload: { url: '/api/upload' },
onChange: (html) => console.log(html)
});
</script>To upgrade an existing <textarea> in place:
<form method="post" action="/save">
<textarea name="content"></textarea>
<button type="submit">Save</button>
</form>
<script>
new NolEditor('textarea[name=content]');
// The edited HTML is mirrored into textarea.value and submits as-is.
</script>Server uploads
new NolEditor('#editor', {
upload: {
url: '/api/upload',
fieldName: 'file',
headers: { Authorization: 'Bearer ' + token },
maxSize: 10 * 1024 * 1024,
accept: 'image/*'
},
onError: (err) => showToast(err.message)
});Your endpoint receives multipart/form-data and returns JSON containing the URL:
{ "url": "/uploads/2026/abc.png" }Working examples for Express, Spring Boot, PHP, Django, and S3 presigned URLs are in the upload guide.
Documentation
| Guide | Contents | | --- | --- | | Getting started | Install paths, saving and loading content | | API reference | Options, methods, events, shortcuts, theming | | File uploads | Server API integration, backend examples, S3 | | Framework integration | React, Vue, Next.js, Nuxt, Svelte, Angular | | Security | XSS defense, server sanitizing, upload safety, CSP |
Main options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| lang | string | object | 'en' | UI language code, or a custom string table |
| markdown | boolean | true | Turn ## , - , > … into blocks as you type |
| maxLength | number | — | Character limit; blocks typing, never editing |
| columnResize | boolean | true | Drag a cell's right edge to resize its column |
| historyLimit | number | 100 | Undo snapshots kept |
| historyDelay | number | 400 | Idle ms before typing commits an undo step |
| ariaLabel | string | from lang | Accessible name for the editing area |
| toolbar | array | 'full' | 'basic' | 'minimal' | 'full' | Toolbar layout |
| hide | string[] | — | Toolbar items to leave out |
| buttons | object | — | Custom buttons, keyed by name |
| placeholder | string | from lang | Text shown while empty |
| height | number | string | 320 | Minimum height of the editing area |
| maxHeight | number | string | null | Height at which the area starts scrolling |
| value | string | '' | Initial HTML |
| dark | boolean | false | Start in dark theme |
| statusbar | boolean | true | Character-count bar at the bottom |
| upload | object | null | Upload configuration |
| allowedIframeHosts | string[] | YouTube etc. | Hosts allowed as iframe embeds |
| onChange | function | null | (html, editor) |
| onError | function | null | (error, editor) |
The full list lives in the API reference.
Customizing the toolbar
Presets
Three named layouts are built in, so the common cases need no array:
new NolEditor('#editor', { toolbar: 'basic' }); // 'full' (default), 'basic', 'minimal'Hiding a few items
When you want everything except a couple of buttons, hide beats retyping the
whole layout. A group left empty is dropped along with its divider, so you never
get a stray separator:
new NolEditor('#editor', { hide: ['print', 'video', 'emoji'] });An explicit layout
Each inner array is one group, with a divider drawn between groups:
new NolEditor('#editor', {
toolbar: [
['bold', 'italic', 'underline'],
['ul', 'ol', 'checklist'],
['link', 'image', 'table'],
['source']
]
});Your own buttons
Give a button a name, drop that name into the layout, and it behaves like any built-in one:
const editor = new NolEditor('#editor', {
toolbar: [['bold', 'italic'], ['save']],
buttons: {
save: {
icon: '<svg viewBox="0 0 24 24">…</svg>', // or text: 'Save'
title: 'Save the document',
action: (editor) => post('/api/posts', editor.getHTML()),
active: (editor) => !editor.isEmpty() // optional highlight state
}
}
});active is re-evaluated as the selection moves, the same way bold lights up
inside bold text.
To make a button available to every editor on the page:
NolEditor.registerButton('save', { text: 'Save', action: (ed) => save(ed.getHTML()) });A button passed in buttons overrides a global one of the same name.
Changing the toolbar at runtime
editor.setToolbar('minimal'); // swap layouts
editor.setToolbar([['bold'], ['source']]);
editor.hideItem('print'); // one name or an array
editor.showItem(['print', 'video']);
editor.addButton('save', { text: 'Save', action: onSave });All of these are chainable and rebuild the toolbar in place, preserving the source and fullscreen states.
Main API
editor.getHTML() // current HTML
editor.setHTML(html) // replace content (sanitized first)
editor.getText() // plain text
editor.insertHTML(html) // insert at the caret
editor.clear() // empty the editor
editor.isEmpty() // true when there is no text or media
editor.focus()
editor.enable() / disable()
editor.uploadFiles(files) // push files through the upload pipeline
editor.findNext(q)
editor.replaceAll(q, r)
editor.toggleSource()
editor.toggleFullscreen()
editor.print()
editor.setDark(true)
editor.on('change', fn) // events: change, upload, error
editor.destroy() // remove and detach listeners
editor.setToolbar(layout) // swap the layout or preset
editor.hideItem(names) // hide toolbar items
editor.showItem(names) // bring them back
editor.addButton(name, def)Static helpers:
NolEditor.registerLocale('fr-CA', { bold: 'Gras' });
NolEditor.registerButton('save', { text: 'Save', action: (ed) => save(ed) });
NolEditor.locales // every registered language table
NolEditor.buttons // every globally registered buttonKeyboard shortcuts
| Key | Action |
| --- | --- |
| Ctrl/Cmd + B / I / U | Bold / italic / underline |
| Ctrl/Cmd + K | Insert link |
| Ctrl/Cmd + F | Find and replace |
| Ctrl/Cmd + P | Print |
| Ctrl/Cmd + Z / Y | Undo / redo |
| Ctrl/Cmd + Shift + V | Paste without formatting |
| Tab / Shift+Tab | Move between table cells, otherwise indent |
| Esc | Close popover, exit fullscreen |
Languages
Fourteen languages ship with the editor:
| Code | Language | Code | Language |
| --- | --- | --- | --- |
| en | English (built in) | pt-BR | Português do Brasil |
| ko | 한국어 | ru | Русский |
| ja | 日本語 | it | Italiano |
| zh-CN | 简体中文 | vi | Tiếng Việt |
| zh-TW | 繁體中文 | id | Bahasa Indonesia |
| es | Español | tr | Türkçe |
| fr | Français | de | Deutsch |
Only English is bundled into the core, so a page never pays for languages it doesn't use. Load the one you need — each is about 2KB gzipped.
<script src="nol-editor.min.js"></script>
<script src="locales/ja.min.js"></script>
<script>
new NolEditor('#editor', { lang: 'ja' });
</script>With a bundler, import the locale and pass the table directly:
import NolEditor from 'nol-editor';
import ja from 'nol-editor/locales/ja';
new NolEditor('#editor', { lang: ja });Naming a language you haven't loaded logs a warning and falls back to English rather than showing raw keys.
Adding or adjusting a language
lang also takes a plain object. Anything you leave out falls back to English,
so a partial table is fine:
new NolEditor('#editor', {
lang: {
bold: 'Gras',
'link.url': 'Adresse du lien',
chars: (n) => `${n} caractères`
}
});To make a language reusable by code rather than passing the table every time, register it once:
import NolEditor from 'nol-editor';
import frCanada from './locales/fr-CA.js';
NolEditor.registerLocale('fr-CA', frCanada);
new NolEditor('#editor', { lang: 'fr-CA' });Regional codes fall back sensibly: fr-CA uses fr if only that is loaded, and
a bare zh picks up zh-CN.
NolEditor.locales.en lists every key, so it doubles as the template for a new
translation. Copying a file from src/locales/ and sending a pull
request is very welcome — the test suite checks that every locale covers the full
key set, so nothing silently falls back.
Theming
Everything is driven by CSS variables — override them and you're done.
.noleditor {
--nol-primary: #10b981;
--nol-radius: 4px;
--nol-border: #cbd5e1;
}Security
setHTML(), pasting, and returning from source mode all strip <script>, on* handlers,
javascript: URLs, and iframes from non-whitelisted hosts.
That said, client-side sanitizing is not a security boundary. An attacker can post to your API without going through the browser, so always sanitize again on the server before storing. Language-specific recipes are in the security guide.
Building from source
npm installInstalling runs the build, so dist/ ends up with UMD, ESM, CJS, minified builds,
and type definitions.
| Command | What it does |
| --- | --- |
| npm run build | Regenerate dist/ |
| npm test | 35 tests against a real Chromium |
| npm run typecheck | Validate the shipped type definitions |
The tests exercise behavior that depends on contenteditable and
document.execCommand, so they drive a real browser through Playwright rather
than a simulated DOM. Chromium needs downloading once:
npx playwright install chromiumREADME screenshots are captured from the live editor. Regenerate them when the UI changes:
node tools/screenshots.mjsReleasing
Publishing is tied to a version tag, so a release is an explicit act rather than something that happens on every push:
npm version minor && git push --follow-tagsThat runs the release workflow, which refuses to
publish unless the tag matches package.json, the type definitions check out and
all the browser tests pass. It then publishes with provenance and drafts a GitHub
release from the matching CHANGELOG section.
Authentication works either way: register the repository as a trusted publisher on
npmjs.com and nothing needs storing, or save an automation token as the NPM_TOKEN
secret. The very first publish of a package name has to be done by hand — npm cannot
grant a trusted publisher for a package that does not exist yet.
Browser support
Current versions of Chrome, Edge, Firefox, and Safari, including mobile.
Known limitations
Cell selection on touch needs a short hold before it starts, because an immediate drag across cells is indistinguishable from scrolling the page.
License
MIT. Free for commercial use; just keep the copyright notice.
