RichTextEditor
AppQuill 2.x WYSIWYG with token-tinted snow theme. Production features: controlled value (via `kui-rte:set-html` event), imperative API via `window.KuiRte.get(id)`, name+hidden-form-sync, drag-and-drop image upload (with `imageUploadFn` global override), paste sanitization (Word / GDocs cleanup), char + word counter with maxLength, autosave to localStorage, markdown shortcuts, color + highlight, sub/sup, indent/outdent, horizontal rule, tables, emoji picker, @-mentions, /-slash command menu, selection bubble menu, image resize/align overlay, fullscreen mode. Pixel-identical React sibling at modules/app/RichTextEditor/index.tsx.
Use the toolbar — markdown shortcuts work too: try **bold** or # heading.
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'article-body',
name: 'body',
label: 'Article body',
showCounter: true,
showWordCount: true
}) %>
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'release-notes',
name: 'releaseNotes',
label: 'Release notes',
value: 'Release notes
...
'
}) %>
This document is read-only.
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'archived',
name: 'archived',
label: 'Archived document',
value: savedHtml,
readOnly: true
}) %>
Maximum 200 characters.
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'short-summary',
name: 'summary',
label: 'Short summary',
maxLength: 200,
showCounter: true
}) %>
Type @ to mention a teammate, or / to insert a block.
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'comment',
name: 'comment',
label: 'Comment',
mentions: [
{ id: 'u1', label: 'Jane Doe', description: 'Designer' },
{ id: 'u2', label: 'John Smith', description: 'Engineer' }
],
slashItems: [
{ id: 'h1', label: 'Heading 1', command: 'header:1' },
{ id: 'list', label: 'Bullet list', command: 'list:bullet' },
{ id: 'hr', label: 'Divider', command: 'hr' }
]
}) %>
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'reply', name: 'reply', value: 'Hello
'
}) %>
Your changes survive page refresh — key: 'demo-draft'.
Insert image
Upload a file or paste an image URL.
Max 5.0 MB. PNG, JPG, GIF, WebP, SVG.
Describe the image for screen readers.
Insert table
Pick the size from the grid or type values.
<%- include('modules/app/RichTextEditor', {
id: 'draft', name: 'draft', autosaveKey: 'my-draft'
}) %>
<%
// ─── RichTextEditor (EJS, split) ───────────────────────────────────────────
//
// Folder-scoped sibling of /home/kuray/01_NextJS_Components/modules/app/
// RichTextEditor/. File-for-file parallels (one .js per .tsx/.ts):
// sanitize.js ← sanitize.ts
// markdown.js ← markdown.ts
// store.js ← store.ts (plain-JS state container)
// quill-helpers.js ← quill-helpers.ts
// quill-setup.js ← useQuillSetup.ts
// editor-actions.js ← useEditorActions.ts
// trigger-keyboard.js ← useTriggerKeyboard.ts
// bubble-menu.js ← BubbleMenu.tsx
// emoji-picker.js ← EmojiPicker.tsx
// suggestion-popup.js ← SuggestionPopup.tsx
// image-overlay.js ← ImageOverlay.tsx
// popup-overlays.js ← PopupOverlays.tsx
// bindings.js ← (toolbar standalone wiring + modal wiring)
// client.js ← bootstrap (parallel of React index.tsx)
// _toolbar.ejs ← Toolbar.tsx
// _modals.ejs ← ImageInsertModal.tsx + TableInsertModal.tsx
// index.ejs ← this file (orchestrator)
//
// The .js files are concatenated once per page via EJS `include`
// (each module attaches its functions onto window.__KuiRte so the
// 14 files share scope across instances).
var _id = locals.id || ('rte-' + Math.random().toString(36).substr(2, 6));
var _name = locals.name || _id;
var _label = locals.label || '';
var _hint = locals.hint || '';
var _error = locals.error || '';
var _value = locals.value || '';
var _placeholder = locals.placeholder || 'Write something…';
var _readOnly = !!locals.readOnly;
var _minHeight = Number(locals.minHeight) > 0 ? Number(locals.minHeight) : 180;
var _className = locals.className || '';
var _maxLength = Number(locals.maxLength) > 0 ? Number(locals.maxLength) : 0;
var _showCounter = !!locals.showCounter;
var _showWordCount = !!locals.showWordCount;
var _autosaveKey = locals.autosaveKey || '';
var _sanitizeOnPaste = locals.sanitizeOnPaste === false ? false : true;
var _imageUploadFn = locals.imageUploadFn || '';
var _mentions = locals.mentions || null;
var _slashItems = locals.slashItems || null;
var _hintId = _hint ? (_id + '-hint') : '';
var _errorId = _error ? (_id + '-error') : '';
var _describedBy = [_hintId, _errorId].filter(Boolean).join(' ');
%>
<% if (_label) { %>
<% } %>
data-max-length="<%= _maxLength %>"<% } %>
<% if (_autosaveKey) { %>data-autosave-key="<%= _autosaveKey %>"<% } %>
<% if (_sanitizeOnPaste) { %>data-sanitize="1"<% } %>
<% if (_imageUploadFn) { %>data-image-upload-fn="<%= _imageUploadFn %>"<% } %>
<% if (_mentions) { %>data-mentions='<%- JSON.stringify(_mentions) %>'<% } %>
<% if (_slashItems) { %>data-slash='<%- JSON.stringify(_slashItems) %>'<% } %>
<% if (_describedBy) { %>aria-describedby="<%= _describedBy %>"<% } %>
aria-invalid="<%= _error ? 'true' : 'false' %>"
class="rounded-md border <%= _error ? 'border-error' : 'border-border' %> bg-surface-base overflow-hidden focus-within:ring-2 focus-within:ring-border-focus<%= _readOnly ? ' bg-surface-sunken' : '' %>"
>
<%- include('./_toolbar', { _id: _id, _readOnly: _readOnly }) %>
<% if (_showCounter || _showWordCount || _maxLength) { %>
<% if (_showWordCount) { %>0 words<% } %>
<% if (_showCounter || _maxLength) { %>0<% if (_maxLength) { %> / <%= _maxLength %><% } %><% } %>
<% } %>
<% if (_hint && !_error) { %>
<%= _hint %>
<% } %>
<% if (_error) { %>
<%= _error %>
<% } %>
<%- include('./_modals', { _id: _id }) %>