Add lightweight single-file Markdown reader
Self-contained HTML with inline CSS and a small Markdown parser. Mobile-first layout, dark mode via prefers-color-scheme, file picker plus drag-and-drop, no external requests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
559
index.html
Normal file
559
index.html
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||||
|
<title>mdreader</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--muted: #666;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--code-bg: #f5f5f5;
|
||||||
|
--quote-border: #d0d0d0;
|
||||||
|
--link: #0366d6;
|
||||||
|
--bar-bg: #fafafa;
|
||||||
|
--accent: #0366d6;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a1a;
|
||||||
|
--fg: #e8e8e8;
|
||||||
|
--muted: #999;
|
||||||
|
--border: #333;
|
||||||
|
--code-bg: #262626;
|
||||||
|
--quote-border: #444;
|
||||||
|
--link: #58a6ff;
|
||||||
|
--bar-bg: #222;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bar-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
padding-top: max(10px, env(safe-area-inset-top));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.bar h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.btn.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.btn:active { opacity: 0.7; }
|
||||||
|
input[type=file] { display: none; }
|
||||||
|
main {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 18px 60px;
|
||||||
|
padding-bottom: max(60px, env(safe-area-inset-bottom));
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty h2 { color: var(--fg); margin-bottom: 8px; }
|
||||||
|
.empty p { margin: 6px 0; font-size: 14px; }
|
||||||
|
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6 {
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#content h1 { font-size: 1.8em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
||||||
|
#content h2 { font-size: 1.45em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
||||||
|
#content h3 { font-size: 1.2em; }
|
||||||
|
#content h4 { font-size: 1.05em; }
|
||||||
|
#content h5 { font-size: 0.95em; }
|
||||||
|
#content h6 { font-size: 0.85em; color: var(--muted); }
|
||||||
|
#content p { margin: 0.8em 0; }
|
||||||
|
#content a { color: var(--link); text-decoration: none; }
|
||||||
|
#content a:hover { text-decoration: underline; }
|
||||||
|
#content code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
#content pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.88em;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
#content pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
#content blockquote {
|
||||||
|
border-left: 4px solid var(--quote-border);
|
||||||
|
margin: 0.8em 0;
|
||||||
|
padding: 0.2em 0 0.2em 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#content blockquote > p { margin: 0.4em 0; }
|
||||||
|
#content ul, #content ol {
|
||||||
|
padding-left: 1.6em;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
#content li { margin: 0.3em 0; }
|
||||||
|
#content li > ul, #content li > ol { margin: 0.3em 0; }
|
||||||
|
#content hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
#content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
#content th, #content td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
#content th { background: var(--code-bg); font-weight: 600; }
|
||||||
|
#content del { color: var(--muted); }
|
||||||
|
#content input[type=checkbox] { margin-right: 6px; }
|
||||||
|
#content .task-item { list-style: none; margin-left: -1.4em; }
|
||||||
|
.drop-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(3, 102, 214, 0.15);
|
||||||
|
border: 3px dashed var(--accent);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.drop-overlay.show { display: flex; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
main { padding: 16px 14px 40px; }
|
||||||
|
#content h1 { font-size: 1.55em; }
|
||||||
|
#content h2 { font-size: 1.3em; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bar">
|
||||||
|
<h1 id="filename">No file loaded</h1>
|
||||||
|
<button class="btn secondary" id="reloadBtn" style="display:none" title="Reload">↻</button>
|
||||||
|
<button class="btn" id="openBtn">Open</button>
|
||||||
|
<input type="file" id="fileInput" accept=".md,.markdown,.txt,text/markdown,text/plain">
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<div id="empty" class="empty">
|
||||||
|
<h2>mdreader</h2>
|
||||||
|
<p>Tap <strong>Open</strong> to pick a Markdown file.</p>
|
||||||
|
<p style="margin-top:18px; font-size:13px;">Works offline. Add to your home screen for one-tap access.</p>
|
||||||
|
</div>
|
||||||
|
<article id="content"></article>
|
||||||
|
</main>
|
||||||
|
<div id="dropOverlay" class="drop-overlay">Drop .md file to read</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// --- Minimal markdown parser ---
|
||||||
|
// Handles: headings, paragraphs, bold/italic/strike, inline code, code fences,
|
||||||
|
// links, images, lists (ordered/unordered/nested/tasks), blockquotes, hr, tables.
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInline(text) {
|
||||||
|
// Protect inline code first with placeholders
|
||||||
|
const codes = [];
|
||||||
|
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
|
||||||
|
codes.push(code);
|
||||||
|
return `\x00C${codes.length - 1}\x00`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape remaining HTML
|
||||||
|
text = escapeHtml(text);
|
||||||
|
|
||||||
|
// Images: 
|
||||||
|
text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
|
||||||
|
(_, alt, url, title) => {
|
||||||
|
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||||
|
return `<img src="${url}" alt="${alt}"${t}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
|
||||||
|
(_, txt, url, title) => {
|
||||||
|
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||||
|
return `<a href="${url}"${t} target="_blank" rel="noopener">${txt}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autolinks: <http://...>
|
||||||
|
text = text.replace(/<(https?:\/\/[^\s&]+)>/g,
|
||||||
|
(_, url) => `<a href="${url}" target="_blank" rel="noopener">${url}</a>`);
|
||||||
|
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
text = text.replace(/\*\*([^\s*][^*]*?[^\s*]|\S)\*\*/g, '<strong>$1</strong>');
|
||||||
|
text = text.replace(/__([^\s_][^_]*?[^\s_]|\S)__/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_
|
||||||
|
text = text.replace(/(^|[^*])\*([^\s*][^*]*?[^\s*]|\S)\*(?!\*)/g, '$1<em>$2</em>');
|
||||||
|
text = text.replace(/(^|[^_\w])_([^\s_][^_]*?[^\s_]|\S)_(?!\w)/g, '$1<em>$2</em>');
|
||||||
|
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
|
// Restore inline code
|
||||||
|
text = text.replace(/\x00C(\d+)\x00/g, (_, i) =>
|
||||||
|
`<code>${escapeHtml(codes[+i])}</code>`);
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(src) {
|
||||||
|
// Normalize line endings
|
||||||
|
src = src.replace(/\r\n?/g, '\n');
|
||||||
|
const lines = src.split('\n');
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
function isBlank(s) { return /^\s*$/.test(s); }
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Skip blank lines
|
||||||
|
if (isBlank(line)) { i++; continue; }
|
||||||
|
|
||||||
|
// Code fence
|
||||||
|
const fenceMatch = line.match(/^(\s*)(```+|~~~+)\s*(\S*)\s*$/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const fence = fenceMatch[2];
|
||||||
|
const lang = fenceMatch[3];
|
||||||
|
const buf = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i].startsWith(fence[0].repeat(fence.length))) {
|
||||||
|
buf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length) i++; // closing fence
|
||||||
|
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
||||||
|
out.push(`<pre><code${langClass}>${escapeHtml(buf.join('\n'))}</code></pre>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATX heading
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
const level = headingMatch[1].length;
|
||||||
|
out.push(`<h${level}>${parseInline(headingMatch[2])}</h${level}>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setext heading (next line is === or ---)
|
||||||
|
if (i + 1 < lines.length && /\S/.test(line)) {
|
||||||
|
const next = lines[i + 1];
|
||||||
|
if (/^=+\s*$/.test(next)) {
|
||||||
|
out.push(`<h1>${parseInline(line.trim())}</h1>`);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^-+\s*$/.test(next) && next.trim().length >= 2 && !/^\s*-\s/.test(line)) {
|
||||||
|
out.push(`<h2>${parseInline(line.trim())}</h2>`);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) {
|
||||||
|
out.push('<hr>');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
if (/^\s*>/.test(line)) {
|
||||||
|
const buf = [];
|
||||||
|
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
||||||
|
buf.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
// Recursively render inner content
|
||||||
|
out.push(`<blockquote>${parseMarkdown(buf.join('\n'))}</blockquote>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table: header line with | and a separator line
|
||||||
|
if (line.includes('|') && i + 1 < lines.length &&
|
||||||
|
/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(lines[i + 1])) {
|
||||||
|
const headerCells = splitTableRow(line);
|
||||||
|
const aligns = splitTableRow(lines[i + 1]).map(c => {
|
||||||
|
const s = c.trim();
|
||||||
|
const l = s.startsWith(':');
|
||||||
|
const r = s.endsWith(':');
|
||||||
|
if (l && r) return 'center';
|
||||||
|
if (r) return 'right';
|
||||||
|
if (l) return 'left';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
i += 2;
|
||||||
|
const rows = [];
|
||||||
|
while (i < lines.length && lines[i].includes('|') && !isBlank(lines[i])) {
|
||||||
|
rows.push(splitTableRow(lines[i]));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
let html = '<table><thead><tr>';
|
||||||
|
headerCells.forEach((c, idx) => {
|
||||||
|
const a = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : '';
|
||||||
|
html += `<th${a}>${parseInline(c.trim())}</th>`;
|
||||||
|
});
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
rows.forEach(r => {
|
||||||
|
html += '<tr>';
|
||||||
|
r.forEach((c, idx) => {
|
||||||
|
const a = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : '';
|
||||||
|
html += `<td${a}>${parseInline(c.trim())}</td>`;
|
||||||
|
});
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
out.push(html);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
const listItem = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
||||||
|
if (listItem) {
|
||||||
|
const result = parseList(lines, i);
|
||||||
|
out.push(result.html);
|
||||||
|
i = result.next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph: gather consecutive non-blank lines that aren't block starts
|
||||||
|
const paraBuf = [line];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !isBlank(lines[i]) && !isBlockStart(lines[i], lines[i + 1])) {
|
||||||
|
paraBuf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<p>${parseInline(paraBuf.join('\n'))}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTableRow(line) {
|
||||||
|
let s = line.trim();
|
||||||
|
if (s.startsWith('|')) s = s.slice(1);
|
||||||
|
if (s.endsWith('|')) s = s.slice(0, -1);
|
||||||
|
// Split on | not preceded by backslash
|
||||||
|
const out = [];
|
||||||
|
let cur = '';
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
if (s[i] === '\\' && s[i + 1] === '|') { cur += '|'; i++; }
|
||||||
|
else if (s[i] === '|') { out.push(cur); cur = ''; }
|
||||||
|
else cur += s[i];
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockStart(line, nextLine) {
|
||||||
|
if (/^\s*#{1,6}\s+/.test(line)) return true;
|
||||||
|
if (/^(\s*)(```+|~~~+)/.test(line)) return true;
|
||||||
|
if (/^\s*>/.test(line)) return true;
|
||||||
|
if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) return true;
|
||||||
|
if (/^(\s*)([-*+]|\d+\.)\s+/.test(line)) return true;
|
||||||
|
if (nextLine && (/^=+\s*$/.test(nextLine) || /^-+\s*$/.test(nextLine))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(lines, start) {
|
||||||
|
const firstMatch = lines[start].match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
||||||
|
const baseIndent = firstMatch[1].length;
|
||||||
|
const ordered = /\d+\./.test(firstMatch[2]);
|
||||||
|
const items = [];
|
||||||
|
let i = start;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const m = lines[i].match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
||||||
|
if (!m) break;
|
||||||
|
const indent = m[1].length;
|
||||||
|
if (indent < baseIndent) break;
|
||||||
|
if (indent > baseIndent) {
|
||||||
|
// Nested list — handled inside item's content collection below
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Collect this item's content lines (until next item at same/less indent or blank-then-non-list)
|
||||||
|
const itemLines = [m[3]];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const nm = lines[i].match(/^(\s*)([-*+]|\d+\.)\s+/);
|
||||||
|
if (nm && nm[1].length <= baseIndent) break;
|
||||||
|
if (isBlank(lines[i])) {
|
||||||
|
// peek ahead
|
||||||
|
if (i + 1 < lines.length) {
|
||||||
|
const nm2 = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/);
|
||||||
|
if (nm2 && nm2[1].length <= baseIndent) break;
|
||||||
|
if (!nm2 && /^\s{0,3}\S/.test(lines[i + 1] || '') &&
|
||||||
|
!lines[i + 1].startsWith(' '.repeat(baseIndent + 2))) break;
|
||||||
|
} else break;
|
||||||
|
itemLines.push('');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Continuation — strip baseIndent+2 spaces if present
|
||||||
|
const stripped = lines[i].replace(new RegExp(`^\\s{0,${baseIndent + 4}}`), '');
|
||||||
|
itemLines.push(stripped);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
items.push(itemLines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlank(s) { return /^\s*$/.test(s); }
|
||||||
|
|
||||||
|
const tag = ordered ? 'ol' : 'ul';
|
||||||
|
let html = `<${tag}>`;
|
||||||
|
items.forEach(content => {
|
||||||
|
// Task list?
|
||||||
|
const taskMatch = content.match(/^\[([ xX])\]\s+([\s\S]*)$/);
|
||||||
|
let cls = '';
|
||||||
|
if (taskMatch) {
|
||||||
|
const checked = taskMatch[1].toLowerCase() === 'x' ? ' checked' : '';
|
||||||
|
content = `<input type="checkbox" disabled${checked}> ${taskMatch[2]}`;
|
||||||
|
cls = ' class="task-item"';
|
||||||
|
html += `<li${cls}>${parseInline(content)}</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If content has block elements (multi-line with blank lines or nested list), render as block
|
||||||
|
if (/\n\s*\n/.test(content) || /^(\s*)([-*+]|\d+\.)\s+/m.test(content.split('\n').slice(1).join('\n'))) {
|
||||||
|
html += `<li>${parseMarkdown(content)}</li>`;
|
||||||
|
} else {
|
||||||
|
// Single paragraph — render inline only
|
||||||
|
html += `<li>${parseInline(content.replace(/\n/g, ' '))}</li>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += `</${tag}>`;
|
||||||
|
|
||||||
|
return { html, next: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- App ---
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const openBtn = document.getElementById('openBtn');
|
||||||
|
const reloadBtn = document.getElementById('reloadBtn');
|
||||||
|
const filenameEl = document.getElementById('filename');
|
||||||
|
const contentEl = document.getElementById('content');
|
||||||
|
const emptyEl = document.getElementById('empty');
|
||||||
|
const dropOverlay = document.getElementById('dropOverlay');
|
||||||
|
|
||||||
|
let lastFile = null;
|
||||||
|
|
||||||
|
function render(name, text) {
|
||||||
|
filenameEl.textContent = name;
|
||||||
|
document.title = name + ' — mdreader';
|
||||||
|
contentEl.innerHTML = parseMarkdown(text);
|
||||||
|
emptyEl.style.display = 'none';
|
||||||
|
reloadBtn.style.display = '';
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFile(file) {
|
||||||
|
lastFile = file;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => render(file.name, e.target.result);
|
||||||
|
reader.onerror = () => alert('Could not read file');
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', e => {
|
||||||
|
if (e.target.files[0]) loadFile(e.target.files[0]);
|
||||||
|
});
|
||||||
|
reloadBtn.addEventListener('click', () => {
|
||||||
|
if (lastFile) loadFile(lastFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop (desktop convenience)
|
||||||
|
['dragenter', 'dragover'].forEach(ev =>
|
||||||
|
document.addEventListener(ev, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer.types.includes('Files')) dropOverlay.classList.add('show');
|
||||||
|
}));
|
||||||
|
['dragleave', 'drop'].forEach(ev =>
|
||||||
|
document.addEventListener(ev, e => {
|
||||||
|
if (ev === 'dragleave' && e.relatedTarget) return;
|
||||||
|
dropOverlay.classList.remove('show');
|
||||||
|
}));
|
||||||
|
document.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f) loadFile(f);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user