diff --git a/Resources/web/reader.css b/Resources/web/reader.css index c57cf45..b473dc1 100644 --- a/Resources/web/reader.css +++ b/Resources/web/reader.css @@ -475,3 +475,85 @@ html.mindle-print-mode .mindle-mermaid { color: var(--muted); font-style: italic; } + +/* ------------------------------------------------------------------ + Diff-on-reload chrome (v1.6). + ------------------------------------------------------------------ */ + +.mindle-diff-chunk { + margin: 1.25em 0; + padding: 0.75em 1em; + border-left: 3px solid var(--accent); + background: var(--code-bg); + border-radius: 4px; +} + +.mindle-diff-removed, +.mindle-diff-added { + padding: 0.4em 0.75em; + border-radius: 3px; + margin: 0.25em 0; +} + +.mindle-diff-removed { + background: rgba(192, 57, 43, 0.10); + text-decoration: line-through; + text-decoration-color: rgba(192, 57, 43, 0.55); +} + +.mindle-diff-removed > :first-child { margin-top: 0; } +.mindle-diff-removed > :last-child { margin-bottom: 0; } + +.mindle-diff-added { + background: rgba(39, 174, 96, 0.10); +} + +.mindle-diff-added > :first-child { margin-top: 0; } +.mindle-diff-added > :last-child { margin-bottom: 0; } + +.mindle-diff-controls { + display: flex; + gap: 0.5em; + margin-top: 0.5em; +} + +.mindle-diff-controls button { + font: 11px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + font-weight: 500; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--rule); + background: var(--background); + color: var(--text); + cursor: pointer; +} +.mindle-diff-controls button:hover { + background: var(--surface); +} +.mindle-diff-controls button[data-mindle-diff-action="accept"]:hover { + border-color: rgba(39, 174, 96, 0.55); + color: rgb(39, 130, 80); +} +.mindle-diff-controls button[data-mindle-diff-action="reject"]:hover { + border-color: rgba(192, 57, 43, 0.55); + color: rgb(160, 50, 40); +} + +/* PDF export hides the diff UI entirely — printed output is the + accepted, current text. The chunk wrappers and removed blocks + collapse so only the .mindle-diff-added content survives. */ +html.mindle-print-mode .mindle-diff-chunk { + background: transparent !important; + border-left: none !important; + padding: 0 !important; + margin: 0 !important; +} +html.mindle-print-mode .mindle-diff-removed, +html.mindle-print-mode .mindle-diff-controls { + display: none !important; +} +html.mindle-print-mode .mindle-diff-added { + background: transparent !important; + padding: 0 !important; + margin: 0 !important; +} diff --git a/Resources/web/reader.html b/Resources/web/reader.html index 629f1ab..d30a297 100644 --- a/Resources/web/reader.html +++ b/Resources/web/reader.html @@ -12,6 +12,7 @@ +
diff --git a/Resources/web/reader.js b/Resources/web/reader.js index 6d5c8eb..8c6a8d9 100644 --- a/Resources/web/reader.js +++ b/Resources/web/reader.js @@ -206,23 +206,168 @@ return "```yaml\n" + m[1] + "\n```\n\n" + src.slice(m[0].length); } - window.mindleLoad = async function (markdown, preserveScroll) { + // -------- Diff state (v1.6) -------- + // Track the active doc's two-text state so accept/reject handlers can + // compute a new lastSynced or new current after a click. Updated on + // every mindleLoad call. + let activeCurrent = ""; + let activeLastSynced = ""; + let diffChunks = []; // {id, removeStart, removeEnd, addStart, addEnd, before, after} + + // Set by attachDiffHandlers when the user clicks ✓ Keep / ✗ Revert, + // so the next render after the round-trip can scroll to where the + // chunk used to be — visual confirmation that the action landed, + // instead of leaving the reader frozen at the previous scrollY. + let pendingScrollAfterRender = null; + + window.mindleLoad = async function (markdown, preserveScroll, lastSynced) { // Live-reload: capture scroll before swapping HTML so we can restore // it once the new render is laid out. Initial loads / tab switches // pass false and start at the top. const savedScroll = preserveScroll ? window.scrollY : 0; - renderedHTML = md.render(unwrapFrontmatter(markdown || "")); + activeCurrent = markdown || ""; + activeLastSynced = (lastSynced != null) ? String(lastSynced) : ""; + const showDiff = activeLastSynced && activeLastSynced !== activeCurrent && window.Diff; + if (showDiff) { + renderedHTML = md.render(buildDiffMarkdownSource(activeLastSynced, activeCurrent)); + } else { + renderedHTML = md.render(unwrapFrontmatter(activeCurrent)); + diffChunks = []; + } // Switching documents clears search state; annotations are replayed below. searchState = { query: "", current: 0, total: 0, matchSets: [] }; await applyAll(); + if (showDiff) attachDiffHandlers(); reportSearchResult(); if (preserveScroll) { // applyAll's mermaid pass can settle in another frame; restore on // the next paint so the position lands after layout finalizes. - requestAnimationFrame(() => window.scrollTo(0, savedScroll)); + requestAnimationFrame(() => { + const target = (pendingScrollAfterRender != null) + ? pendingScrollAfterRender + : savedScroll; + pendingScrollAfterRender = null; + window.scrollTo(0, target); + }); } }; + // -------- Diff render helpers -------- + + function buildDiffMarkdownSource(lastSynced, current) { + diffChunks = computeDiffChunks(lastSynced, current); + let source = ""; + let cursor = 0; // position in `current` + for (const chunk of diffChunks) { + if (cursor < chunk.addStart) { + source += current.slice(cursor, chunk.addStart); + } + source += renderChunkBlock(chunk); + cursor = chunk.addEnd; + } + if (cursor < current.length) { + source += current.slice(cursor); + } + return unwrapFrontmatter(source); + } + + function computeDiffChunks(lastSynced, current) { + const parts = window.Diff.diffLines(lastSynced, current); + const chunks = []; + let removePos = 0, addPos = 0, idx = 0; + let i = 0; + while (i < parts.length) { + const p = parts[i]; + if (!p.added && !p.removed) { + removePos += p.value.length; + addPos += p.value.length; + i++; + continue; + } + const removeStart = removePos; + const addStart = addPos; + let before = "", after = ""; + while (i < parts.length && (parts[i].added || parts[i].removed)) { + if (parts[i].removed) { + before += parts[i].value; + removePos += parts[i].value.length; + } else if (parts[i].added) { + after += parts[i].value; + addPos += parts[i].value.length; + } + i++; + } + chunks.push({ + id: "mindle-diff-" + idx++, + removeStart, removeEnd: removePos, + addStart, addEnd: addPos, + before, after + }); + } + return chunks; + } + + function renderChunkBlock(chunk) { + // Pre-render before/after through markdown-it so block markdown + // (lists, code fences, headings) inside a chunk renders correctly. + // The outer wrapper is a raw HTML block — markdown-it leaves it + // alone since blank lines surround it. + const beforeHTML = chunk.before.trim() ? md.render(chunk.before) : ""; + const afterHTML = chunk.after.trim() ? md.render(chunk.after) : ""; + let body = ""; + if (beforeHTML) body += '