For a small editor, start with a constrained contenteditable="true" surface, observe browser input events, and convert the resulting DOM into a normalized document model before saving. Use plaintext-only when formatting is not required. Do not build new architecture around deprecated document.execCommand(). If you need tables, mentions, collaboration, comments, or extensive history, evaluate a maintained editor framework; choose EditContext when you need custom rendering with precise IME and selection control.
Choose the editing architecture before writing code
An HTML editor has two separate responsibilities: collecting text and maintaining a document. The browser can provide the first through an editable element, but its generated markup is not a stable storage format. Decide what your application is willing to store and render, then constrain the editing surface to that contract.
| Approach | Best fit | Main costs and risks |
|---|---|---|
contenteditable with custom handlers |
Small formatting scope owned by a team willing to implement normalization | Browser-generated markup, paste behavior, selection, undo, and accessibility differences |
| Maintained editor framework or component | Tables, mentions, comments, collaborative editing, rich history, or many plugins | Dependency size, schema migration, licensing, and integration work |
| EditContext with a custom renderer | Custom visuals plus advanced IME, composition, and selection requirements | Your code owns rendering, selection mapping, selection bounds, keyboard behavior, and document state |
Use contenteditable as an input surface, not as your database schema. Browser implementations can produce different elements for Enter and different line-break markup, so normalize at a defined boundary.
Define a document contract
Write down the blocks and inline marks that are legal before implementing a toolbar. A practical first contract might allow paragraphs, two heading levels, unordered and ordered lists, links, emphasis, strong text, and code spans. Decide whether images, embeds, tables, and custom attributes are excluded or represented explicitly.
#1 Best Overall
A normalized JSON model is usually easier to validate and migrate than arbitrary HTML:
{
"version": 1,
"blocks": [
{ "type": "paragraph", "children": [
{ "text": "Ship it", "marks": ["strong"] },
{ "text": " today" }
] },
{ "type": "heading", "level": 2, "children": [{ "text": "Next steps" }] }
]
}
If you choose sanitized HTML instead, define the same allowlist and apply it on the server as well as in the browser. Version either representation so future schema changes can migrate old documents instead of guessing what an old fragment meant.
Build a constrained editing surface
Give the editor an accessible name, a visible focus style, and a predictable initial value. Use plaintext-only for notes or titles that must never contain formatting.
<label for="article-editor">Article body</label>
<div id="article-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-describedby="editor-help"><p>Start writing…</p></div>
<p id="editor-help">Use the toolbar for headings and emphasis.</p>
<button type="button" data-mark="strong">Bold</button>
<button type="button" data-mark="em">Italic</button>
<button type="button" data-block="h2">Heading</button>
<output id="editor-status" aria-live="polite"></output>
const editor = document.querySelector('#article-editor');
const status = document.querySelector('#editor-status');
function selectionInsideEditor() {
const sel = window.getSelection();
return sel && sel.rangeCount && editor.contains(sel.anchorNode) && editor.contains(sel.focusNode)
? sel : null;
}
function wrapSelection(tagName) {
const sel = selectionInsideEditor();
if (!sel || sel.isCollapsed) return;
const range = sel.getRangeAt(0);
const wrapper = document.createElement(tagName);
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
sel.removeAllRanges();
const next = document.createRange();
next.selectNodeContents(wrapper);
sel.addRange(next);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'format' }));
}
function insertBlock(tagName) {
const sel = selectionInsideEditor();
if (!sel) return;
const range = sel.getRangeAt(0);
let block = range.startContainer.nodeType === Node.ELEMENT_NODE
? range.startContainer
: range.startContainer.parentElement;
block = block.closest('p, h1, h2, li') || editor;
if (block === editor) return;
const replacement = document.createElement(tagName);
replacement.innerHTML = block.innerHTML;
block.replaceWith(replacement);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'formatBlock' }));
}
document.addEventListener('click', (event) => {
const mark = event.target.closest('[data-mark]');
if (mark) wrapSelection(mark.dataset.mark);
const block = event.target.closest('[data-block]');
if (block) insertBlock(block.dataset.block);
});
editor.addEventListener('input', () => {
status.textContent = `${editor.textContent.length} characters`;
// Convert editor.innerHTML to your document model here; do not persist raw DOM.
});
editor.addEventListener('compositionstart', () => { editor.dataset.composing = 'true'; });
editor.addEventListener('compositionend', () => {
delete editor.dataset.composing;
editor.dispatchEvent(new Event('input', { bubbles: true }));
});
The example deliberately performs only simple transformations. A production toolbar should transform your document model, then render it, rather than attempting to handle every nested selection with ad-hoc DOM operations.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteHandle input, selection, and formatting as separate concerns
Observe modern input events
Listen to beforeinput when you need to inspect or replace an operation, and to input for the resulting change. Track the current selection with selectionchange, but ignore selections outside your editor. Keep toolbar state synchronized with the active block and marks; do not infer state only when a button is clicked.
Do not make new code depend on execCommand
document.execCommand() can modify an active editable element for operations such as bold, links, insertion, and deletion, but it is deprecated. Existing code may still rely on it, yet new architecture should use input events, explicit model transforms, and the Clipboard API instead. Mixing direct DOM commands with a model often creates selections and undo entries that your application cannot reconcile.
Preserve selection deliberately
Any rerender can invalidate a browser Range. Store a logical position in your model (for example, block ID plus text offset), render, and map that position back to a DOM range. Avoid replacing innerHTML on every keystroke; it causes caret jumps and can break composition.
Implement paste and clipboard behavior intentionally
Paste is an input boundary, not a harmless string insertion. A user may paste HTML containing styles, links, images, event attributes, or content copied from office software. Decide whether your editor accepts plain text only or a strict HTML subset.
- Read
text/plainand, only when required,text/htmlfrom the paste event. - Parse HTML with
DOMParser, keep only allowed elements and attributes, and turn unsupported blocks into text or paragraphs. - Normalize line breaks, lists, links, and whitespace into your document contract.
- Insert the resulting model content at the current selection and create one logical history operation.
Use the Clipboard API for copy and paste features where available rather than adding new dependencies on execCommand('copy'). A strict plain-text paste handler can be implemented without trusting source HTML:
editor.addEventListener('paste', (event) => {
event.preventDefault();
const text = event.clipboardData?.getData('text/plain') ?? '';
const sel = selectionInsideEditor();
if (!sel || !text) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text }));
});
Normalize and sanitize before persistence
Normalization should produce the same result for equivalent browser markup. Convert legacy tags and browser-specific breaks to your allowed blocks, merge adjacent identical marks, trim meaningless wrapper elements, and reject attributes you do not use.
At the trust boundary, remove event-handler attributes, dangerous URL schemes, disallowed styles, embedded objects, and unknown elements. Validate links by protocol and host policy. Sanitize again on the server before storing or rendering; client-side checks can be bypassed. Never render user HTML with an unsafe “allow everything” setting.
Support IME, mobile keyboards, and undo
IME and composition
Input Method Editors are used for languages whose characters are composed over several keystrokes. During compositionstart through compositionend, do not normalize or rerender the active text node on every interim event. Treat the final composition as one model transaction. Test Japanese, Chinese, and Korean input, emoji pickers, dead keys, and autocorrect.
Rank #4
Undo and redo
Native undo can be useful for a small editor, but direct DOM replacement and custom paste handlers can split or erase the browser’s history. If undo is a product requirement, record model transactions with inverse operations, group typing and composition sensibly, and expose keyboard shortcuts and toolbar buttons. Persisting a document does not automatically persist its undo history; history normally belongs to the current editing session unless you design a separate format.
EditContext for custom renderers
EditContext is intended for custom rich-text editors that need advanced text input experiences such as IME composition or emoji pickers. Your application owns text state, rendering, selection mapping, selection bounds, and edit handling. It is powerful when a canvas-like or highly virtualized renderer is necessary, but it is not a shortcut: you must implement keyboard behavior, accessibility semantics, and model synchronization yourself.
Persist safely and render consistently
- Convert the current editor state to your versioned document format.
- Validate the format and size on the server.
- Sanitize any HTML representation again at the server boundary.
- Store the version number with the document and keep migrations for future schema changes.
- When displaying saved content, use the same allowlist and URL policy as the editor.
Autosave should be debounced and should report a visible state such as “Saving”, “Saved”, or “Offline”. Use request IDs or revision numbers so a slower response cannot overwrite a newer edit.
Accessibility and device behavior
- Provide a visible label, a focus indicator, and an announcement for validation or save errors.
- Use
role="textbox"andaria-multiline="true"when your semantics require them, and ensure toolbar buttons expose pressed state. - Keep keyboard navigation predictable. Do not trap Tab unless you offer an alternative way to leave the editor.
- Test touch selection, virtual keyboards, orientation changes, zoom, and narrow viewports.
- Do not rely on color alone to show active formatting.
Test the behaviors that usually fail
Run a matrix across every browser and mobile version you support. Include:
Recommended Free Tools
- Enter, Shift+Enter, Backspace at block boundaries, and arrow navigation across links and marks.
- IME composition, emoji, autocorrect, dead keys, and screen readers.
- Undo and redo after typing, paste, formatting, block conversion, and autosave.
- Plain-text and rich HTML paste from web pages and office applications.
- Malformed markup, unsafe URLs, oversized input, and repeated sanitize/render cycles.
- Selection preservation after toolbar actions, rerenders, and collaborative or remote updates.
Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| The caret jumps to the start | You replace innerHTML while the user is typing |
Render only changed model regions or map a logical selection back after rendering. |
| Characters disappear for some languages | Normalization runs during IME composition | Defer transforms until compositionend and test with real IMEs. |
| Paste introduces scripts or styles | Raw clipboard HTML is persisted | Use a strict element, attribute, and URL allowlist on both client and server. |
| Undo skips operations | DOM mutations and native history are being mixed | Group changes as model transactions or use a framework with managed history. |
| Enter creates inconsistent markup | Browser-specific block and break behavior | Normalize Enter operations into your document contract before saving. |
| Toolbar state is wrong | Selection changes are not observed | Handle selectionchange, verify the selection is inside the editor, and recompute active marks. |
| Saved content becomes unsafe later | Only the client sanitized it | Validate and sanitize again at the server trust boundary and during rendering. |
Performance, reliability, and scaling decisions
Keep the editable region small when possible, debounce autosave, and avoid serializing the entire document for every keystroke. For long documents, use block-level rendering and stable IDs so one changed paragraph does not rerender the whole tree. Measure input latency during paste and composition rather than relying on synthetic typing alone.
Once you need comments, tables, mentions, collaboration, or a long-lived history, the cost of owning selection, normalization, accessibility, and security usually exceeds the cost of evaluating a maintained framework. A custom EditContext renderer is justified when its rendering and input-control benefits are requirements, not merely because it is newer.
Or skip the browser setup
If your immediate need is a clean screenshot of an editor demo, documentation page, or rendered HTML preview, ScreenshotNeo captures a URL through one API request. It removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.
Use the API documentation at https://screenshotneo.com/docs/ for all options. This cURL request saves a WebP image:
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/editor-demo -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/editor-demo"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/editor-demo' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
await Bun.write('shot.webp', res);
ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. It supports PNG, JPEG, WebP, and PDF output, with full-page capture, CSS-selector element capture, device presets, custom viewport and retina scale, dark mode, waits, request blocking, headers, cookies, user agents, geolocation, JavaScript, signed links, asynchronous webhooks, bulk capture, caching TTL, and a usage API. Every feature is on every plan. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

