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:
parent
d5638e9697
commit
cb2cf1ebe3
3 changed files with 357 additions and 273 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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, '>').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 = '<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;
|
||||
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 = '<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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue