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:
parent
f2af379ff5
commit
7904a99c21
4 changed files with 167 additions and 10 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<strong>The Browse app now opens markdown files in this same editor.</strong>
|
<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">
|
||||||
Browse provides a unified file tree + per-file-type preview where
|
<strong>The Browse app now opens markdown files in this same editor.</strong>
|
||||||
<code>.md</code> files render in this Toast UI editor. The
|
Browse provides a unified file tree + per-file-type preview where
|
||||||
standalone Markdown Editor remains available for offline single-file
|
<code>.md</code> files render in this Toast UI editor. The
|
||||||
editing and air-gapped environments.
|
standalone Markdown Editor remains available for offline single-file
|
||||||
</div>
|
editing and air-gapped environments.
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue