feat(browse): TOC pane + FS-API saves in the markdown plugin
Completes the markdown plugin's deferred v2 items:
1. TOC pane
A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.
Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."
2. FS-API writes
Saves now route to whichever source the file came from:
- node.handle + createWritable available → FileSystemWritableFileStream
(local folder picker). The user's chosen file gets overwritten
via the browser's File System Access API.
- node.url + server source → PUT to the server URL (as before).
- zip-virtual file → save disabled (no writable stream from JSZip).
- Anything else → save disabled with a tooltip.
Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.
Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.
CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.
A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d77981407c
commit
0b382716e3
4 changed files with 396 additions and 54 deletions
|
|
@ -342,3 +342,137 @@ html, body {
|
|||
|
||||
.status-bar.is-error { color: var(--danger); }
|
||||
.status-bar.is-info { color: var(--text); }
|
||||
|
||||
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
|
||||
|
||||
/* Editor toolbar (above the editor+TOC split): Save + dirty marker +
|
||||
status + source hint. Sticks to the top of the pane body. */
|
||||
.md-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 {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.md-toolbar__status {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.md-toolbar__source {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
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;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-toc-pane__header {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.md-toc-pane__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toc-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-item a {
|
||||
display: block;
|
||||
padding: 0.2rem 0.75rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.toc-item a:hover {
|
||||
background: var(--bg-hover);
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.toc-item a:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
// 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. Save (Ctrl+S) writes back via PUT
|
||||
// when the file came from a server URL; FS-API and zip-virtual files
|
||||
// are read-only for now (toolbar shows a hint).
|
||||
// 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 loaded from mdedit's bundled vendor file in the
|
||||
// browse build (see browse/build.sh). window.toastui is available
|
||||
// synchronously when this module runs.
|
||||
// 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.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -17,11 +21,10 @@
|
|||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
var currentInstance = null; // { editor, container, dirty, node, hash }
|
||||
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl }
|
||||
|
||||
// Compute SHA-256 hex of a string for a quick "is this content
|
||||
// different from what was loaded?" check. Used to decide whether
|
||||
// the save button should be active. Not used for integrity.
|
||||
// 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);
|
||||
|
|
@ -41,6 +44,152 @@
|
|||
currentInstance = null;
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
function parseHeadings(content) {
|
||||
var headings = [];
|
||||
var lines = content.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var m = lines[i].match(/^(#{1,6})\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
var text = m[2].trim()
|
||||
.replace(/\\(.)/g, '$1')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.trim();
|
||||
headings.push({ level: m[1].length, text: text, lineIndex: i });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function scrollEditorToHeading(editor, heading) {
|
||||
try {
|
||||
var els = editor.getEditorElements();
|
||||
if (editor.isWysiwygMode && editor.isWysiwygMode()) {
|
||||
var ww = els.wwEditor;
|
||||
if (!ww) return;
|
||||
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;
|
||||
flashHeading(hs[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var line = heading.lineIndex + 1;
|
||||
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* swallow; click was best-effort */ }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function renderToc(tocEl, content, editor) {
|
||||
if (!tocEl) 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>';
|
||||
return;
|
||||
}
|
||||
// Build a flat ordered list; CSS handles the visual indent.
|
||||
var html = '<ul class="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 += '</ul>';
|
||||
tocEl.innerHTML = html;
|
||||
|
||||
// One delegated click handler.
|
||||
tocEl.querySelectorAll('.toc-item').forEach(function (li) {
|
||||
li.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var idx = parseInt(li.dataset.line, 10);
|
||||
var text = li.dataset.text;
|
||||
scrollEditorToHeading(editor, { text: text, lineIndex: idx });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Light debounce so TOC doesn't rebuild on every keystroke.
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function () {
|
||||
clearTimeout(t);
|
||||
var args = arguments, self = this;
|
||||
t = setTimeout(function () { fn.apply(self, args); }, ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Save (server + FS-API) ──────────────────────────────────────────────
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
}
|
||||
|
||||
function canSave(node) {
|
||||
if (node.zipParentId != null) return false;
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||
if (node.url && window.app.state.source === 'server') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Mount ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function render(node, container, ctx) {
|
||||
if (typeof window.toastui === 'undefined') {
|
||||
container.innerHTML =
|
||||
|
|
@ -49,10 +198,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Tear down any previous markdown instance (single-file model).
|
||||
dispose();
|
||||
|
||||
// Read the file content.
|
||||
// Read content.
|
||||
var text;
|
||||
try {
|
||||
var buf = await ctx.getArrayBuffer(node);
|
||||
|
|
@ -66,45 +214,65 @@
|
|||
}
|
||||
|
||||
// Build the markdown plugin's DOM:
|
||||
// ┌──────────────────────────────────┐
|
||||
// │ toolbar (Save, dirty marker) │
|
||||
// ├──────────────────────────────────┤
|
||||
// │ Toast UI editor │
|
||||
// └──────────────────────────────────┘
|
||||
//
|
||||
// TOC pane is deferred — a near-term iteration can split this
|
||||
// into editor | toc once the simpler form is exercised.
|
||||
// ┌──────────────────────────────────────────────────┐
|
||||
// │ toolbar (Save, ● modified, status, source hint) │
|
||||
// ├──────────────────────────────────┬───────────────┤
|
||||
// │ editor (Toast UI) │ TOC pane │
|
||||
// └──────────────────────────────────┴───────────────┘
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
|
||||
var toolbar = document.createElement('div');
|
||||
toolbar.className = 'md-toolbar';
|
||||
toolbar.style.cssText = '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;';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-sm btn-primary';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = true; // enabled when content changes
|
||||
saveBtn.disabled = true;
|
||||
|
||||
var dirty = document.createElement('span');
|
||||
dirty.style.cssText = 'color:var(--text-muted);font-size:0.85rem;';
|
||||
dirty.textContent = '';
|
||||
dirty.className = 'md-toolbar__dirty';
|
||||
|
||||
var status = document.createElement('span');
|
||||
status.style.cssText = 'flex:1;text-align:right;color:var(--text-muted);font-size:0.85rem;';
|
||||
status.className = 'md-toolbar__status';
|
||||
|
||||
var sourceHint = document.createElement('span');
|
||||
sourceHint.className = 'md-toolbar__source';
|
||||
if (node.zipParentId != null) {
|
||||
sourceHint.textContent = 'read-only (inside zip)';
|
||||
} else if (node.handle) {
|
||||
sourceHint.textContent = 'local';
|
||||
} else if (node.url) {
|
||||
sourceHint.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);
|
||||
|
||||
var editorHost = document.createElement('div');
|
||||
editorHost.style.cssText = 'flex:1;min-height:0;overflow:hidden;';
|
||||
container.appendChild(editorHost);
|
||||
editorHost.className = 'md-editor-host';
|
||||
split.appendChild(editorHost);
|
||||
|
||||
var tocPane = document.createElement('div');
|
||||
tocPane.className = 'md-toc-pane';
|
||||
var tocHeader = document.createElement('div');
|
||||
tocHeader.className = 'md-toc-pane__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);
|
||||
|
||||
var initialHash = await hashContent(text);
|
||||
var editor = new window.toastui.Editor({
|
||||
|
|
@ -123,47 +291,56 @@
|
|||
]
|
||||
});
|
||||
|
||||
currentInstance = { editor: editor, container: container, dirty: false, node: node, hash: initialHash };
|
||||
currentInstance = {
|
||||
editor: editor,
|
||||
container: container,
|
||||
dirty: false,
|
||||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody
|
||||
};
|
||||
|
||||
var writable = canSave(node);
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
}
|
||||
|
||||
renderToc(tocBody, text, editor);
|
||||
|
||||
function markDirty(isDirty) {
|
||||
currentInstance.dirty = isDirty;
|
||||
saveBtn.disabled = !isDirty;
|
||||
saveBtn.disabled = !isDirty || !writable;
|
||||
dirty.textContent = isDirty ? '● modified' : '';
|
||||
}
|
||||
|
||||
editor.on('change', async function () {
|
||||
var updateOnChange = debounce(async function () {
|
||||
var current = editor.getMarkdown();
|
||||
var h = await hashContent(current);
|
||||
markDirty(h !== currentInstance.hash);
|
||||
});
|
||||
renderToc(tocBody, current, editor);
|
||||
}, 250);
|
||||
|
||||
editor.on('change', updateOnChange);
|
||||
|
||||
async function save() {
|
||||
if (!currentInstance.dirty) return;
|
||||
if (!currentInstance.dirty || !writable) return;
|
||||
var content = editor.getMarkdown();
|
||||
// Read-only sources: zip-virtual, FS-API without write
|
||||
// permission. For now we only attempt PUT against server URLs;
|
||||
// FS-API saves can be wired in a later iteration via the
|
||||
// existing zddc-source polyfill.
|
||||
if (!node.url || window.app.state.source !== 'server') {
|
||||
status.textContent = 'Save not yet supported for this source.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
status.textContent = 'Saving…';
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
await saveContent(node, content);
|
||||
currentInstance.hash = await hashContent(content);
|
||||
markDirty(false);
|
||||
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
var now = new Date();
|
||||
status.textContent = 'Saved ' + now.toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Save failed: ' + (e.message || e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +348,7 @@
|
|||
|
||||
// Ctrl+S / Cmd+S inside the editor → save.
|
||||
container.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,4 +72,35 @@ test.describe('Browse', () => {
|
|||
await expect(page.locator('#previewTitle')).toHaveText(/notes\.txt/);
|
||||
await expect(page.locator('#previewPopout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking a .md file mounts the markdown editor with a TOC', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectory('notes', [
|
||||
{
|
||||
name: 'readme.md',
|
||||
content: '# Title\n\nIntro.\n\n## Section One\n\nText.\n\n### Subsection\n\nDeeper.',
|
||||
size: 100,
|
||||
},
|
||||
]);
|
||||
});
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
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();
|
||||
|
||||
// TOC enumerates the three headings.
|
||||
await page.waitForSelector('.toc-list li', { timeout: 10000 });
|
||||
const tocItems = await page.locator('.toc-list li a').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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1155,7 +1155,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · diamond-flame-kettle</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 00:02:27 · d779814-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue