feat(browse): markdown front-matter pane + TOC resizer; misc UX fixes

Markdown preview pane now surfaces YAML front-matter above the TOC as a
key/value list (definition list), so engineering documents with header
metadata (title, revision, status, etc.) show their identity at a glance
without opening the file in mdedit. Front-matter parsing handles both
scalar and array values; arrays render as comma-joined.

TOC pane is now resizable (4px col-resize handle on its left edge);
preserves the user's chosen width across re-renders inside a single
session.

mdedit welcome banner moved inside #welcome-screen so the "browse opens
md in this same editor" callout only shows when no file is open — it
was previously visible in every state which was noisy.

archive.spec.js: wait for #filePreviewToggle to be attached before
clicking, fixing a Playwright flake where the preview button hadn't
mounted yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-10 19:30:26 -05:00
parent f2af379ff5
commit 7904a99c21
4 changed files with 167 additions and 10 deletions

View file

@ -506,3 +506,52 @@ html, body {
outline: 2px solid var(--primary); outline: 2px solid var(--primary);
outline-offset: -1px; outline-offset: -1px;
} }
/* Front-matter display section inside the TOC pane. */
.md-fm-section {
border-bottom: 1px solid var(--border);
max-height: 40%;
overflow-y: auto;
}
.md-fm-body {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
line-height: 1.4;
}
.fm-empty {
color: var(--text-muted);
font-style: italic;
font-size: 0.85rem;
margin: 0;
}
.fm-list {
margin: 0;
display: grid;
grid-template-columns: minmax(5rem, max-content) 1fr;
gap: 0.15rem 0.5rem;
}
.fm-list dt {
font-weight: 600;
color: var(--text-muted);
font-size: 0.8rem;
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-list dd {
margin: 0;
color: var(--text);
font-size: 0.85rem;
overflow-wrap: anywhere;
}
/* TOC pane resizer — narrower than the main one. */
.md-toc-resizer {
width: 4px;
}

View file

@ -44,6 +44,64 @@
currentInstance = null; currentInstance = null;
} }
// ── Front matter ───────────────────────────────────────────────────────
//
// Lightweight YAML-front-matter parser. Same shape as mdedit's
// parseFrontMatter — handles `---\n…\n---\n` envelope, key: value
// lines, simple `[a, b, c]` arrays. Comments (#) skipped. Returns
// { data, body }; body is the markdown content with the front-matter
// envelope stripped.
function parseFrontMatter(content) {
if (!content || !content.startsWith('---\n')) {
return { data: {}, body: content || '' };
}
var endIdx = content.indexOf('\n---\n', 4);
if (endIdx === -1) return { data: {}, body: content };
var fmText = content.substring(4, endIdx);
var body = content.substring(endIdx + 5);
var data = {};
var lines = fmText.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.charAt(0) === '#') continue;
var colon = line.indexOf(':');
if (colon <= 0) continue;
var key = line.substring(0, colon).trim();
var val = line.substring(colon + 1).trim();
val = val.replace(/^["']|["']$/g, '');
if (val.startsWith('[') && val.endsWith(']')) {
val = val.slice(1, -1).split(',').map(function (s) {
return s.trim().replace(/^["']|["']$/g, '');
});
}
data[key] = val;
}
return { data: data, body: body };
}
function renderFrontMatter(fmEl, content) {
if (!fmEl) return;
var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data);
if (keys.length === 0) {
fmEl.innerHTML = '<p class="fm-empty">No front matter.</p>';
return;
}
var html = '<dl class="fm-list">';
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
var v = parsed.data[k];
var displayV = Array.isArray(v)
? v.map(escapeHtml).join(', ')
: escapeHtml(String(v));
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
}
html += '</dl>';
fmEl.innerHTML = html;
}
// ── TOC (table of contents) ───────────────────────────────────────────── // ── TOC (table of contents) ─────────────────────────────────────────────
// //
// Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style // Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style
@ -262,8 +320,26 @@
editorHost.className = 'md-editor-host'; editorHost.className = 'md-editor-host';
split.appendChild(editorHost); split.appendChild(editorHost);
var tocResizer = document.createElement('div');
tocResizer.className = 'pane-resizer md-toc-resizer';
tocResizer.setAttribute('aria-hidden', 'true');
split.appendChild(tocResizer);
var tocPane = document.createElement('div'); var tocPane = document.createElement('div');
tocPane.className = 'md-toc-pane'; tocPane.className = 'md-toc-pane';
// Front-matter section above TOC, read-only display.
var fmSection = document.createElement('div');
fmSection.className = 'md-fm-section';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-toc-pane__header';
fmHeader.textContent = 'Front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-fm-body';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
tocPane.appendChild(fmSection);
var tocHeader = document.createElement('div'); var tocHeader = document.createElement('div');
tocHeader.className = 'md-toc-pane__header'; tocHeader.className = 'md-toc-pane__header';
tocHeader.textContent = 'Outline'; tocHeader.textContent = 'Outline';
@ -307,6 +383,34 @@
} }
renderToc(tocBody, text, editor); renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
// TOC pane resizer — drag horizontally. Stays in-memory only;
// refresh resets to the default 220px.
(function () {
var dragging = false;
var startX = 0;
var startWidth = 0;
tocResizer.addEventListener('mousedown', function (e) {
dragging = true;
tocResizer.classList.add('is-dragging');
startX = e.clientX;
startWidth = tocPane.getBoundingClientRect().width;
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
// Drag left to grow the TOC, right to shrink it.
var dx = e.clientX - startX;
var w = Math.max(150, Math.min(window.innerWidth * 0.4, startWidth - dx));
tocPane.style.width = w + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false;
tocResizer.classList.remove('is-dragging');
});
})();
function markDirty(isDirty) { function markDirty(isDirty) {
currentInstance.dirty = isDirty; currentInstance.dirty = isDirty;
@ -319,6 +423,7 @@
var h = await hashContent(current); var h = await hashContent(current);
markDirty(h !== currentInstance.hash); markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor); renderToc(tocBody, current, editor);
renderFrontMatter(fmBody, current);
}, 250); }, 250);
editor.on('change', updateOnChange); editor.on('change', updateOnChange);

View file

@ -58,15 +58,14 @@
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div> <div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content"> <div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin:1rem;border-radius:var(--radius)"> <div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;border-radius:var(--radius);max-width:36rem">
<strong>The Browse app now opens markdown files in this same editor.</strong> <strong>The Browse app now opens markdown files in this same editor.</strong>
Browse provides a unified file tree + per-file-type preview where Browse provides a unified file tree + per-file-type preview where
<code>.md</code> files render in this Toast UI editor. The <code>.md</code> files render in this Toast UI editor. The
standalone Markdown Editor remains available for offline single-file standalone Markdown Editor remains available for offline single-file
editing and air-gapped environments. editing and air-gapped environments.
</div> </div>
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p> <p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p> <p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div> </div>

View file

@ -438,7 +438,11 @@ test.describe('Archive Browser', () => {
test('Preview toggle is checked by default', async ({ page }) => { test('Preview toggle is checked by default', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#filePreviewToggle', { timeout: 15000 }); // The checkbox lives inside the (hidden) preview-controls area
// before any directory is loaded — waitForSelector defaults to
// 'visible' which would time out. Wait for it to be ATTACHED
// and verify the underlying state instead.
await page.waitForSelector('#filePreviewToggle', { state: 'attached', timeout: 15000 });
await expect(page.locator('#filePreviewToggle')).toBeChecked(); await expect(page.locator('#filePreviewToggle')).toBeChecked();
}); });