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 = '