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 += '
' + beforeHTML + '
'; + if (afterHTML) body += '
' + afterHTML + '
'; + const controls = + '
' + + '' + + '' + + '
'; + return "\n\n" + + '
' + + body + controls + + '
' + + "\n\n"; + } + + function attachDiffHandlers() { + doc.querySelectorAll('[data-mindle-diff-action]').forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const action = btn.getAttribute("data-mindle-diff-action"); + const id = btn.getAttribute("data-mindle-diff-id"); + const chunk = diffChunks.find(c => c.id === id); + if (!chunk) return; + // Capture where the chunk lives now so we can scroll back to + // the same vertical position on the next render — when the + // chunk chrome has collapsed but the content stays where the + // user was looking. Without this the reader drifts up to a + // shorter doc and the just-actioned content scrolls off-view. + const wrapper = btn.closest(".mindle-diff-chunk"); + if (wrapper) { + pendingScrollAfterRender = + wrapper.getBoundingClientRect().top + window.scrollY - 40; + } + if (action === "accept") { + // Promote this chunk's "after" into the baseline. + const newLastSynced = + activeLastSynced.slice(0, chunk.removeStart) + + chunk.after + + activeLastSynced.slice(chunk.removeEnd); + postToSwift("diffSetLastSynced", { text: newLastSynced }); + } else if (action === "reject") { + // Revert this chunk's "after" back to the baseline's "before" + // — Swift will write through to disk. + const newCurrent = + activeCurrent.slice(0, chunk.addStart) + + chunk.before + + activeCurrent.slice(chunk.addEnd); + postToSwift("diffSetCurrent", { text: newCurrent }); + } + }); + }); + } + window.mindleSetTheme = function (theme) { document.documentElement.dataset.theme = theme; // Mermaid diagrams bake the theme into their SVG at render time, so diff --git a/Resources/web/vendor/diff.min.js b/Resources/web/vendor/diff.min.js new file mode 100644 index 0000000..4d96b76 --- /dev/null +++ b/Resources/web/vendor/diff.min.js @@ -0,0 +1,37 @@ +/*! + + diff v7.0.0 + +BSD 3-Clause License + +Copyright (c) 2009-2015, Kevin Decker +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +@license +*/ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).Diff={})}(this,function(e){"use strict";function r(){}function w(e,n,t,r,i){for(var o,l=[];n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var a=0,u=l.length,s=0,f=0;ae.length?n:e}),d.value=e.join(c)):d.value=e.join(t.slice(s,s+d.count)),s+=d.count,d.added||(f+=d.count))}return l}r.prototype={diff:function(l,a){var u=2=d&&c<=v+1)return f(w(s,p[0].lastComponent,a,l,s.useLongestToken));var g=-1/0,m=1/0;function i(){for(var e=Math.max(g,-h);e<=Math.min(m,h);e+=2){var n=void 0,t=p[e-1],r=p[e+1],i=(t&&(p[e-1]=void 0),!1),o=(r&&(o=r.oldPos-e,i=r&&0<=o&&o=d&&c<=v+1)return f(w(s,n.lastComponent,a,l,s.useLongestToken));(p[e]=n).oldPos+1>=d&&(m=Math.min(m,e-1)),c<=v+1&&(g=Math.max(g,e+1))}else p[e]=void 0}h++}if(n)!function e(){setTimeout(function(){if(tr)return n();i()||e()},0)}();else for(;h<=t&&Date.now()<=r;){var o=i();if(o)return o}},addToPath:function(e,n,t,r,i){var o=e.lastComponent;return o&&!i.oneChangePerToken&&o.added===n&&o.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:o.count+1,added:n,removed:t,previousComponent:o.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:o}}},extractCommon:function(e,n,t,r,i){for(var o=n.length,l=t.length,a=e.oldPos,u=a-r,s=0;u+1n.length&&(t=e.length-n.length);var r=n.length;e.lengthe.length)&&(n=e.length);for(var t=0,r=new Array(n);te.length)return!1;for(var t=0;t"):r.removed&&n.push(""),n.push(r.value.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")),r.added?n.push(""):r.removed&&n.push("")}return n.join("")},e.createPatch=function(e,n,t,r,i,o){return M(e,e,n,t,r,i,o)},e.createTwoFilesPatch=M,e.diffArrays=function(e,n,t){return F.diff(e,n,t)},e.diffChars=function(e,n,t){return I.diff(e,n,t)},e.diffCss=function(e,n,t){return m.diff(e,n,t)},e.diffJson=function(e,n,t){return x.diff(e,n,t)},e.diffLines=y,e.diffSentences=function(e,n,t){return g.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){return t=function(e,n){if("function"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}(t,{ignoreWhitespace:!0}),v.diff(e,n,t)},e.diffWords=function(e,n,t){return null==(null==t?void 0:t.ignoreWhitespace)||t.ignoreWhitespace?i.diff(e,n,t):a(e,n,t)},e.diffWordsWithSpace=a,e.formatPatch=E,e.merge=function(e,n,t){e=J(e,t),n=J(n,t);for(var r={},i=((e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(q(e)?q(n)?(r.oldFileName=H(r,e.oldFileName,n.oldFileName),r.newFileName=H(r,e.newFileName,n.newFileName),r.oldHeader=H(r,e.oldHeader,n.oldHeader),r.newHeader=H(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[],0),o=0,l=0,a=0;i