|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | +<title>markdown-copy component</title> |
| 7 | +<style> |
| 8 | + body { |
| 9 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| 10 | + max-width: 720px; |
| 11 | + margin: 60px auto; |
| 12 | + padding: 0 20px; |
| 13 | + background: #f8f9fa; |
| 14 | + color: #1a1a2e; |
| 15 | + } |
| 16 | + h1 { font-weight: 600; font-size: 1.4rem; margin-bottom: 0.3em; } |
| 17 | + p.intro { color: #555; font-size: 0.95rem; margin-bottom: 2rem; } |
| 18 | +</style> |
| 19 | +</head> |
| 20 | +<body> |
| 21 | + |
| 22 | +<h1><markdown-copy> demo</h1> |
| 23 | +<p class="intro">A web component that renders markdown, with copy & view-source controls.</p> |
| 24 | + |
| 25 | +<markdown-copy> |
| 26 | +## Getting Started |
| 27 | + |
| 28 | +Welcome to the project! Here's what you need to know. |
| 29 | + |
| 30 | +### Installation |
| 31 | + |
| 32 | +```bash |
| 33 | +npm install my-cool-library |
| 34 | +``` |
| 35 | + |
| 36 | +### Features |
| 37 | + |
| 38 | +- **Fast** — optimized for speed |
| 39 | +- **Lightweight** — under 5kb gzipped |
| 40 | +- **Extensible** — plugin architecture |
| 41 | + |
| 42 | +> "Simplicity is the ultimate sophistication." — Leonardo da Vinci |
| 43 | + |
| 44 | +### Usage |
| 45 | + |
| 46 | +```js |
| 47 | +import { render } from 'my-cool-library'; |
| 48 | + |
| 49 | +render('#app', { |
| 50 | + template: '<h1>Hello world</h1>' |
| 51 | +}); |
| 52 | +``` |
| 53 | + |
| 54 | +That's it — you're ready to go! |
| 55 | +</markdown-copy> |
| 56 | + |
| 57 | +<br> |
| 58 | + |
| 59 | +<markdown-copy> |
| 60 | +## Shopping List |
| 61 | + |
| 62 | +Things to pick up: |
| 63 | + |
| 64 | +- Sourdough bread |
| 65 | +- Olive oil (extra virgin) |
| 66 | +- Lemons × 4 |
| 67 | +- Fresh basil |
| 68 | +- Mozzarella |
| 69 | + |
| 70 | +| Item | Qty | Aisle | |
| 71 | +|------|-----|-------| |
| 72 | +| Bread | 1 | Bakery | |
| 73 | +| Olive oil | 1 | 3 | |
| 74 | +| Lemons | 4 | Produce | |
| 75 | + |
| 76 | +*Don't forget the reusable bags!* |
| 77 | +</markdown-copy> |
| 78 | + |
| 79 | +<!-- ════════════════════════════════════════════ --> |
| 80 | +<!-- Web Component Definition --> |
| 81 | +<!-- ════════════════════════════════════════════ --> |
| 82 | +<script type="module"> |
| 83 | +import { marked } from 'https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/lib/marked.esm.js'; |
| 84 | + |
| 85 | +class MarkdownCopy extends HTMLElement { |
| 86 | + constructor() { |
| 87 | + super(); |
| 88 | + this.attachShadow({ mode: 'open' }); |
| 89 | + this._showRaw = false; |
| 90 | + this._menuOpen = false; |
| 91 | + } |
| 92 | + |
| 93 | + connectedCallback() { |
| 94 | + /* ── grab raw markdown, dedent it ── */ |
| 95 | + this._raw = this._dedent(this.textContent); |
| 96 | + |
| 97 | + /* ── render ── */ |
| 98 | + this.shadowRoot.innerHTML = ` |
| 99 | + <style>${MarkdownCopy.styles}</style> |
| 100 | + <div class="wrap"> |
| 101 | + <!-- tag / menu --> |
| 102 | + <div class="tag-area"> |
| 103 | + <button class="tag" aria-label="Markdown options">markdown</button> |
| 104 | + <div class="menu hidden"> |
| 105 | + <button class="menu-btn" data-action="copy"> |
| 106 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> |
| 107 | + Copy |
| 108 | + </button> |
| 109 | + <button class="menu-btn" data-action="toggle"> |
| 110 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> |
| 111 | + See code |
| 112 | + </button> |
| 113 | + </div> |
| 114 | + </div> |
| 115 | + <!-- content --> |
| 116 | + <div class="rendered">${marked.parse(this._raw)}</div> |
| 117 | + <pre class="raw hidden">${this._escapeHtml(this._raw)}</pre> |
| 118 | + </div> |
| 119 | + `; |
| 120 | + |
| 121 | + /* ── wire events ── */ |
| 122 | + const tag = this.shadowRoot.querySelector('.tag'); |
| 123 | + const menu = this.shadowRoot.querySelector('.menu'); |
| 124 | + |
| 125 | + tag.addEventListener('click', (e) => { |
| 126 | + e.stopPropagation(); |
| 127 | + this._menuOpen = !this._menuOpen; |
| 128 | + menu.classList.toggle('hidden', !this._menuOpen); |
| 129 | + }); |
| 130 | + |
| 131 | + this.shadowRoot.querySelector('[data-action="copy"]').addEventListener('click', () => { |
| 132 | + navigator.clipboard.writeText(this._raw).then(() => { |
| 133 | + const btn = this.shadowRoot.querySelector('[data-action="copy"]'); |
| 134 | + const orig = btn.innerHTML; |
| 135 | + btn.textContent = 'Copied!'; |
| 136 | + setTimeout(() => btn.innerHTML = orig, 1200); |
| 137 | + }); |
| 138 | + }); |
| 139 | + |
| 140 | + this.shadowRoot.querySelector('[data-action="toggle"]').addEventListener('click', () => { |
| 141 | + this._showRaw = !this._showRaw; |
| 142 | + const rendered = this.shadowRoot.querySelector('.rendered'); |
| 143 | + const raw = this.shadowRoot.querySelector('.raw'); |
| 144 | + const btn = this.shadowRoot.querySelector('[data-action="toggle"]'); |
| 145 | + rendered.classList.toggle('hidden', this._showRaw); |
| 146 | + raw.classList.toggle('hidden', !this._showRaw); |
| 147 | + /* update label */ |
| 148 | + const svgIcon = btn.querySelector('svg')?.outerHTML || ''; |
| 149 | + btn.innerHTML = svgIcon + (this._showRaw ? ' See rendered' : ' See code'); |
| 150 | + }); |
| 151 | + |
| 152 | + /* close menu on outside click */ |
| 153 | + document.addEventListener('click', () => { |
| 154 | + this._menuOpen = false; |
| 155 | + menu.classList.add('hidden'); |
| 156 | + }); |
| 157 | + |
| 158 | + this.shadowRoot.addEventListener('click', (e) => { |
| 159 | + if (!e.target.closest('.tag-area')) { |
| 160 | + this._menuOpen = false; |
| 161 | + menu.classList.add('hidden'); |
| 162 | + } |
| 163 | + }); |
| 164 | + } |
| 165 | + |
| 166 | + /* ── helpers ── */ |
| 167 | + |
| 168 | + _dedent(text) { |
| 169 | + const lines = text.replace(/^\n+/, '').replace(/\n+$/, '').split('\n'); |
| 170 | + const indent = lines |
| 171 | + .filter(l => l.trim().length > 0) |
| 172 | + .reduce((min, l) => { |
| 173 | + const leading = l.match(/^ */)[0].length; |
| 174 | + return Math.min(min, leading); |
| 175 | + }, Infinity); |
| 176 | + return lines.map(l => l.slice(indent)).join('\n').trim(); |
| 177 | + } |
| 178 | + |
| 179 | + _escapeHtml(s) { |
| 180 | + return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| 181 | + } |
| 182 | + |
| 183 | + /* ── styles ── */ |
| 184 | + |
| 185 | + static get styles() { |
| 186 | + return /* css */` |
| 187 | + :host { display: block; } |
| 188 | +
|
| 189 | + .wrap { |
| 190 | + position: relative; |
| 191 | + border: 1px solid #d0d5dd; |
| 192 | + border-radius: 10px; |
| 193 | + padding: 1.25rem 1.5rem; |
| 194 | + background: #fff; |
| 195 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| 196 | + color: #1a1a2e; |
| 197 | + line-height: 1.6; |
| 198 | + } |
| 199 | +
|
| 200 | + /* ── rendered markdown ── */ |
| 201 | + .rendered { font-size: 0.95rem; } |
| 202 | + .rendered h1, .rendered h2, .rendered h3 { |
| 203 | + margin-top: 1.1em; margin-bottom: 0.4em; font-weight: 600; |
| 204 | + } |
| 205 | + .rendered h2 { font-size: 1.25rem; border-bottom: 1px solid #eee; padding-bottom: 0.3em; } |
| 206 | + .rendered h3 { font-size: 1.05rem; } |
| 207 | + .rendered p { margin: 0.5em 0; } |
| 208 | + .rendered ul, .rendered ol { padding-left: 1.4em; margin: 0.4em 0; } |
| 209 | + .rendered li { margin: 0.2em 0; } |
| 210 | + .rendered blockquote { |
| 211 | + margin: 0.8em 0; padding: 0.4em 1em; |
| 212 | + border-left: 3px solid #c5a3ff; background: #faf7ff; |
| 213 | + color: #444; font-style: italic; |
| 214 | + } |
| 215 | + .rendered code { |
| 216 | + background: #f0f0f5; padding: 0.15em 0.4em; border-radius: 4px; |
| 217 | + font-size: 0.88em; font-family: 'SF Mono', Consolas, monospace; |
| 218 | + } |
| 219 | + .rendered pre { |
| 220 | + background: #1e1e2e; color: #cdd6f4; padding: 1em; |
| 221 | + border-radius: 8px; overflow-x: auto; font-size: 0.85rem; |
| 222 | + line-height: 1.5; |
| 223 | + } |
| 224 | + .rendered pre code { |
| 225 | + background: none; padding: 0; color: inherit; |
| 226 | + } |
| 227 | + .rendered table { |
| 228 | + border-collapse: collapse; width: 100%; margin: 0.6em 0; font-size: 0.9rem; |
| 229 | + } |
| 230 | + .rendered th, .rendered td { |
| 231 | + border: 1px solid #ddd; padding: 0.45em 0.8em; text-align: left; |
| 232 | + } |
| 233 | + .rendered th { background: #f5f5fa; font-weight: 600; } |
| 234 | + .rendered img { max-width: 100%; border-radius: 6px; } |
| 235 | +
|
| 236 | + /* ── raw view ── */ |
| 237 | + .raw { |
| 238 | + white-space: pre-wrap; |
| 239 | + word-wrap: break-word; |
| 240 | + font-family: 'SF Mono', Consolas, monospace; |
| 241 | + font-size: 0.88rem; |
| 242 | + line-height: 1.55; |
| 243 | + margin: 0; |
| 244 | + color: #374151; |
| 245 | + background: #f9fafb; |
| 246 | + padding: 0.8em; |
| 247 | + border-radius: 6px; |
| 248 | + } |
| 249 | +
|
| 250 | + /* ── tag & menu ── */ |
| 251 | + .tag-area { |
| 252 | + position: absolute; |
| 253 | + top: 10px; right: 12px; |
| 254 | + display: flex; flex-direction: column; align-items: flex-end; |
| 255 | + z-index: 2; |
| 256 | + user-select: none; |
| 257 | + } |
| 258 | + .tag { |
| 259 | + background: #eef0ff; |
| 260 | + color: #5b5fc7; |
| 261 | + font-size: 0.7rem; |
| 262 | + font-weight: 600; |
| 263 | + text-transform: uppercase; |
| 264 | + letter-spacing: 0.04em; |
| 265 | + padding: 3px 10px; |
| 266 | + border-radius: 6px; |
| 267 | + border: 1px solid #d4d7ff; |
| 268 | + cursor: pointer; |
| 269 | + transition: background 0.15s, transform 0.1s; |
| 270 | + font-family: inherit; |
| 271 | + } |
| 272 | + .tag:hover { background: #dde0ff; } |
| 273 | + .tag:active { transform: scale(0.96); } |
| 274 | +
|
| 275 | + .menu { |
| 276 | + margin-top: 6px; |
| 277 | + background: #fff; |
| 278 | + border: 1px solid #e0e0e0; |
| 279 | + border-radius: 8px; |
| 280 | + box-shadow: 0 4px 16px rgba(0,0,0,0.10); |
| 281 | + overflow: hidden; |
| 282 | + min-width: 140px; |
| 283 | + } |
| 284 | + .menu-btn { |
| 285 | + display: flex; align-items: center; gap: 8px; |
| 286 | + width: 100%; |
| 287 | + padding: 8px 14px; |
| 288 | + font-size: 0.82rem; |
| 289 | + font-family: inherit; |
| 290 | + color: #333; |
| 291 | + background: none; |
| 292 | + border: none; |
| 293 | + cursor: pointer; |
| 294 | + transition: background 0.12s; |
| 295 | + } |
| 296 | + .menu-btn:hover { background: #f3f4f6; } |
| 297 | + .menu-btn + .menu-btn { border-top: 1px solid #f0f0f0; } |
| 298 | + .menu-btn svg { flex-shrink: 0; opacity: 0.6; } |
| 299 | +
|
| 300 | + .hidden { display: none !important; } |
| 301 | + `; |
| 302 | + } |
| 303 | +} |
| 304 | + |
| 305 | +customElements.define('markdown-copy', MarkdownCopy); |
| 306 | +</script> |
| 307 | + |
| 308 | +</body> |
| 309 | +</html> |
0 commit comments