Overview
HTML Editor is a web-based HTML editor designed for web developers, designers, and learners. It offers a lightweight, minimalist environment for writing and previewing HTML, CSS, and JavaScript code in real time. The editor also supports creating, opening, and editing various text-based file types, such as .txt, .css, .js, .svg, and more.
This project is hosted on GitLab and licensed under the MIT License.
Changelog
- Find and replace
Added a fully featured replace bar with:- Find and replace inputs
- Match case, whole word, and regex modes
- Replace All button with replacement count feedback
- Undo button for last replacement
- Document metrics
Live character and byte counts in the footer, formatted with thousand-separator (Intl.NumberFormat) - Debounced updates
Debounced preview rendering and document metrics updates for smoother performance - Refactored JavaScript
Modular helper functions (debounce,toggleClass,interpretEscapeSequences,separateThousands, and others) improve readability and reuse - UI and CSS enhancements
- Grouped header controls into
<div>wrappers - Introduced utility classes (
.toggle,.active,.number) - Expanded font-size options (up to
24px) - Consistent sizing using
remunits
- Grouped header controls into
- Extended state persistence
localStoragenow saves and restores replace bar state, find and replace inputs, and document metrics, in addition to existing settings
Specific review requests
- Best practices: How well does my code adhere to best practices and coding conventions?
- Code structure and readability: Are there any improvements I can make to the organization and clarity of my code?
- Performance: Are there any optimizations that can enhance the performance of the editor?
- Features and usability: Do you have any suggestions for additional features or improvements to the existing ones?
- Accessibility and semantics: Is the markup appropriate?
- Edge cases: Can you spot any scenarios where the replace or preview operation might fail?
Source code
CSS
/* ==================== */
/* Base and Component Styles */
/* ==================== */
html,
body {
height: 100%;
padding: 0;
margin: 0;
}
body {
display: flex;
flex-direction: column;
}
header {
background: linear-gradient(#FFF, #CCC);
}
footer {
background: linear-gradient(#CCC, #FFF);
padding: 5px;
}
header div,
footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
header div {
margin: 5px;
}
main {
display: flex;
flex: 1;
}
main.vertical {
flex-direction: column;
}
header *,
footer * {
font: 0.75rem Arial, sans-serif;
color: #333;
}
select,
button,
input {
margin: 0;
}
label[for="editorSizeInput"],
#share {
margin-left: auto;
}
#editorSizeInput,
iframe {
padding: 0;
}
#footerContainerToggle {
width: 1rem;
height: 1rem;
padding: 0;
border-bottom-width: 0.3125rem;
border-radius: 0;
}
#copyButton {
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
img {
display: block;
width: 1rem;
height: 1rem;
}
main div {
position: relative;
}
#previewerWrapper {
border-left: 5px solid #CCC;
}
main.vertical #previewerWrapper {
border-left: 0;
border-top: 5px solid #CCC;
}
main div * {
position: absolute;
width: 100%;
height: 100%;
border: 0;
margin: 0;
background: #FFF;
}
textarea {
box-sizing: border-box;
padding: 5px;
outline: 0;
resize: none;
color: #333;
}
textarea.dark {
background: #333;
color: #FFF;
}
/* ==================== */
/* Utilities */
/* ==================== */
.toggle {
padding: 2px 6px;
border: 1px solid #666;
border-radius: 12px;
background: transparent;
}
.active {
border-color: #333;
background: #FFF;
}
.number {
font-family: monospace;
}
.hidden {
display: none;
}
HTML
<header>
<div>
<a href="" download="template.html" id="downloadLink" title="Download HTML document">Download</a>
<label for="fontSizeSelector">Font size</label>
<select id="fontSizeSelector">
<option>12</option>
<option>13</option>
<option selected>14</option>
<option>15</option>
<option>16</option>
<option>17</option>
<option>18</option>
<option>20</option>
<option>22</option>
<option>24</option>
</select>
<label for="previewSelector">Preview</label>
<select id="previewSelector">
<option>Instant</option>
<optgroup label="Delayed">
<option value="500" selected>0.5 s</option>
<option value="1000">1.0 s</option>
<option value="1500">1.5 s</option>
<option value="2000">2.0 s</option>
</optgroup>
<option>Manual</option>
</select>
<button type="button" id="runButton">Run</button>
<button type="button" id="resetButton">Reset</button>
<button type="button" id="selectButton">Select</button>
<input type="file" accept="text/html" id="fileInput">
<label for="editorSizeInput">Editor size</label>
<input type="range" id="editorSizeInput">
<output for="editorSizeInput" class="number" id="editorSizeOutput"></output>
<button type="button" class="toggle" id="verticalViewToggle">Vertical View</button>
<button type="button" class="toggle" id="darkEditorToggle">Dark Editor</button>
<button type="button" class="toggle" id="spellcheckToggle">Spellcheck</button>
<button type="button" class="toggle" id="replaceBarToggle">Replace</button>
<button type="button" class="toggle" id="footerContainerToggle" title="Toggle footer"></button>
</div>
<div id="replaceBar">
<label for="findInput">Find</label>
<input type="text" id="findInput">
<label for="replaceInput">Replace</label>
<input type="text" id="replaceInput">
<button type="button" class="toggle" id="matchCaseToggle">Match Case</button>
<button type="button" class="toggle" id="wholeWordToggle">Whole Word</button>
<button type="button" class="toggle" id="regexModeToggle">Regex</button>
<button type="button" id="replaceAllButton">Replace All</button>
<button type="button" id="undoButton" disabled>Undo</button>
<output for="findInput matchCaseToggle wholeWordToggle regexModeToggle editor" id="replaceFeedback"></output>
</div>
</header>
<main>
<div id="editorWrapper">
<textarea><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HTML Document Template</title>
<style>
p {
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
<p>Hello, world!</p>
<script>
console.log(document.querySelector('p').textContent);
</script>
</body>
</html></textarea>
</div>
<div id="previewerWrapper">
<iframe></iframe>
</div>
</main>
<footer>
<output for="editor">
<span class="number" id="characterCount"></span> <span id="characterLabel"></span> | <span class="number" id="byteCount"></span> <span id="byteLabel"></span>
</output>
<span id="share">Share</span>
<a href="https://x.com/intent/post?text=HTML%20Editor%3A%20Online%20HTML%20Editor%20with%20Real-Time%20Preview&url=https%3A%2F%2Fhtmleditor.gitlab.io" target="_blank"><img src="images/x.svg" alt="X"></a>
<a href="https://www.facebook.com/sharer.php?u=https%3A%2F%2Fhtmleditor.gitlab.io" target="_blank"><img src="images/facebook.svg" alt="Facebook"></a>
<a href="https://www.linkedin.com/feed/?shareActive=true&shareUrl=https%3A%2F%2Fhtmleditor.gitlab.io" target="_blank"><img src="images/linkedin.svg" alt="LinkedIn"></a>
<a href="mailto:?subject=HTML%20Editor%3A%20Online%20HTML%20Editor%20with%20Real-Time%20Preview&body=https%3A%2F%2Fhtmleditor.gitlab.io" target="_blank"><img src="images/email.svg" alt="Email"></a>
<button type="button" id="copyButton"><img src="images/link.svg" alt="Link"></button>
<output id="copyNotification"></output>
<address><a href="https://codereview.stackexchange.com/questions/296011/html-editor-online-html-editor-with-real-time-preview-version-6" title="Code Review Stack Exchange">Feedback</a> | Created by <a href="https://mori.pages.dev" rel="author">Mori</a></address>
</footer>
JavaScript
// ====================
// Constants and Variables
// ====================
const downloadLink = document.getElementById('downloadLink');
const fontSizeSelector = document.getElementById('fontSizeSelector');
const previewSelector = document.getElementById('previewSelector');
const runButton = document.getElementById('runButton');
const resetButton = document.getElementById('resetButton');
const selectButton = document.getElementById('selectButton');
const fileInput = document.getElementById('fileInput');
const editorSizeInput = document.getElementById('editorSizeInput');
const editorSizeOutput = document.getElementById('editorSizeOutput');
const verticalViewToggle = document.getElementById('verticalViewToggle');
const darkEditorToggle = document.getElementById('darkEditorToggle');
const spellcheckToggle = document.getElementById('spellcheckToggle');
const replaceBarToggle = document.getElementById('replaceBarToggle');
const footerContainerToggle = document.getElementById('footerContainerToggle');
const replaceBar = document.getElementById('replaceBar');
const findInput = document.getElementById('findInput');
const replaceInput = document.getElementById('replaceInput');
const matchCaseToggle = document.getElementById('matchCaseToggle');
const wholeWordToggle = document.getElementById('wholeWordToggle');
const regexModeToggle = document.getElementById('regexModeToggle');
const toggleButtons = document.querySelectorAll('.toggle');
const replaceAllButton = document.getElementById('replaceAllButton');
const undoButton = document.getElementById('undoButton');
const replaceFeedback = document.getElementById('replaceFeedback');
const mainContainer = document.querySelector('main');
const editorWrapper = document.getElementById('editorWrapper');
const previewerWrapper = document.getElementById('previewerWrapper');
const editor = document.querySelector('textarea');
const previewer = document.querySelector('iframe');
const footerContainer = document.querySelector('footer');
const characterCount = document.getElementById('characterCount');
const characterLabel = document.getElementById('characterLabel');
const byteCount = document.getElementById('byteCount');
const byteLabel = document.getElementById('byteLabel');
const copyButton = document.getElementById('copyButton');
const copyNotification = document.getElementById('copyNotification');
const numberFormatter = new Intl.NumberFormat();
let previousEditorValue = null;
// ====================
// Reusable Functions
// ====================
// Create a Blob from the editor content
function createContentBlob() {
return new Blob([editor.value], {
type: 'text/html; charset=utf-8'
});
}
// Debounce function
function debounce(callback) {
let timeout = null;
return function(delay) {
clearTimeout(timeout);
timeout = setTimeout(callback, delay);
};
}
// Toggle class helper function
function toggleClass(element, className, force) {
element.classList.toggle(className, force);
}
// Check if a button is active
function isActive(button) {
return button.classList.contains('active');
}
function deactivate(button) {
button.classList.remove('active');
}
// Interpret some common escape sequences in the replacement string
function interpretEscapeSequences(str) {
const escapeMap = {
'\\n': '\n', // Newline
'\\r': '\r', // Carriage return
'\\t': '\t', // Tab
'\\\\': '\\', // Backslash
};
return str.replace(/\\[nrt\\]/g, match => escapeMap[match]);
}
// Format numbers with thousand separators
function separateThousands(number) {
return numberFormatter.format(number);
}
// ====================
// Core Functions
// ====================
// Create a download URL for the editor content
function createDownloadURL() {
const blob = createContentBlob();
downloadLink.href = URL.createObjectURL(blob);
}
// Resize the editor font size
function resizeFont() {
editor.style.fontSize = `${fontSizeSelector.value}px`;
}
// Resize the editor and previewer panes
function resizeEditor() {
const editorSizeInputValue = editorSizeInput.value;
editorWrapper.style.flexGrow = editorSizeInputValue;
previewerWrapper.style.flexGrow = 100 - editorSizeInputValue;
editorSizeOutput.value = (editorSizeInputValue / 100).toFixed(2);
}
function updatePlaceholder() {
findInput.placeholder = isActive(regexModeToggle) ? 'pattern or /pattern/flags' : '';
}
function clearReplaceFeedback() {
if (replaceFeedback.value) {
replaceFeedback.value = '';
}
}
function clearUndoState() {
if (previousEditorValue !== null) {
previousEditorValue = null;
undoButton.disabled = true;
}
}
// Update the iframe with the editor content
function preview() {
previewer.replaceWith(previewer); // A fresh iframe to delete JavaScript variables
const previewerDocument = previewer.contentDocument;
previewerDocument.write(editor.value);
previewerDocument.close();
}
// Update character count
function updateCharacterCount() {
const characterCountValue = editor.value.length;
characterCount.textContent = separateThousands(characterCountValue);
characterLabel.textContent = characterCountValue === 1 ? 'character' : 'characters';
}
// Update byte count
function updateByteCount() {
const blob = createContentBlob();
const byteCountValue = blob.size;
byteCount.textContent = separateThousands(byteCountValue);
byteLabel.textContent = byteCountValue === 1 ? 'byte' : 'bytes';
}
// Update both character count and byte count
function updateDocumentMetrics() {
if (footerContainer.className !== 'hidden') {
updateCharacterCount();
updateByteCount();
}
}
// ====================
// Debounced Functions
// ====================
const debouncedPreview = debounce(preview);
const debouncedUpdateDocumentMetrics = debounce(updateDocumentMetrics);
const debouncedClearCopyNotification = debounce(function() {
copyNotification.value = '';
});
// ====================
// Preview Update Dispatcher
// ====================
// Determine and trigger the appropriate preview update based on the selected preview mode
function dispatchPreviewUpdate() {
const previewSelectorValue = previewSelector.value;
if (previewSelectorValue === 'Instant') {
preview();
} else if (previewSelectorValue !== 'Manual') {
debouncedPreview(previewSelectorValue);
}
}
// ====================
// Event Listeners
// ====================
downloadLink.addEventListener('click', createDownloadURL);
downloadLink.addEventListener('contextmenu', createDownloadURL);
fontSizeSelector.addEventListener('change', resizeFont);
runButton.addEventListener('click', preview);
editorSizeInput.addEventListener('input', resizeEditor);
for (const button of toggleButtons) {
button.addEventListener('click', function() {
toggleClass(this, 'active');
if (this === verticalViewToggle) {
toggleClass(mainContainer, 'vertical');
} else if (this === darkEditorToggle) {
toggleClass(editor, 'dark');
} else if (this === spellcheckToggle) {
editor.spellcheck = !editor.spellcheck;
} else if (this === replaceBarToggle) {
toggleClass(replaceBar, 'hidden');
} else if (this === footerContainerToggle) {
toggleClass(footerContainer, 'hidden');
updateDocumentMetrics();
} else if (this === matchCaseToggle || this === wholeWordToggle) {
deactivate(regexModeToggle);
updatePlaceholder();
clearReplaceFeedback();
} else if (this === regexModeToggle) {
deactivate(matchCaseToggle);
deactivate(wholeWordToggle);
updatePlaceholder();
clearReplaceFeedback();
}
});
}
for (const textField of [findInput, replaceInput, editor]) {
textField.addEventListener('input', function() {
clearReplaceFeedback();
if (this === editor) {
clearUndoState();
dispatchPreviewUpdate();
debouncedUpdateDocumentMetrics(500);
}
});
}
resetButton.addEventListener('click', function() {
if (editor.value && editor.value !== editor.defaultValue && !confirm('Are you sure you want to reset the editor content to the default template?\nAll unsaved changes will be lost.')) {
return;
}
fileInput.value = '';
downloadLink.download = 'template.html';
editor.value = editor.defaultValue;
clearReplaceFeedback();
clearUndoState();
dispatchPreviewUpdate();
updateDocumentMetrics();
});
selectButton.addEventListener('click', function() {
editor.select();
});
fileInput.addEventListener('change', async function() {
const file = this.files[0];
if (file) { // Ensure that there's a file to read so Chrome, for example, doesn't run this function when you cancel choosing a new file
downloadLink.download = file.name;
editor.value = await file.text();
clearReplaceFeedback();
clearUndoState();
dispatchPreviewUpdate();
updateDocumentMetrics();
}
});
replaceAllButton.addEventListener('click', function() {
const findInputValue = findInput.value;
const replaceInputValue = replaceInput.value;
const currentEditorValue = editor.value;
const isCaseSensitive = isActive(matchCaseToggle);
const isWholeWord = isActive(wholeWordToggle);
const isRegex = isActive(regexModeToggle);
if (!findInputValue) {
replaceFeedback.value = 'No find term entered';
return;
}
// Prepare pattern and flags for RegExp constructor based on find input and options
let pattern;
let flags = 'g'; // Global flag is always needed to replace all
let regex;
if (isRegex) {
const regexLiteralMatch = findInputValue.match(/^\/(.*)\/(.*)$/);
if (regexLiteralMatch) {
[, pattern, flags] = regexLiteralMatch;
if (!flags.includes('g')) {
flags += 'g';
}
} else {
pattern = findInputValue;
}
} else {
pattern = findInputValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (isWholeWord) {
// Unicode-aware word boundaries (mimics \b) with 'u' flag for non-English characters
pattern = `(?<![\\p{L}\\p{N}_])${pattern}(?![\\p{L}\\p{N}_])`;
flags += 'u';
}
if (!isCaseSensitive) {
flags += 'i';
}
}
try {
regex = new RegExp(pattern, flags);
} catch (error) {
replaceFeedback.value = error.message;
return;
}
const matches = currentEditorValue.match(regex);
if (matches) {
const matchCount = matches.length;
previousEditorValue = currentEditorValue;
// Use function replacement in non-regex mode to avoid interpreting $ patterns
editor.value = currentEditorValue.replace(regex, isRegex ? interpretEscapeSequences(replaceInputValue) : () => replaceInputValue);
replaceFeedback.innerHTML = `<span class="number">${separateThousands(matchCount)}</span> ${matchCount === 1 ? 'replacement' : 'replacements'} made`;
undoButton.disabled = false;
dispatchPreviewUpdate();
updateDocumentMetrics();
} else {
replaceFeedback.value = 'No matches found';
}
});
undoButton.addEventListener('click', function() {
editor.value = previousEditorValue;
replaceFeedback.value = 'Replacement undone';
clearUndoState();
dispatchPreviewUpdate();
updateDocumentMetrics();
});
copyButton.addEventListener('click', function() {
navigator.clipboard.writeText(location);
copyNotification.value = 'Link copied';
debouncedClearCopyNotification(2000);
});
// Save the current state to localStorage
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
const currentState = {
spellcheckToggle: spellcheckToggle.className,
verticalViewToggle: verticalViewToggle.className,
darkEditorToggle: darkEditorToggle.className,
replaceBarToggle: replaceBarToggle.className,
footerContainerToggle: footerContainerToggle.className,
matchCaseToggle: matchCaseToggle.className,
wholeWordToggle: wholeWordToggle.className,
regexModeToggle: regexModeToggle.className,
fontSizeSelector: fontSizeSelector.value,
previewSelector: previewSelector.value,
editorSizeInput: editorSizeInput.value,
findInput: findInput.value,
replaceInput: replaceInput.value,
editor: editor.value,
};
localStorage.setItem('state', JSON.stringify(currentState));
}
});
// ====================
// Initialization
// ====================
// Restore the state from localStorage
const restoredState = JSON.parse(localStorage.getItem('state'));
if (restoredState) {
spellcheckToggle.className = restoredState.spellcheckToggle;
verticalViewToggle.className = restoredState.verticalViewToggle;
darkEditorToggle.className = restoredState.darkEditorToggle;
replaceBarToggle.className = restoredState.replaceBarToggle;
footerContainerToggle.className = restoredState.footerContainerToggle;
matchCaseToggle.className = restoredState.matchCaseToggle;
wholeWordToggle.className = restoredState.wholeWordToggle;
regexModeToggle.className = restoredState.regexModeToggle;
fontSizeSelector.value = restoredState.fontSizeSelector;
previewSelector.value = restoredState.previewSelector;
editorSizeInput.value = restoredState.editorSizeInput;
findInput.value = restoredState.findInput;
replaceInput.value = restoredState.replaceInput;
editor.value = restoredState.editor;
}
// Update the UI based on the restored state
editor.spellcheck = isActive(spellcheckToggle);
toggleClass(mainContainer, 'vertical', isActive(verticalViewToggle));
toggleClass(editor, 'dark', isActive(darkEditorToggle));
toggleClass(replaceBar, 'hidden', !isActive(replaceBarToggle));
toggleClass(footerContainer, 'hidden', !isActive(footerContainerToggle));
resizeFont();
resizeEditor();
updatePlaceholder();
dispatchPreviewUpdate();
updateDocumentMetrics();