fix(browse): re-implement markdown editor layout on CSS Grid

The previous nested-flexbox layout produced indeterminate heights
inside the Toast UI editor host and made the TOC pane width fragile —
visually the editor and outline weren't laying out reliably. This
swaps the whole shell to CSS Grid, which gives every cell a definite
size.

Layout:
   ┌──────────────────────────────────────────────────────────────┐
   │  toolbar (Save | ● modified | status | source)               │
   ├─────────────────────────────────────┬────────────────────────┤
   │                                     │  Outline               │
   │   Toast UI Editor                   │  • Heading 1           │
   │   (md / wysiwyg / preview)          │    • Subheading        │
   │                                     ├────────────────────────┤
   │                                     │  Front matter          │
   │                                     │  title: …  rev: …      │
   └─────────────────────────────────────┴────────────────────────┘

Notes:
  - The shell mounts as a single child of #previewBody (not by
    re-classing previewBody itself), so the outer flex layout that
    fills the preview pane is preserved.
  - Sidebar is its own grid (outline 1fr + front-matter auto/max 40%),
    each section independently scrollable.
  - Resizer is a 6 px element on the grid column boundary; drag
    updates grid-template-columns. Keyboard left/right adjust by 24 px.
    Width persists across mounts (lastTocWidth) within a session.
  - parseHeadings now skips front-matter envelope + fenced code so a
    "##" inside ```bash``` doesn't show up as an outline entry.
  - scrollEditorToHeading uses findScrollParent + scrollTo({behavior:
    'smooth'}) so jumps feel less jarring.
  - Class names follow BEM: .md-shell__*, .md-side__*, .md-toc__*,
    .md-fm__*. Tests updated to the new selectors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 11:30:33 -05:00
parent d5638e9697
commit cb2cf1ebe3
3 changed files with 357 additions and 273 deletions

View file

