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); } .status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */ /* ── 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 + /* Toolbar spans both columns; subtle row above the editor. */
status + source hint. Sticks to the top of the pane body. */ .md-shell__toolbar {
.md-toolbar { grid-area: toolbar;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: 0.85rem; font-size: 0.85rem;
} }
.md-shell__dirty {
.md-toolbar__dirty {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
min-width: 6rem; min-width: 5.5rem;
} }
.md-shell__status {
.md-toolbar__status {
flex: 1; flex: 1;
text-align: right; text-align: right;
color: var(--text-muted); color: var(--text-muted);
@ -389,8 +403,7 @@ html, body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.md-shell__source {
.md-toolbar__source {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.75rem; font-size: 0.75rem;
font-style: italic; font-style: italic;
@ -401,97 +414,158 @@ html, body {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
/* Editor + TOC two-pane split inside the preview body. */ /* Editor host: a single grid cell with overflow:hidden so Toast UI's
.md-split { internal scrollers handle the content. */
flex: 1; .md-shell__editor {
display: flex; grid-area: editor;
flex-direction: row;
min-height: 0;
overflow: hidden;
}
.md-editor-host {
flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; 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 /* Resizer sits on the grid border between editor (col 1) and sidebar
resize it (yet) kept simple in v1. */ (col 2). Positioned absolutely over the boundary so it doesn't take
.md-toc-pane { up a grid track itself. */
width: 220px; .md-shell__resizer {
flex-shrink: 0; 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); border-left: 1px solid var(--border);
background: var(--bg); 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; padding: 0.35rem 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-size: 0.75rem; font-size: 0.72rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--text-muted); color: var(--text-muted);
flex-shrink: 0;
} }
.md-side__body {
.md-toc-pane__body {
flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0.4rem 0; min-height: 0;
padding: 0.3rem 0;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; line-height: 1.45;
} }
.toc-empty { /* ── Outline list ───────────────────────────────────────────────────────── */
.md-toc__empty {
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.82rem;
} }
.md-toc__list {
.toc-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.md-toc__item {
.toc-item {
margin: 0; margin: 0;
} padding: 0.22rem 0.75rem;
.toc-item a {
display: block;
padding: 0.2rem 0.75rem;
text-decoration: none;
color: var(--text); color: var(--text);
cursor: pointer;
border-left: 2px solid transparent; 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;
} }
.md-toc__item:hover {
.toc-item a:hover { background: var(--bg-secondary);
background: var(--bg-hover);
border-left-color: var(--primary); border-left-color: var(--primary);
} }
.md-toc__item:focus-visible {
.toc-item a:focus-visible {
outline: 2px solid var(--primary); outline: 2px solid var(--primary);
outline-offset: -2px; 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; } /* Flash on click applied to the heading element in the editor pane.
.toc-level-2 a { padding-left: 1.4rem; } The class is scoped to .md-toc__flash so it doesn't paint outside
.toc-level-3 a { padding-left: 2.05rem; } this plugin. */
.toc-level-4 a { padding-left: 2.7rem; color: var(--text-muted); } .md-toc__flash {
.toc-level-5 a { padding-left: 3.35rem; color: var(--text-muted); font-size: 0.8rem; } background-color: rgba(95, 168, 224, 0.25) !important;
.toc-level-6 a { padding-left: 4rem; color: var(--text-muted); font-size: 0.8rem; } 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 ────────────────────────────────────────────────────────── */
.sort-control { .sort-control {
@ -523,51 +597,5 @@ html, body {
outline-offset: -1px; outline-offset: -1px;
} }
/* Front-matter display section inside the TOC pane. */ /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
.md-fm-section { by the .md-shell BEM block above. */
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

@ -1,30 +1,45 @@
// preview-markdown.js — markdown plugin for the browse preview pane. // 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) // Layout (CSS Grid):
// and is available synchronously as window.toastui by the time this // ┌─────────────────────────────────────────────────────────────────┐
// module runs. // │ 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 () { (function () {
'use strict'; 'use strict';
if (!window.app || !window.app.modules) return; 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) { function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .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) { async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null; if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text); var enc = new TextEncoder().encode(text);
@ -44,21 +59,16 @@
currentInstance = null; currentInstance = null;
} }
// ── Front matter ─────────────────────────────────────────────────────── // ── Front matter ────────────────────────────────────────────────────────
// // Lightweight YAML front-matter parser. Same envelope as mdedit's:
// Lightweight YAML-front-matter parser. Same shape as mdedit's // `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
// 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) { function parseFrontMatter(content) {
if (!content || !content.startsWith('---\n')) { if (!content || content.indexOf('---\n') !== 0) {
return { data: {}, body: content || '' }; return { data: {}, body: content || '' };
} }
var endIdx = content.indexOf('\n---\n', 4); var endIdx = content.indexOf('\n---\n', 4);
if (endIdx === -1) return { data: {}, body: content }; if (endIdx === -1) return { data: {}, body: content };
var fmText = content.substring(4, endIdx); var fmText = content.substring(4, endIdx);
var body = content.substring(endIdx + 5); var body = content.substring(endIdx + 5);
var data = {}; var data = {};
@ -86,10 +96,10 @@
var parsed = parseFrontMatter(content); var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data); var keys = Object.keys(parsed.data);
if (keys.length === 0) { 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; return;
} }
var html = '<dl class="fm-list">'; var html = '<dl class="md-fm__list">';
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
var k = keys[i]; var k = keys[i];
var v = parsed.data[k]; var v = parsed.data[k];
@ -102,20 +112,27 @@
fmEl.innerHTML = html; fmEl.innerHTML = html;
} }
// ── TOC (table of contents) ───────────────────────────────────────────── // ── TOC (table of contents) ────────────────────────────────────────────
// // ATX headings only; the body markdown drives the outline. Clicking
// Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style // a heading routes to whichever Toast UI pane is currently active
// headings, build a flat hierarchical list, click jumps the editor to // (WYSIWYG or markdown preview).
// the heading's line. We track WYSIWYG vs markdown mode and route the
// scroll behaviour to whichever pane is visible.
function parseHeadings(content) { function parseHeadings(content) {
var headings = []; 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++) { 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; if (!m) continue;
var text = m[2].trim() var text = m[2]
.replace(/\\(.)/g, '$1') .replace(/\\(.)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1') .replace(/\*(.*?)\*/g, '$1')
@ -137,8 +154,11 @@
var hs = ww.querySelectorAll('h1, h2, h3, h4, h5, h6'); var hs = ww.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < hs.length; i++) { for (var i = 0; i < hs.length; i++) {
if (hs[i].textContent.trim() === heading.text) { if (hs[i].textContent.trim() === heading.text) {
var top = hs[i].getBoundingClientRect().top - ww.getBoundingClientRect().top; var scroller = findScrollParent(hs[i]) || ww;
ww.scrollTop = top - 10; scroller.scrollTo({
top: hs[i].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(hs[i]); flashHeading(hs[i]);
return; return;
} }
@ -146,56 +166,72 @@
} else { } else {
var line = heading.lineIndex + 1; var line = heading.lineIndex + 1;
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ } 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; var preview = els.mdPreview;
if (!preview) return; if (preview) {
var phs = preview.querySelectorAll('h1, h2, h3, h4, h5, h6'); var phs = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var j = 0; j < phs.length; j++) { for (var j = 0; j < phs.length; j++) {
if (phs[j].textContent.trim() === heading.text) { if (phs[j].textContent.trim() === heading.text) {
var ptop = phs[j].getBoundingClientRect().top - preview.getBoundingClientRect().top; var pscroller = findScrollParent(phs[j]) || preview;
preview.scrollTop = ptop - 10; pscroller.scrollTo({
top: phs[j].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(phs[j]); flashHeading(phs[j]);
return; 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) { function flashHeading(el) {
if (!el) return; if (!el) return;
el.style.transition = 'background-color 0.3s ease'; el.classList.add('md-toc__flash');
el.style.backgroundColor = 'var(--primary-light)'; setTimeout(function () { el.classList.remove('md-toc__flash'); }, 900);
setTimeout(function () {
el.style.backgroundColor = '';
setTimeout(function () { el.style.transition = ''; }, 300);
}, 1200);
} }
function renderToc(tocEl, content, editor) { function renderToc(tocEl, content, editor) {
if (!tocEl) return; if (!tocEl) return;
if (!content || !content.trim()) {
tocEl.innerHTML = '<p class="md-toc__empty">Empty file.</p>';
return;
}
var headings = parseHeadings(content); var headings = parseHeadings(content);
if (!content.trim()) {
tocEl.innerHTML = '<p class="toc-empty">Empty file.</p>';
return;
}
if (headings.length === 0) { 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; return;
} }
// Build a flat ordered list; CSS handles the visual indent. // Build a flat list; CSS handles indentation. Using a flat list
var html = '<ul class="toc-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++) { for (var i = 0; i < headings.length; i++) {
var h = headings[i]; var h = headings[i];
html += '<li class="toc-item toc-level-' + h.level + '" data-line="' + h.lineIndex + '" data-text="' + escapeHtml(h.text) + '">' html += '<li class="md-toc__item md-toc__item--l' + h.level + '"'
+ '<a href="#" tabindex="0">' + escapeHtml(h.text) + '</a></li>'; + ' data-line="' + h.lineIndex + '"'
+ ' data-text="' + escapeHtml(h.text) + '"'
+ ' title="' + escapeHtml(h.text) + '">'
+ escapeHtml(h.text)
+ '</li>';
} }
html += '</ul>'; html += '</ul>';
tocEl.innerHTML = html; tocEl.innerHTML = html;
tocEl.querySelectorAll('.md-toc__item').forEach(function (li) {
// One delegated click handler. li.addEventListener('click', function () {
tocEl.querySelectorAll('.toc-item').forEach(function (li) {
li.addEventListener('click', function (e) {
e.preventDefault();
var idx = parseInt(li.dataset.line, 10); var idx = parseInt(li.dataset.line, 10);
var text = li.dataset.text; var text = li.dataset.text;
scrollEditorToHeading(editor, { text: text, lineIndex: idx }); scrollEditorToHeading(editor, { text: text, lineIndex: idx });
@ -203,7 +239,6 @@
}); });
} }
// Light debounce so TOC doesn't rebuild on every keystroke.
function debounce(fn, ms) { function debounce(fn, ms) {
var t; var t;
return function () { return function () {
@ -213,17 +248,15 @@
}; };
} }
// ── Save (server + FS-API) ────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) { async function saveContent(node, content) {
// FS-API mode: write via the local file handle.
if (node.handle && typeof node.handle.createWritable === 'function') { if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable(); var writable = await node.handle.createWritable();
await writable.write(content); await writable.write(content);
await writable.close(); await writable.close();
return; return;
} }
// Server mode: PUT the new bytes.
if (node.url && window.app.state.source === 'server') { if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, { var resp = await fetch(node.url, {
method: 'PUT', method: 'PUT',
@ -231,9 +264,7 @@
body: content, body: content,
credentials: 'same-origin' credentials: 'same-origin'
}); });
if (!resp.ok) { if (!resp.ok) throw new Error('HTTP ' + resp.status);
throw new Error('HTTP ' + resp.status);
}
return; return;
} }
throw new Error('No write target for this file (read-only source).'); 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>'; + 'Toast UI Editor isn\'t bundled in this build.</div>';
return; return;
} }
dispose(); dispose();
// Read content. // Read content.
@ -271,85 +301,93 @@
return; return;
} }
// Build the markdown plugin's DOM: // Wipe the container and install a single shell child. The
// ┌──────────────────────────────────────────────────┐ // shell is a CSS Grid with two rows (toolbar | body) and two
// │ toolbar (Save, ● modified, status, source hint) │ // columns (editor | sidebar). Setting these on a dedicated
// ├──────────────────────────────────┬───────────────┤ // child — rather than touching previewBody's class — keeps
// │ editor (Toast UI) │ TOC pane │ // the outer flex layout intact (previewBody itself is the
// └──────────────────────────────────┴───────────────┘ // flex item that fills the preview pane).
container.innerHTML = ''; container.innerHTML = '';
container.style.display = 'flex'; var shell = document.createElement('div');
container.style.flexDirection = 'column'; shell.className = 'md-shell';
shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
container.appendChild(shell);
// Toolbar (row 1, spans both columns).
var toolbar = document.createElement('div'); var toolbar = document.createElement('div');
toolbar.className = 'md-toolbar'; toolbar.className = 'md-shell__toolbar';
var saveBtn = document.createElement('button'); 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.type = 'button';
saveBtn.textContent = 'Save'; saveBtn.textContent = 'Save';
saveBtn.disabled = true; saveBtn.disabled = true;
var dirty = document.createElement('span'); var dirtyEl = document.createElement('span');
dirty.className = 'md-toolbar__dirty'; dirtyEl.className = 'md-shell__dirty';
var status = document.createElement('span'); var statusEl = document.createElement('span');
status.className = 'md-toolbar__status'; statusEl.className = 'md-shell__status';
var sourceHint = document.createElement('span'); var sourceEl = document.createElement('span');
sourceHint.className = 'md-toolbar__source'; sourceEl.className = 'md-shell__source';
if (node.zipParentId != null) { if (node.zipParentId != null) {
sourceHint.textContent = 'read-only (inside zip)'; sourceEl.textContent = 'read-only (zip)';
} else if (node.handle) { } else if (node.handle) {
sourceHint.textContent = 'local'; sourceEl.textContent = 'local';
} else if (node.url) { } else if (node.url) {
sourceHint.textContent = 'server'; sourceEl.textContent = 'server';
} }
toolbar.appendChild(saveBtn); toolbar.appendChild(saveBtn);
toolbar.appendChild(dirty); toolbar.appendChild(dirtyEl);
toolbar.appendChild(status); toolbar.appendChild(statusEl);
toolbar.appendChild(sourceHint); toolbar.appendChild(sourceEl);
container.appendChild(toolbar); shell.appendChild(toolbar);
var split = document.createElement('div');
split.className = 'md-split';
container.appendChild(split);
// Editor host (row 2, col 1).
var editorHost = document.createElement('div'); var editorHost = document.createElement('div');
editorHost.className = 'md-editor-host'; editorHost.className = 'md-shell__editor';
split.appendChild(editorHost); shell.appendChild(editorHost);
var tocResizer = document.createElement('div'); // Resizer between editor and sidebar (row 2, between cols).
tocResizer.className = 'pane-resizer md-toc-resizer'; var resizer = document.createElement('div');
tocResizer.setAttribute('aria-hidden', 'true'); resizer.className = 'md-shell__resizer';
split.appendChild(tocResizer); 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'); // Sidebar (row 2, col 2). Its own grid: outline (1fr) + front-matter (auto).
tocPane.className = 'md-toc-pane'; var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
// Front-matter section above TOC, read-only display. shell.appendChild(sidebar);
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 tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div'); var tocHeader = document.createElement('div');
tocHeader.className = 'md-toc-pane__header'; tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline'; tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div'); var tocBody = document.createElement('div');
tocBody.className = 'md-toc-pane__body'; tocBody.className = 'md-side__body md-toc__body';
tocBody.innerHTML = '<p class="toc-empty">Loading…</p>'; tocSection.appendChild(tocHeader);
tocPane.appendChild(tocHeader); tocSection.appendChild(tocBody);
tocPane.appendChild(tocBody); sidebar.appendChild(tocSection);
split.appendChild(tocPane);
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 initialHash = await hashContent(text);
var editor = new window.toastui.Editor({ var editor = new window.toastui.Editor({
el: editorHost, el: editorHost,
@ -373,7 +411,8 @@
dirty: false, dirty: false,
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody tocEl: tocBody,
fmEl: fmBody
}; };
var writable = canSave(node); var writable = canSave(node);
@ -385,73 +424,89 @@
renderToc(tocBody, text, editor); renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text); renderFrontMatter(fmBody, text);
// TOC pane resizer — drag horizontally. Stays in-memory only; // ── Resizer ────────────────────────────────────────────────────────
// refresh resets to the default 220px. // Drag the resizer to grow/shrink the sidebar. Updates the
// container's grid-template-columns so the editor + sidebar
// both reflow cleanly.
(function () { (function () {
var dragging = false; var dragging = false;
var startX = 0; var startX = 0;
var startWidth = 0; var startW = 0;
tocResizer.addEventListener('mousedown', function (e) { 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; dragging = true;
tocResizer.classList.add('is-dragging'); resizer.classList.add('is-dragging');
startX = e.clientX; startX = e.clientX;
startWidth = tocPane.getBoundingClientRect().width; startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault(); e.preventDefault();
}); });
document.addEventListener('mousemove', function (e) { // Keyboard: ← / → adjust by 24px.
if (!dragging) return; resizer.addEventListener('keydown', function (e) {
// Drag left to grow the TOC, right to shrink it. if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
var dx = e.clientX - startX; e.preventDefault();
var w = Math.max(150, Math.min(window.innerWidth * 0.4, startWidth - dx)); var step = e.key === 'ArrowLeft' ? 24 : -24;
tocPane.style.width = w + 'px'; var w = Math.max(TOC_MIN_WIDTH,
}); Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
document.addEventListener('mouseup', function () { lastTocWidth = w;
if (!dragging) return; shell.style.gridTemplateColumns = '1fr ' + w + 'px';
dragging = false;
tocResizer.classList.remove('is-dragging');
}); });
})(); })();
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) { function markDirty(isDirty) {
currentInstance.dirty = isDirty; currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty || !writable; 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 current = editor.getMarkdown();
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); renderFrontMatter(fmBody, current);
}, 250); }, 250);
editor.on('change', onChange);
editor.on('change', updateOnChange); // ── Save ───────────────────────────────────────────────────────────
async function save() { async function save() {
if (!currentInstance.dirty || !writable) return; if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown(); var content = editor.getMarkdown();
try { try {
status.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); await saveContent(node, content);
currentInstance.hash = await hashContent(content); currentInstance.hash = await hashContent(content);
markDirty(false); markDirty(false);
var now = new Date(); statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
status.textContent = 'Saved ' + now.toLocaleTimeString();
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success'); window.zddc.toast('Saved ' + node.name, 'success');
} }
} catch (e) { } catch (e) {
status.textContent = 'Save failed: ' + (e.message || e); statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error'); window.zddc.toast('Save failed: ' + (e.message || e), 'error');
} }
} }
} }
saveBtn.addEventListener('click', save); saveBtn.addEventListener('click', save);
// Ctrl+S / Cmd+S inside the editor → save.
container.addEventListener('keydown', function (e) { container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); e.preventDefault();

View file

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