From cb2cf1ebe3d2777fe0ac2792d0b53f54658a2699 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 11:30:33 -0500 Subject: [PATCH] fix(browse): re-implement markdown editor layout on CSS Grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/css/tree.css | 248 +++++++++++++---------- browse/js/preview-markdown.js | 365 +++++++++++++++++++--------------- tests/browse.spec.js | 17 +- 3 files changed, 357 insertions(+), 273 deletions(-) diff --git a/browse/css/tree.css b/browse/css/tree.css index e05690c..46a29f7 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -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. */ diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 3178e13..b20f073 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); } - 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 = '

No front matter.

'; + fmEl.innerHTML = '

No front matter.

'; return; } - var html = '
'; + var html = '
'; 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; - 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; - flashHeading(phs[j]); - 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 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 = '

Empty file.

'; + return; + } var headings = parseHeadings(content); - if (!content.trim()) { - tocEl.innerHTML = '

Empty file.

'; - return; - } if (headings.length === 0) { - tocEl.innerHTML = '

No headings.

'; + tocEl.innerHTML = '

No headings yet.

'; return; } - // Build a flat ordered list; CSS handles the visual indent. - var html = '
    '; + // Build a flat list; CSS handles indentation. Using a flat list + // (rather than nested
      s) keeps the click target a clean, + // full-width row regardless of heading depth. + var html = '
        '; for (var i = 0; i < headings.length; i++) { var h = headings[i]; - html += '
      • ' - + '' + escapeHtml(h.text) + '
      • '; + html += '
      • ' + + escapeHtml(h.text) + + '
      • '; } html += '
      '; 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.'; 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 = '

      Loading…

      '; - 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(); diff --git a/tests/browse.spec.js b/tests/browse.spec.js index cbdd169..c3fda3a 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -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); }); });