@ -360,27 +360,41 @@ html, body {
.status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
| body). The grid gives every cell a definite size, which Toast UI
needs to compute its scroll regions correctly. A 4-px resizer sits
between the editor and sidebar; JS updates grid-template-columns on
drag. */
.md-shell {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 260px; /* JS overrides on resize */
grid-template-areas:
"toolbar toolbar"
"editor sidebar";
height: 100%;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
/* Editor toolbar (above the editor+TOC split): Save + dirty marker +
status + source hint. Sticks to the top of the pane body. */
.md-toolbar {
/* Toolbar spans both columns; subtle row above the editor. */
.md-shell__toolbar {
grid-area: toolbar;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: 0.85rem;
}
.md-toolbar__dirty {
.md-shell__dirty {
color: var(--text-muted);
font-size: 0.85rem;
min-width: 6rem;
min-width: 5.5rem;
}
.md-toolbar__status {
.md-shell__status {
flex: 1;
text-align: right;
color: var(--text-muted);
@ -389,8 +403,7 @@ html, body {
text-overflow: ellipsis;
white-space: nowrap;
}
.md-toolbar__source {
.md-shell__source {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
@ -401,97 +414,158 @@ html, body {
border: 1px solid var(--border);
}
/* Editor + TOC two-pane split inside the preview body. */
.md-split {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
overflow: hidden;
}
.md-editor-host {
flex: 1;
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
.md-shell__editor {
grid-area: editor;
min-width: 0;
min-height: 0;
overflow: hidden;
/* Toast UI mounts a .toastui-editor-defaultUI element here; give
it a definite height via height:100% in the JS. */
}
/* TOC pane sits on the right. Fixed width by default; the user can't
resize it (yet) kept simple in v1. */
.md-toc-pane {
width: 220px;
flex-shrink: 0;
/* Resizer sits on the grid border between editor (col 1) and sidebar
(col 2). Positioned absolutely over the boundary so it doesn't take
up a grid track itself. */
.md-shell__resizer {
grid-area: editor;
align-self: stretch;
justify-self: end;
width: 6px;
margin-right: -3px; /* center on the column boundary */
cursor: col-resize;
background: transparent;
z-index: 2;
transition: background 0.12s;
}
.md-shell__resizer:hover,
.md-shell__resizer.is-dragging,
.md-shell__resizer:focus-visible {
background: var(--primary);
outline: none;
}
/* Sidebar (right column): grid of two stacked sections Outline
(1fr) takes the bulk of the height, Front matter (auto, capped) is
below. */
.md-shell__sidebar {
grid-area: sidebar;
display: grid;
grid-template-rows: 1fr auto;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--border);
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.md-toc-pane__header {
.md-side {
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
overflow: hidden;
}
.md-side--fm {
border-top: 1px solid var(--border);
/* Front matter doesn't dominate — cap it so the outline keeps room. */
max-height: 40%;
}
.md-side__header {
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
flex-shrink: 0;
}
.md-toc-pane__body {
flex: 1;
.md-side__body {
overflow-y: auto;
padding: 0.4rem 0;
min-height: 0;
padding: 0.3rem 0;
font-size: 0.85rem;
line-height: 1.4;
line-height: 1.45;
}
.toc-empty {
/* ── Outline list ───────────────────────────────────────────────────────── */
.md-toc__empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0.75rem;
margin: 0;
font-size: 0.85rem;
font-size: 0.82rem;
}
.toc-list {
.md-toc__list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-item {
.md-toc__item {
margin: 0;
}
.toc-item a {
display: block;
padding: 0.2rem 0.75rem;
text-decoration: none;
padding: 0.22rem 0.75rem;
color: var(--text);
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s;
transition: background 0.1s, border-color 0.1s, color 0.1s;
/* Truncate long headings rather than wrap; the title attribute
carries the full text. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toc-item a:hover {
background: var(--bg-hover);
.md-toc__item:hover {
background: var(--bg-secondary);
border-left-color: var(--primary);
}
.toc-item a:focus-visible {
.md-toc__item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.md-toc__item--l1 { padding-left: 0.75rem; font-weight: 600; }
.md-toc__item--l2 { padding-left: 1.4rem; }
.md-toc__item--l3 { padding-left: 2.05rem; font-size: 0.82rem; }
.md-toc__item--l4 { padding-left: 2.7rem; font-size: 0.8rem; color: var(--text-muted); }
.md-toc__item--l5 { padding-left: 3.35rem; font-size: 0.78rem; color: var(--text-muted); }
.md-toc__item--l6 { padding-left: 4rem; font-size: 0.78rem; color: var(--text-muted); }
.toc-level-1 a { padding-left: 0.75rem; font-weight: 600; }
.toc-level-2 a { padding-left: 1.4rem; }
.toc-level-3 a { padding-left: 2.05rem; }
.toc-level-4 a { padding-left: 2.7rem; color: var(--text-muted); }
.toc-level-5 a { padding-left: 3.35rem; color: var(--text-muted); font-size: 0.8rem; }
.toc-level-6 a { padding-left: 4rem; color: var(--text-muted); font-size: 0.8rem; }
/* Flash on click applied to the heading element in the editor pane.
The class is scoped to .md-toc__flash so it doesn't paint outside
this plugin. */
.md-toc__flash {
background-color: rgba(95, 168, 224, 0.25) !important;
transition: background-color 0.3s ease;
}
/* ── Front matter list ──────────────────────────────────────────────────── */
.md-fm__empty {
color: var(--text-muted);
font-style: italic;
font-size: 0.82rem;
margin: 0;
padding: 0.5rem 0.75rem;
}
.md-fm__list {
margin: 0;
padding: 0.3rem 0.75rem;
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
gap: 0.2rem 0.6rem;
font-size: 0.8rem;
}
.md-fm__list dt {
font-weight: 600;
color: var(--text-muted);
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-fm__list dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
/* ── Sort control ────────────────────────────────────────────────────────── */
.sort-control {
@ -523,51 +597,5 @@ html, body {
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;
}
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */

View file

@ -1,30 +1,45 @@
// preview-markdown.js — markdown plugin for the browse preview pane.
// Click a .md / .markdown file in the tree → instantiate Toast UI
// editor inside the right pane, alongside a TOC pane on the right.
// Save (Ctrl+S) writes back via:
// - PUT to the file's server URL when in server mode, or
// - FileSystemWritableFileStream when in FS-API mode (local folder
// picker). Both paths set dirty=false + a status timestamp on
// success.
// zip-virtual files are read-only — the save button stays disabled.
//
// Toast UI Editor is bundled (shared/vendor/toastui-editor-all.min.js)
// and is available synchronously as window.toastui by the time this
// module runs.
// Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐
// │ toolbar: Save | ● modified | status | source │
// ├────────────────────────────────────────┬────────────────────────┤
// │ │ Outline │
// │ │ • Heading 1 │
// │ Toast UI Editor │ • Subheading │
// │ (md / wysiwyg / preview) │ • Heading 2 │
// │ ├────────────────────────┤
// │ │ Front matter │
// │ │ title: Foo │
// │ │ revision: A │
// └────────────────────────────────────────┴────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both.
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored
// (shared/vendor/toastui-editor-all.min.js); window.toastui is
// available synchronously before this module runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var TOC_MIN_WIDTH = 180;
var TOC_MAX_WIDTH = 480;
var TOC_DEFAULT_WIDTH = 260;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl }
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastTocWidth = TOC_DEFAULT_WIDTH; // remember across mounts
// Compute SHA-256 hex of a string for a "is this content different
// from what we loaded?" check. Used to enable/disable Save.
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
@ -44,21 +59,16 @@
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.
// ── Front matter ────────────────────────────────────────────────────────
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
function parseFrontMatter(content) {
if (!content || !content.startsWith('---\n')) {
if (!content || content.indexOf('---\n') !== 0) {
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 = {};
@ -86,10 +96,10 @@
var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data);
if (keys.length === 0) {
fmEl.innerHTML = '<p class="fm-empty">No front matter.</p>';
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
return;
}
var html = '<dl class="fm-list">';
var html = '<dl class="md-fm__list">';
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
var v = parsed.data[k];
@ -102,20 +112,27 @@
fmEl.innerHTML = html;
}
// ── TOC (table of contents) ─────────────────────────────────────────────
//
// Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style
// headings, build a flat hierarchical list, click jumps the editor to
// the heading's line. We track WYSIWYG vs markdown mode and route the
// scroll behaviour to whichever pane is visible.
// ── TOC (table of contents) ────────────────────────────────────────────
// ATX headings only; the body markdown drives the outline. Clicking
// a heading routes to whichever Toast UI pane is currently active
// (WYSIWYG or markdown preview).
function parseHeadings(content) {
var headings = [];
var lines = content.split('\n');
// Strip front matter so headings inside the envelope (e.g. comments)
// don't appear in the outline.
var parsed = parseFrontMatter(content);
var body = parsed.body;
var lines = body.split('\n');
var inFence = false;
for (var i = 0; i < lines.length; i++) {
var m = lines[i].match(/^(#{1,6})\s+(.+)$/);
var line = lines[i];
// Skip fenced code blocks — headings inside them aren't real.
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!m) continue;
var text = m[2].trim()
var text = m[2]
.replace(/\\(.)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
@ -137,8 +154,11 @@
var hs = ww.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < hs.length; i++) {
if (hs[i].textContent.trim() === heading.text) {
var top = hs[i].getBoundingClientRect().top - ww.getBoundingClientRect().top;
ww.scrollTop = top - 10;
var scroller = findScrollParent(hs[i]) || ww;
scroller.scrollTo({
top: hs[i].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(hs[i]);
return;
}
@ -146,56 +166,72 @@
} else {
var line = heading.lineIndex + 1;
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
// Find the matching heading in the live markdown preview
// (right column of split view). If preview is collapsed
// (markdown-only) this is a no-op.
var preview = els.mdPreview;
if (!preview) return;
if (preview) {
var phs = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var j = 0; j < phs.length; j++) {
if (phs[j].textContent.trim() === heading.text) {
var ptop = phs[j].getBoundingClientRect().top - preview.getBoundingClientRect().top;
preview.scrollTop = ptop - 10;
var pscroller = findScrollParent(phs[j]) || preview;
pscroller.scrollTo({
top: phs[j].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(phs[j]);
return;
}
}
}
} catch (e) { /* swallow; click was best-effort */ }
}
} catch (_e) { /* swallow; click was best-effort */ }
}
function findScrollParent(el) {
var cur = el.parentElement;
while (cur) {
var s = getComputedStyle(cur);
if (/(auto|scroll)/.test(s.overflowY)) return cur;
cur = cur.parentElement;
}
return null;
}
function flashHeading(el) {
if (!el) return;
el.style.transition = 'background-color 0.3s ease';
el.style.backgroundColor = 'var(--primary-light)';
setTimeout(function () {
el.style.backgroundColor = '';
setTimeout(function () { el.style.transition = ''; }, 300);
}, 1200);
el.classList.add('md-toc__flash');
setTimeout(function () { el.classList.remove('md-toc__flash'); }, 900);
}
function renderToc(tocEl, content, editor) {
if (!tocEl) return;
if (!content || !content.trim()) {
tocEl.innerHTML = '<p class="md-toc__empty">Empty file.</p>';
return;
}
var headings = parseHeadings(content);
if (!content.trim()) {
tocEl.innerHTML = '<p class="toc-empty">Empty file.</p>';
return;
}
if (headings.length === 0) {
tocEl.innerHTML = '<p class="toc-empty">No headings.</p>';
tocEl.innerHTML = '<p class="md-toc__empty">No headings yet.</p>';
return;
}
// Build a flat ordered list; CSS handles the visual indent.
var html = '<ul class="toc-list">';
// Build a flat list; CSS handles indentation. Using a flat list
// (rather than nested <ul>s) keeps the click target a clean,
// full-width row regardless of heading depth.
var html = '<ul class="md-toc__list">';
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
html += '<li class="toc-item toc-level-' + h.level + '" data-line="' + h.lineIndex + '" data-text="' + escapeHtml(h.text) + '">'
+ '<a href="#" tabindex="0">' + escapeHtml(h.text) + '</a></li>';
html += '<li class="md-toc__item md-toc__item--l' + h.level + '"'
+ ' data-line="' + h.lineIndex + '"'
+ ' data-text="' + escapeHtml(h.text) + '"'
+ ' title="' + escapeHtml(h.text) + '">'
+ escapeHtml(h.text)
+ '</li>';
}
html += '</ul>';
tocEl.innerHTML = html;
// One delegated click handler.
tocEl.querySelectorAll('.toc-item').forEach(function (li) {
li.addEventListener('click', function (e) {
e.preventDefault();
tocEl.querySelectorAll('.md-toc__item').forEach(function (li) {
li.addEventListener('click', function () {
var idx = parseInt(li.dataset.line, 10);
var text = li.dataset.text;
scrollEditorToHeading(editor, { text: text, lineIndex: idx });
@ -203,7 +239,6 @@
});
}
// Light debounce so TOC doesn't rebuild on every keystroke.
function debounce(fn, ms) {
var t;
return function () {
@ -213,17 +248,15 @@
};
}
// ── Save (server + FS-API) ──────────────────────────────────────────────
// ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) {
// FS-API mode: write via the local file handle.
if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
// Server mode: PUT the new bytes.
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
@ -231,9 +264,7 @@
body: content,
credentials: 'same-origin'
});
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
@ -255,7 +286,6 @@
+ 'Toast UI Editor isn\'t bundled in this build.</div>';
return;
}
dispose();
// Read content.
@ -271,85 +301,93 @@
return;
}
// Build the markdown plugin's DOM:
// ┌──────────────────────────────────────────────────┐
// │ toolbar (Save, ● modified, status, source hint) │
// ├──────────────────────────────────┬───────────────┤
// │ editor (Toast UI) │ TOC pane │
// └──────────────────────────────────┴───────────────┘
// Wipe the container and install a single shell child. The
// shell is a CSS Grid with two rows (toolbar | body) and two
// columns (editor | sidebar). Setting these on a dedicated
// child — rather than touching previewBody's class — keeps
// the outer flex layout intact (previewBody itself is the
// flex item that fills the preview pane).
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
var shell = document.createElement('div');
shell.className = 'md-shell';
shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
container.appendChild(shell);
// Toolbar (row 1, spans both columns).
var toolbar = document.createElement('div');
toolbar.className = 'md-toolbar';
toolbar.className = 'md-shell__toolbar';
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary';
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
var dirty = document.createElement('span');
dirty.className = 'md-toolbar__dirty';
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var status = document.createElement('span');
status.className = 'md-toolbar__status';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceHint = document.createElement('span');
sourceHint.className = 'md-toolbar__source';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (node.zipParentId != null) {
sourceHint.textContent = 'read-only (inside zip)';
sourceEl.textContent = 'read-only (zip)';
} else if (node.handle) {
sourceHint.textContent = 'local';
sourceEl.textContent = 'local';
} else if (node.url) {
sourceHint.textContent = 'server';
sourceEl.textContent = 'server';
}
toolbar.appendChild(saveBtn);
toolbar.appendChild(dirty);
toolbar.appendChild(status);
toolbar.appendChild(sourceHint);
container.appendChild(toolbar);
var split = document.createElement('div');
split.className = 'md-split';
container.appendChild(split);
toolbar.appendChild(dirtyEl);
toolbar.appendChild(statusEl);
toolbar.appendChild(sourceEl);
shell.appendChild(toolbar);
// Editor host (row 2, col 1).
var editorHost = document.createElement('div');
editorHost.className = 'md-editor-host';
split.appendChild(editorHost);
editorHost.className = 'md-shell__editor';
shell.appendChild(editorHost);
var tocResizer = document.createElement('div');
tocResizer.className = 'pane-resizer md-toc-resizer';
tocResizer.setAttribute('aria-hidden', 'true');
split.appendChild(tocResizer);
// Resizer between editor and sidebar (row 2, between cols).
var resizer = document.createElement('div');
resizer.className = 'md-shell__resizer';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', 'Resize outline pane');
resizer.tabIndex = 0;
shell.appendChild(resizer);
var tocPane = document.createElement('div');
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);
// Sidebar (row 2, col 2). Its own grid: outline (1fr) + front-matter (auto).
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
shell.appendChild(sidebar);
var tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div');
tocHeader.className = 'md-toc-pane__header';
tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div');
tocBody.className = 'md-toc-pane__body';
tocBody.innerHTML = '<p class="toc-empty">Loading…</p>';
tocPane.appendChild(tocHeader);
tocPane.appendChild(tocBody);
split.appendChild(tocPane);
tocBody.className = 'md-side__body md-toc__body';
tocSection.appendChild(tocHeader);
tocSection.appendChild(tocBody);
sidebar.appendChild(tocSection);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
fmHeader.textContent = 'Front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
var initialHash = await hashContent(text);
var editor = new window.toastui.Editor({
el: editorHost,
@ -373,7 +411,8 @@
dirty: false,
node: node,
hash: initialHash,
tocEl: tocBody
tocEl: tocBody,
fmEl: fmBody
};
var writable = canSave(node);
@ -385,73 +424,89 @@
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
// TOC pane resizer — drag horizontally. Stays in-memory only;
// refresh resets to the default 220px.
// ── Resizer ────────────────────────────────────────────────────────
// Drag the resizer to grow/shrink the sidebar. Updates the
// container's grid-template-columns so the editor + sidebar
// both reflow cleanly.
(function () {
var dragging = false;
var startX = 0;
var startWidth = 0;
tocResizer.addEventListener('mousedown', function (e) {
var startW = 0;
function onMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
// Dragging right shrinks the sidebar; left grows it.
// (The sidebar is on the right; user expectation matches.)
var w = startW - dx;
w = Math.max(TOC_MIN_WIDTH, Math.min(TOC_MAX_WIDTH, w));
lastTocWidth = w;
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
e.preventDefault();
}
function onUp() {
dragging = false;
resizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
resizer.addEventListener('mousedown', function (e) {
dragging = true;
tocResizer.classList.add('is-dragging');
resizer.classList.add('is-dragging');
startX = e.clientX;
startWidth = tocPane.getBoundingClientRect().width;
startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
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');
// Keyboard: ← / → adjust by 24px.
resizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var step = e.key === 'ArrowLeft' ? 24 : -24;
var w = Math.max(TOC_MIN_WIDTH,
Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
lastTocWidth = w;
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
});
})();
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) {
currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty || !writable;
dirty.textContent = isDirty ? '● modified' : '';
dirtyEl.textContent = isDirty ? '● modified' : '';
}
var updateOnChange = debounce(async function () {
var onChange = debounce(async function () {
var current = editor.getMarkdown();
var h = await hashContent(current);
markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor);
renderFrontMatter(fmBody, current);
}, 250);
editor.on('change', onChange);
editor.on('change', updateOnChange);
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown();
try {
status.textContent = 'Saving…';
statusEl.textContent = 'Saving…';
await saveContent(node, content);
currentInstance.hash = await hashContent(content);
markDirty(false);
var now = new Date();
status.textContent = 'Saved ' + now.toLocaleTimeString();
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) {
status.textContent = 'Save failed: ' + (e.message || e);
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
}
}
saveBtn.addEventListener('click', save);
// Ctrl+S / Cmd+S inside the editor → save.
container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();

View file

@ -90,17 +90,18 @@ test.describe('Browse', () => {
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
// Markdown plugin DOM mounts: toolbar, editor host, TOC pane.
await expect(page.locator('.md-toolbar')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.md-editor-host')).toBeVisible();
await expect(page.locator('.md-toc-pane')).toBeVisible();
// Markdown plugin DOM mounts: shell, toolbar, editor host, sidebar.
await expect(page.locator('.md-shell')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.md-shell__toolbar')).toBeVisible();
await expect(page.locator('.md-shell__editor')).toBeVisible();
await expect(page.locator('.md-shell__sidebar')).toBeVisible();
// TOC enumerates the three headings.
await page.waitForSelector('.toc-list li', { timeout: 10000 });
const tocItems = await page.locator('.toc-list li a').allTextContents();
// Outline lists the three headings.
await page.waitForSelector('.md-toc__list .md-toc__item', { timeout: 10000 });
const tocItems = await page.locator('.md-toc__list .md-toc__item').allTextContents();
expect(tocItems).toEqual(['Title', 'Section One', 'Subsection']);
// Source hint reflects local FS-API mode.
await expect(page.locator('.md-toolbar__source')).toHaveText(/local/i);
await expect(page.locator('.md-shell__source')).toHaveText(/local/i);
});
});