Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Resources/web/reader.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions Resources/web/reader.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<script src="vendor/highlight.min.js"></script>
<script src="vendor/mermaid.min.js"></script>
<script src="vendor/katex/katex.min.js"></script>
<script src="vendor/diff.min.js"></script>
</head>
<body>
<article id="doc"></article>
Expand Down
151 changes: 148 additions & 3 deletions Resources/web/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += '<div class="mindle-diff-removed">' + beforeHTML + '</div>';
if (afterHTML) body += '<div class="mindle-diff-added">' + afterHTML + '</div>';
const controls =
'<div class="mindle-diff-controls">' +
'<button data-mindle-diff-action="accept" data-mindle-diff-id="' + chunk.id + '">✓ Keep</button>' +
'<button data-mindle-diff-action="reject" data-mindle-diff-id="' + chunk.id + '">✗ Revert</button>' +
'</div>';
return "\n\n" +
'<div class="mindle-diff-chunk" data-mindle-diff-id="' + chunk.id + '">' +
body + controls +
'</div>' +
"\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
Expand Down
37 changes: 37 additions & 0 deletions Resources/web/vendor/diff.min.js

Large diffs are not rendered by default.

Loading
Loading