|
| 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