Skip to content

Commit ffce69f

Browse files
mriechersclaude
andcommitted
feat(formatter): replace input textarea with rich text contenteditable editor
The input pane now uses a contenteditable div instead of a textarea, so pasting rich text from Word, Google Docs, or any webpage renders visually (bold, headings, links, etc.) instead of showing raw HTML source. Clicking Format extracts the underlying HTML and outputs clean markup — matching the htmltidy.net workflow. - Input: contenteditable div with sans-serif font for visual editing - Output: textarea with monospace font for HTML source code - Paste handler strips clipboard StartFragment/EndFragment boilerplate - Example updated to rich text snippet showing realistic CMS cleanup - CSS split into .editor-richtext (input) and .editor-textarea (output) Agent: Main Assistant Machine: WPMNB-JHP69N7 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 51890bd commit ffce69f

3 files changed

Lines changed: 118 additions & 119 deletions

File tree

formatter/app.js

Lines changed: 71 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -558,60 +558,56 @@ function minify(html) {
558558
// Wires DOM elements to the formatter.
559559
// ============================================================
560560

561-
const EXAMPLE_HTML = `<!DOCTYPE html>
562-
<html lang="en">
563-
<head>
564-
<META CHARSET="UTF-8">
565-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
566-
<TITLE>My Page</TITLE>
567-
<link rel="stylesheet" href="styles.css" />
568-
<style>
569-
body { margin: 0; font-family: sans-serif; }
570-
.container { max-width: 1200px; margin: auto; }
571-
</style>
572-
</head>
573-
<body>
574-
<div class="container">
575-
<header>
576-
<nav>
577-
<a href="/">Home</a><a href="/about">About</a><a href="/contact" >Contact</a>
578-
</nav>
579-
<H1 CLASS="title">Welcome to My Website</H1>
580-
<p>This is a <strong>sample page</strong> with <em>mixed case tags</em>,
581-
inconsistent spacing, and various formatting issues.</p>
582-
</header>
583-
<main>
584-
<section id="features">
585-
<h2>Features</h2>
586-
<ul>
587-
<li>Fast loading</li><li>Responsive design</li><li>Accessible markup</li>
588-
</ul>
589-
<img src="hero.jpg" alt="Hero image" WIDTH="1200" HEIGHT="630">
590-
<BR>
591-
<INPUT type="text" placeholder="Search..." name="q" class="" id="">
592-
</section>
593-
<section id="content">
594-
<article>
595-
<h3>Latest Post</h3>
596-
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
597-
<a HREF="https://example.com" TARGET="_blank" REL="noopener">Read more</a>
598-
</article>
599-
<div></div>
600-
<!-- TODO: add sidebar -->
601-
</section>
602-
</main>
603-
<footer>
604-
<p>&copy; 2025 My Website. All rights reserved.</p>
605-
</footer>
606-
</div>
607-
<script>
608-
console.log("Hello world");
609-
document.addEventListener("DOMContentLoaded", () => {
610-
console.log("Ready!");
611-
});
612-
</script>
613-
</body>
614-
</html>`;
561+
/**
562+
* Rich text example — rendered visually in the contenteditable input.
563+
* Simulates what you'd get pasting from a word processor or CMS.
564+
*/
565+
const EXAMPLE_RICH_HTML = `<h1 style="font-size:24px;color:#333" CLASS="title">Welcome to My Website</h1>
566+
<p style="margin-bottom:12px">This is a <strong>sample page</strong> with <em>mixed formatting</em>,
567+
<span style="color:red;font-weight:bold" class="highlight">inline styles</span>, and various issues that need cleaning.</p>
568+
<h2>Features</h2>
569+
<ul>
570+
<li>Fast loading</li>
571+
<li>Responsive design</li>
572+
<li>Accessible markup</li>
573+
</ul>
574+
<p>Visit <a HREF="https://example.com" TARGET="_blank" REL="noopener" style="color:blue">our website</a> for more information.</p>
575+
<p data-source="cms" class="body-text" id="intro">This paragraph has <span style="font-weight:bold"><span class="">unnecessary wrapper spans</span></span> and extra attributes that should be cleaned up.</p>
576+
<div></div>
577+
<!-- TODO: add sidebar -->`;
578+
579+
/**
580+
* Get the HTML content from the contenteditable input.
581+
* Returns trimmed innerHTML.
582+
*/
583+
function getInputHTML(el) {
584+
// innerHTML is used intentionally — this is a rich text editing tool
585+
// where rendering user-pasted HTML is the core functionality.
586+
// All processing is client-side; nothing is sent to a server.
587+
return el.innerHTML.trim();
588+
}
589+
590+
/**
591+
* Set the HTML content of the contenteditable input.
592+
* innerHTML is safe here — this is a local-only HTML editing tool.
593+
*/
594+
function setInputHTML(el, html) {
595+
el.innerHTML = html; // eslint-disable-line no-unsanitized/property
596+
}
597+
598+
/**
599+
* Strip clipboard HTML boilerplate (StartFragment/EndFragment markers
600+
* and surrounding <html><body> wrappers).
601+
*/
602+
function cleanClipboardHTML(html) {
603+
let cleaned = html;
604+
const fragStart = cleaned.indexOf('<!--StartFragment-->');
605+
const fragEnd = cleaned.indexOf('<!--EndFragment-->');
606+
if (fragStart !== -1 && fragEnd !== -1) {
607+
cleaned = cleaned.substring(fragStart + '<!--StartFragment-->'.length, fragEnd);
608+
}
609+
return cleaned.trim();
610+
}
615611

616612
function init() {
617613
const inputEditor = document.getElementById('input-editor');
@@ -628,11 +624,11 @@ function init() {
628624
const errorSection = document.getElementById('error-section');
629625
const errorDismiss = document.getElementById('error-dismiss');
630626

631-
// Format button
627+
// Format button — extract HTML from the rich text input and clean it
632628
btnFormat.addEventListener('click', () => {
633-
const html = inputEditor.value;
634-
if (!html.trim()) {
635-
showError('No input', 'Paste some HTML into the input pane first.');
629+
const html = getInputHTML(inputEditor);
630+
if (!html) {
631+
showError('No input', 'Paste some rich text or HTML into the input pane first.');
636632
return;
637633
}
638634
hideError();
@@ -650,9 +646,9 @@ function init() {
650646

651647
// Minify button
652648
btnMinify.addEventListener('click', () => {
653-
const html = inputEditor.value;
654-
if (!html.trim()) {
655-
showError('No input', 'Paste some HTML into the input pane first.');
649+
const html = getInputHTML(inputEditor);
650+
if (!html) {
651+
showError('No input', 'Paste some rich text or HTML into the input pane first.');
656652
return;
657653
}
658654
hideError();
@@ -670,48 +666,39 @@ function init() {
670666
}
671667
});
672668

673-
// Paste button — tries to read HTML from clipboard first
669+
// Paste button — reads HTML from clipboard and inserts as rich text
674670
btnPaste.addEventListener('click', async () => {
675671
try {
676-
// Try the modern Clipboard API for rich content
677672
if (navigator.clipboard && navigator.clipboard.read) {
678673
const items = await navigator.clipboard.read();
679674
for (const item of items) {
680675
if (item.types.includes('text/html')) {
681676
const blob = await item.getType('text/html');
682677
const html = await blob.text();
683-
// Extract fragment if markers exist
684-
let cleaned = html;
685-
const fragStart = cleaned.indexOf('<!--StartFragment-->');
686-
const fragEnd = cleaned.indexOf('<!--EndFragment-->');
687-
if (fragStart !== -1 && fragEnd !== -1) {
688-
cleaned = cleaned.substring(fragStart + '<!--StartFragment-->'.length, fragEnd);
689-
}
690-
inputEditor.value = cleaned.trim();
678+
setInputHTML(inputEditor, cleanClipboardHTML(html));
691679
inputEditor.focus();
692680
return;
693681
}
694682
}
695683
}
696684
// Fall back to plain text
697685
const text = await navigator.clipboard.readText();
698-
inputEditor.value = text;
686+
inputEditor.textContent = text;
699687
inputEditor.focus();
700688
} catch {
701-
// Fallback — just focus the textarea so user can Ctrl+V
702689
inputEditor.focus();
703690
}
704691
});
705692

706-
// Load example
693+
// Load example — show rendered rich text
707694
btnLoadExample.addEventListener('click', () => {
708-
inputEditor.value = EXAMPLE_HTML;
695+
setInputHTML(inputEditor, EXAMPLE_RICH_HTML);
709696
inputEditor.focus();
710697
});
711698

712699
// Clear input
713700
btnClearInput.addEventListener('click', () => {
714-
inputEditor.value = '';
701+
setInputHTML(inputEditor, '');
715702
outputEditor.value = '';
716703
document.getElementById('stats-section').hidden = true;
717704
document.getElementById('preview-section').hidden = true;
@@ -740,11 +727,11 @@ function init() {
740727
URL.revokeObjectURL(url);
741728
});
742729

743-
// Use output as input
730+
// Use output as input — renders the cleaned HTML back as rich text
744731
btnUseAsInput.addEventListener('click', () => {
745732
const text = outputEditor.value;
746733
if (!text) return;
747-
inputEditor.value = text;
734+
setInputHTML(inputEditor, text);
748735
outputEditor.value = '';
749736
document.getElementById('stats-section').hidden = true;
750737
inputEditor.focus();
@@ -761,35 +748,19 @@ function init() {
761748
// Dismiss error
762749
errorDismiss.addEventListener('click', hideError);
763750

764-
// ---- Rich text paste support ----
765-
// When the user pastes rich text (from Word, Google Docs, a webpage, etc.)
766-
// we want the HTML markup, not the plain text fallback.
751+
// ---- Rich text paste handler ----
752+
// Intercept paste to strip clipboard boilerplate (StartFragment markers,
753+
// <html><body> wrappers) while preserving the rich text formatting.
767754
inputEditor.addEventListener('paste', (e) => {
768755
const clipboardData = e.clipboardData || window.clipboardData;
769756
if (!clipboardData) return;
770757

771758
const html = clipboardData.getData('text/html');
772759
if (html && html.trim()) {
773760
e.preventDefault();
774-
775-
// Extract meaningful content from the pasted HTML.
776-
// Clipboard HTML often wraps content in <html><body> boilerplate
777-
// with <!--StartFragment-->...<!--EndFragment--> markers.
778-
let cleaned = html;
779-
780-
// Extract just the fragment if markers exist
781-
const fragStart = cleaned.indexOf('<!--StartFragment-->');
782-
const fragEnd = cleaned.indexOf('<!--EndFragment-->');
783-
if (fragStart !== -1 && fragEnd !== -1) {
784-
cleaned = cleaned.substring(fragStart + '<!--StartFragment-->'.length, fragEnd);
785-
}
786-
787-
// Insert at cursor position (or replace selection)
788-
const start = inputEditor.selectionStart;
789-
const end = inputEditor.selectionEnd;
790-
inputEditor.value = inputEditor.value.substring(0, start) + cleaned.trim() + inputEditor.value.substring(end);
791-
inputEditor.selectionStart = inputEditor.selectionEnd = start + cleaned.trim().length;
792-
inputEditor.dispatchEvent(new Event('input'));
761+
const cleaned = cleanClipboardHTML(html);
762+
// Insert cleaned HTML at the current cursor position
763+
document.execCommand('insertHTML', false, cleaned);
793764
}
794765
// If no HTML data, let the default plain-text paste happen
795766
});
@@ -800,16 +771,6 @@ function init() {
800771
e.preventDefault();
801772
btnFormat.click();
802773
}
803-
// Allow Tab to insert a tab character in the textarea
804-
if (e.key === 'Tab') {
805-
e.preventDefault();
806-
const start = inputEditor.selectionStart;
807-
const end = inputEditor.selectionEnd;
808-
const indentSel = document.getElementById('indent-size').value;
809-
const insertStr = indentSel === 'tab' ? '\t' : ' '.repeat(parseInt(indentSel, 10));
810-
inputEditor.value = inputEditor.value.substring(0, start) + insertStr + inputEditor.value.substring(end);
811-
inputEditor.selectionStart = inputEditor.selectionEnd = start + insertStr.length;
812-
}
813774
});
814775
}
815776

formatter/index.html

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<header class="app-header">
3232
<h1>HTML Formatter &amp; Tidy</h1>
3333
<p class="subtitle">
34-
Paste rich text or raw HTML, strip unwanted styles, and get clean markup for your CMS.
34+
Paste rich text from any source, strip unwanted styles, and get clean HTML for your CMS.
3535
</p>
3636
</header>
3737

@@ -172,16 +172,14 @@ <h1>HTML Formatter &amp; Tidy</h1>
172172
</div>
173173
</div>
174174
<div class="textarea-wrapper">
175-
<textarea
175+
<div
176176
id="input-editor"
177-
class="editor-textarea"
178-
placeholder="Paste rich text or HTML here — rich text is automatically converted to markup..."
177+
class="editor-richtext"
178+
contenteditable="true"
179+
data-placeholder="Paste rich text here — formatting will be preserved. Click Format to generate clean HTML..."
179180
spellcheck="false"
180-
autocomplete="off"
181-
autocorrect="off"
182-
autocapitalize="off"
183-
aria-label="HTML input"
184-
></textarea>
181+
aria-label="Rich text input"
182+
></div>
185183
</div>
186184
</div>
187185

formatter/styles.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,46 @@ main,
321321
position: relative;
322322
}
323323

324+
/* Rich text input (contenteditable) */
325+
.editor-richtext {
326+
flex: 1;
327+
width: 100%;
328+
padding: var(--space-md);
329+
font-family: var(--font-sans);
330+
font-size: var(--text-base);
331+
line-height: 1.6;
332+
color: var(--color-text);
333+
background: var(--color-bg);
334+
border: none;
335+
outline: none;
336+
overflow: auto;
337+
overflow-wrap: break-word;
338+
word-wrap: break-word;
339+
-webkit-user-modify: read-write;
340+
}
341+
342+
.editor-richtext:empty::before {
343+
content: attr(data-placeholder);
344+
color: var(--color-text-muted);
345+
pointer-events: none;
346+
display: block;
347+
}
348+
349+
.editor-richtext:focus {
350+
background: color-mix(in srgb, var(--color-bg) 95%, var(--color-primary));
351+
}
352+
353+
/* Make pasted content look reasonable */
354+
.editor-richtext img {
355+
max-width: 100%;
356+
height: auto;
357+
}
358+
359+
.editor-richtext a {
360+
color: var(--color-primary);
361+
}
362+
363+
/* HTML source output (textarea) */
324364
.editor-textarea {
325365
flex: 1;
326366
width: 100%;

0 commit comments

Comments
 (0)