A single-file, zero-dependency tool for editing the text in any HTML file — no code, no build step, no server. Drop a file in, click any text and retype it, hit Save. Layout, styling, and images stay exactly as they were.
It's one .html file. Open it in a browser and it works. Nothing is uploaded anywhere — your file never leaves your machine.
You receive an HTML deliverable (a report, a slide deck, a one-pager) and need to change a few words, a title, or some numbers — without touching the design or asking a developer. This does exactly that, and nothing more.
- ✅ Edits text — headings, paragraphs, list items, table cells, SVG labels.
- ❌ Doesn't touch layout, colors, images, or structure (that's the safety — you can't accidentally break the design).
- 🔒 Safe to open anything — scripts in the loaded file are sandboxed and never run; the file is never uploaded.
- Open
html-text-editor.htmlin any modern browser. - Drag an
.htmlfile onto the page (or click to browse). - Click any text and edit it.
Ctrl+Z/Cmd+Zto undo. - Click Save HTML to download the edited file.
The examples/ folder has files to try the editor on:
examples/ppt-test-cases/— three full PowerPoint-style decks (a corporate pitch, an editorial deck, a dark keynote) to practice editing slide titles, bullets, numbers, and table cells.examples/editor-test-cases/— seven structural stress-tests (nested grids, merged-cell tables, Unicode/RTL, SVG text, absolute-positioned slides, forms, and the i18ndata-*edge case). EachREADME.mdexplains what it tests and what to expect.
The whole thing rests on a single browser feature: document.designMode = 'on'. That turns an entire HTML document into a live word-processor — every piece of text becomes editable in place, with native undo/redo, cursor, and selection — without you writing any per-element editing code. Everything else in the file is just plumbing around that one switch: load a file in, flip the switch, and serialize the result back out.
Here are the 5 mechanisms that make it work.
<iframe id="editor-frame" sandbox="allow-same-origin"></iframe>The uploaded HTML is not injected into the editor's own page — it's loaded into an iframe. Two reasons this matters:
- The loaded file's CSS can't break the toolbar, and the toolbar's CSS can't distort the file. They live in separate documents.
sandboxneutralizes<script>tags in the uploaded file. In a sandbox withoutallow-scripts, that code simply never runs. So you can safely open any HTML — even something off the internet — and it can't execute, pop alerts, or phone home.
The file content is handed to the iframe via srcdoc (an attribute that takes a raw HTML string):
frame.srcdoc = content; // content = the entire .html file as textThis whole tool is one static .html file. There is no backend — it never uploads your file anywhere. Reading happens entirely in the browser:
function readFile(file) {
if (!file || !/\.html?$/i.test(file.name)) { // only .html / .htm
alert('Please drop an .html or .htm file.'); return;
}
var reader = new FileReader();
reader.onload = function (e) { loadHTML(e.target.result, file.name); };
reader.readAsText(file, 'UTF-8'); // ← UTF-8 is critical
}The 'UTF-8' argument is what keeps multibyte content (中文 / Arabic / emoji) from turning into mojibake — it tells the browser to decode the bytes as UTF-8 rather than guessing.
frame.onload = function () {
var doc = frame.contentDocument; // the document INSIDE the iframe
doc.designMode = 'on'; // ← the entire trick
// inject a tiny helper stylesheet so the user can see what they're editing
var style = doc.createElement('style');
style.id = '__editor_style__'; // tagged so we can remove it on save
style.textContent = [
'*:focus { outline: 2px solid rgba(90,160,0,0.5) !important; }', // green focus ring
'img { pointer-events:none !important; max-width:100%; }' // don't drag/resize images
].join('\n');
doc.head.appendChild(style);
};
frame.srcdoc = content; // set AFTER assigning onload, so onload reliably firesTwo subtle but important details here:
- Order matters:
onloadis assigned beforesrcdoc. If you setsrcdocfirst, the load can finish before the handler is attached and you'd miss it. - The injected
<style>is given anid(__editor_style__). That's deliberate — at save time we strip it back out so it doesn't pollute the user's file. The green focus outline is an editing aid, not part of their document.
Why designMode instead of putting contenteditable="true" on each element? Because designMode is universal — it makes the whole document editable in one line, so the tool works on any HTML you throw at it. The contenteditable-per-element approach needs a hand-picked list of selectors, which would make it "case by case."
When you edit text, the browser updates the iframe's live DOM. Saving = read that DOM back out as a string and download it. The tricky part is reconstructing a complete, valid file — outerHTML alone drops the <!DOCTYPE>:
saveBtn.addEventListener('click', function () {
var doc = frame.contentDocument;
// 1. remove our injected editing style so it's not in the saved file
var injected = doc.getElementById('__editor_style__');
if (injected) injected.remove();
// 2. rebuild the DOCTYPE by hand (outerHTML omits it)
var doctype = '';
if (doc.doctype) {
doctype = '<!DOCTYPE ' + doc.doctype.name + '>\n';
}
// 3. DOCTYPE + the entire <html>…</html>
var html = doctype + doc.documentElement.outerHTML;
// 4. trigger a download — again, no server involved
var blob = new Blob([html], { type: 'text/html; charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = currentFilename;
a.click();
URL.revokeObjectURL(url); // free the memory
});The download is a classic trick: wrap the string in a Blob, make a temporary object URL pointing at it, create an invisible <a download>, and programmatically .click() it. The browser saves it like any normal download. revokeObjectURL then frees the memory.
There's no framework here — just three regions shown/hidden by adding/removing a .visible class:
#dropzone— the initial "drop a file" circle#toolbar— filename + Save / Open-new buttons (appears once a file is loaded)#editor-frame— the iframe itself- plus
#overlay— the "Drop to load file" banner that appears when you drag a second file over an already-open one
The drag-and-drop uses a dragCounter to handle a well-known browser quirk: dragenter / dragleave fire for every child element you drag over, so a naïve "hide on dragleave" flickers. Counting enters minus leaves and only hiding at zero fixes it:
document.addEventListener('dragenter', function () { dragCounter++; overlay.classList.add('show'); });
document.addEventListener('dragleave', function () { if (--dragCounter <= 0) overlay.classList.remove('show'); });#save-btn {
background: #e8f5d0; /* light green */
color: #000; /* black text */
border-color: #b7d97a;
font-weight: 700;
}Black text needs a light background to be visible — an earlier version used white text on near-black, so simply switching the text to black would have made it invisible. The fix is a pair: black text + light-green fill, which also keeps it looking like the primary action.
This edits the rendered text. It does not know about text stored in attributes — like data-cn / data-en bilingual pairs in some i18n templates. If a file rebuilds its text from data-* via JavaScript, your visible edit can be overwritten when the language toggles. That's the boundary between "universal" (≈95% of HTML) and "template-specific."
MIT