Dark Mode

Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 65e450d

Browse files
authored
1 parent 8511083 commit 65e450d

File tree

1 file changed

+320
-0
lines changed
  • rich-text-to-markdown.html

1 file changed

+320
-0
lines changed

rich-text-to-markdown.html

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Rich Text - Markdown (No React)title>
7+
<style>
8+
:root {
9+
--bg: #ffffff;
10+
--card: #ffffff;
11+
--muted: #6b7280;
12+
--text: #111111;
13+
--accent: #4f8cff;
14+
--border: #e5e7eb;
15+
}
16+
* { box-sizing: border-box; }
17+
html, body { height: 100%; }
18+
body {
19+
margin: 0;
20+
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
21+
background: var(--bg);
22+
color: var(--text);
23+
}
24+
.wrap {
25+
max-width: 900px;
26+
margin: 0 auto;
27+
padding: 16px;
28+
display: flex;
29+
flex-direction: column;
30+
gap: 12px;
31+
}
32+
header h1 { font-size: 1.25rem; margin: 0 0 4px; }
33+
header p { margin: 0; color: var(--muted); font-size: 0.95rem; }
34+
35+
.panel {
36+
background: var(--card);
37+
border: 1px solid var(--border);
38+
border-radius: 14px;
39+
padding: 12px;
40+
}
41+
42+
.grid {
43+
display: grid;
44+
grid-template-columns: 1fr;
45+
gap: 12px;
46+
}
47+
@media (min-width: 840px) {
48+
.grid { grid-template-columns: 1fr 1fr; }
49+
}
50+
51+
.label {
52+
display: flex;
53+
align-items: center;
54+
justify-content: space-between;
55+
gap: 8px;
56+
font-size: 0.9rem;
57+
color: var(--muted);
58+
margin-bottom: 8px;
59+
}
60+
61+
.dropzone {
62+
border: 1px dashed var(--border);
63+
border-radius: 12px;
64+
min-height: 180px;
65+
padding: 12px;
66+
background: #ffffff;
67+
outline: none;
68+
overflow: auto;
69+
}
70+
.dropzone:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #4f8cff22; }
71+
.placeholder { color: #6b7280; }
72+
73+
textarea.output {
74+
width: 100%;
75+
min-height: 220px;
76+
resize: vertical;
77+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
78+
background: #ffffff;
79+
border: 1px solid var(--border);
80+
border-radius: 12px;
81+
color: var(--text);
82+
padding: 12px;
83+
line-height: 1.35;
84+
}
85+
86+
.btnrow {
87+
display: flex;
88+
gap: 8px;
89+
flex-wrap: wrap;
90+
}
91+
92+
button {
93+
appearance: none;
94+
border: 1px solid var(--border);
95+
background: #f3f4f6; /* light gray for legibility on white */
96+
color: var(--text);
97+
padding: 10px 14px;
98+
border-radius: 12px;
99+
font-size: 0.95rem;
100+
}
101+
button.primary { border-color: transparent; background: var(--accent); color: white; }
102+
button:active { transform: translateY(1px); }
103+
104+
.hint { color: var(--muted); font-size: 0.85rem; }
105+
.toast { font-size: 0.9rem; color: #b3ffbe; display: none; }
106+
style>
107+
head>
108+
<body>
109+
<div class="wrap">
110+
<header>
111+
<h1>Rich Text - Markdown (No React)h1>
112+
<p>Paste formatted text on mobile or desktop. Outputs Markdown with <strong>boldstrong>, <em>italicem>, inline <a href="#">linksa>, and paragraph breaks.p>
113+
header>
114+
115+
<div class="grid">
116+
<section class="panel">
117+
<div class="label">
118+
<span>1) Paste rich text belowspan>
119+
<span class="hint">Tip: long-press - Pastespan>
120+
div>
121+
<div id="input" class="dropzone" contenteditable="true" role="textbox" aria-label="Paste rich text here" data-placeholder="Paste here...">div>
122+
<div class="btnrow" style="margin-top:10px;">
123+
<button id="clearBtn" type="button">Clearbutton>
124+
div>
125+
section>
126+
127+
<section class="panel">
128+
<div class="label">
129+
<span>2) Markdown outputspan>
130+
<span class="toast" id="toast">Copied span>
131+
div>
132+
<textarea id="output" class="output" spellcheck="false" aria-label="Markdown output" placeholder="Your Markdown will appear here...">textarea>
133+
<div class="btnrow" style="margin-top:10px;">
134+
<button id="convertBtn" class="primary" type="button">Convertbutton>
135+
<button id="copyBtn" type="button">Copybutton>
136+
div>
137+
<p class="hint" style="margin-top:10px;">Only <strong>boldstrong>, <em>italicem>, inline links, line breaks, and paragraph breaks are converted. Other formatting is ignored.p>
138+
section>
139+
div>
140+
141+
<section class="panel">
142+
<div class="label" style="margin-bottom:0;">
143+
<span>How it worksspan>
144+
div>
145+
<p class="hint" style="margin-top:6px;">When you paste, the app reads the underlying HTML, safely parses it, and maps <code><strong>code>/<code><em>code>/<code><a>code>/<code><p>code>/<code><br>code> to Markdown.p>
146+
section>
147+
div>
148+
149+
<script>
150+
const input = document.getElementById('input');
151+
const output = document.getElementById('output');
152+
const convertBtn = document.getElementById('convertBtn');
153+
const copyBtn = document.getElementById('copyBtn');
154+
const clearBtn = document.getElementById('clearBtn');
155+
const toast = document.getElementById('toast');
156+
157+
// Placeholder behavior for contenteditable
158+
function updatePlaceholder() {
159+
if (!input.textContent.trim()) {
160+
input.classList.add('placeholder');
161+
input.setAttribute('data-empty', 'true');
162+
input.innerHTML = '' + (input.getAttribute('data-placeholder') || 'Paste here...') + '';
163+
placeCaretAtEnd(input);
164+
}
165+
}
166+
167+
function clearPlaceholder() {
168+
if (input.getAttribute('data-empty') === 'true') {
169+
input.innerHTML = '';
170+
input.classList.remove('placeholder');
171+
input.removeAttribute('data-empty');
172+
}
173+
}
174+
175+
function placeCaretAtEnd(el) {
176+
el.focus();
177+
if (typeof window.getSelection != "undefined"
178+
&& typeof document.createRange != "undefined") {
179+
const range = document.createRange();
180+
range.selectNodeContents(el);
181+
range.collapse(false);
182+
const sel = window.getSelection();
183+
sel.removeAllRanges();
184+
sel.addRange(range);
185+
}
186+
}
187+
188+
function escapeText(text) {
189+
return text
190+
.replace(/\\/g, "\\\\")
191+
.replace(/\*/g, "\\*")
192+
.replace(/_/g, "\\_")
193+
.replace(/\[/g, "\\[")
194+
.replace(/\]/g, "\\]")
195+
.replace(/\(/g, "\\(")
196+
.replace(/\)/g, "\\)")
197+
.replace(/`/g, "\\`");
198+
}
199+
200+
function safeHref(href) {
201+
if (!href) return '';
202+
const h = href.trim();
203+
if (/^(https?:|mailto:|tel:)/i.test(h)) return h;
204+
return '';
205+
}
206+
207+
function walk(node) {
208+
if (node.nodeType === Node.TEXT_NODE) {
209+
return escapeText(node.nodeValue || '');
210+
}
211+
if (node.nodeType !== Node.ELEMENT_NODE) return '';
212+
const tag = node.tagName;
213+
let inner = Array.from(node.childNodes).map(walk).join('');
214+
// Trim inside formatting wrappers to avoid things like ** bold **
215+
const trimInner = () => inner.replace(/^\s+|\s+$/g, '');
216+
217+
switch (tag) {
218+
case 'BR':
219+
return '\n';
220+
case 'P':
221+
return inner.trim() + '\n\n';
222+
case 'DIV':
223+
case 'SECTION':
224+
case 'ARTICLE':
225+
case 'HEADER':
226+
case 'FOOTER':
227+
// Treat block containers as paragraphs
228+
return inner.trim() ? (inner.trim() + '\n\n') : '';
229+
case 'STRONG':
230+
case 'B':
231+
return trimInner() ? `**${trimInner()}**` : '';
232+
case 'EM':
233+
case 'I':
234+
return trimInner() ? `*${trimInner()}*` : '';
235+
case 'A':
236+
const href = safeHref(node.getAttribute('href'));
237+
const text = inner || href;
238+
return href ? `[${text}](${href})` : text;
239+
case 'H1':
240+
case 'H2':
241+
case 'H3':
242+
case 'H4':
243+
case 'H5':
244+
case 'H6':
245+
return inner.trim() + '\n\n';
246+
case 'SPAN':
247+
return inner;
248+
case 'UL':
249+
case 'OL':
250+
// Not requested, but flatten to lines so paragraphs remain coherent
251+
return Array.from(node.children).map(walk).join('\n') + '\n\n';
252+
case 'LI':
253+
return inner.trim();
254+
default:
255+
return inner;
256+
}
257+
}
258+
259+
function htmlToMarkdown(html) {
260+
const parser = new DOMParser();
261+
const doc = parser.parseFromString(html, 'text/html');
262+
let md = walk(doc.body);
263+
// Normalize newlines: collapse 3+ to 2
264+
md = md.replace(/\n{3,}/g, '\n\n');
265+
// Trim extra whitespace around paragraphs
266+
md = md.replace(/[ \t]+\n/g, '\n');
267+
return md.trim();
268+
}
269+
270+
function convert() {
271+
const html = input.getAttribute('data-empty') === 'true' ? '' : input.innerHTML;
272+
const md = htmlToMarkdown(html || '');
273+
output.value = md;
274+
return md;
275+
}
276+
277+
function showToast() {
278+
toast.style.display = 'inline';
279+
setTimeout(() => { toast.style.display = 'none'; }, 1200);
280+
}
281+
282+
// Event wiring
283+
input.addEventListener('focus', clearPlaceholder);
284+
input.addEventListener('blur', () => { if (!input.textContent.trim()) updatePlaceholder(); });
285+
286+
input.addEventListener('paste', (e) => {
287+
// Let the browser paste normally so users see formatting, then convert
288+
setTimeout(convert, 0);
289+
});
290+
291+
input.addEventListener('input', () => {
292+
// Live conversion on input for immediate feedback
293+
convert();
294+
});
295+
296+
convertBtn.addEventListener('click', convert);
297+
298+
copyBtn.addEventListener('click', async () => {
299+
try {
300+
await navigator.clipboard.writeText(output.value || '');
301+
showToast();
302+
} catch (err) {
303+
// Fallback: select the text so the user can copy manually
304+
output.select();
305+
showToast();
306+
}
307+
});
308+
309+
clearBtn.addEventListener('click', () => {
310+
input.innerHTML = '';
311+
output.value = '';
312+
updatePlaceholder();
313+
input.focus();
314+
});
315+
316+
// Init
317+
updatePlaceholder();
318+
script>
319+
body>
320+
html>

0 commit comments

Comments
(0)