diff --git a/browse/css/tree.css b/browse/css/tree.css index 8f2381c..7944993 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -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; } diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 34cafd9..76e5398 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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 = '

Empty file.

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

No headings.

'; + return; + } + // Build a flat ordered list; CSS handles the visual indent. + var html = ''; + 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 = '

Loading…

'; + 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(); } diff --git a/tests/browse.spec.js b/tests/browse.spec.js index e458182..cbdd169 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -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); + }); }); diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 9864999..54bba35 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1155,7 +1155,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-beta · 2026-05-10 · diamond-flame-kettle + v0.0.17-alpha · 2026-05-11 00:02:27 · d779814-dirty