diff --git a/Cargo.lock b/Cargo.lock index b3eb5ff..96c4ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.53" +version = "0.2.55" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index cf6634b..3e58b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.54" +version = "0.2.55" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/static/js/markdown.js b/static/js/markdown.js index c9932e5..535bad4 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -461,15 +461,16 @@ export function renderMarkdown(source, lineOffset = 0) { let end = index + 1; while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++; if (end < lines.length) { - const block = lines.slice(index + 1, end); + let bodyStart = index + 1; let summary = "Details"; - while (block.length && !block[0].trim()) block.shift(); - if (block.length) { - const summaryMatch = block[0].trim().match(/^([\s\S]*?)<\/summary>$/i); - if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); } + while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++; + if (bodyStart < end) { + const summaryMatch = lines[bodyStart].trim().match(/^([\s\S]*?)<\/summary>$/i); + if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; bodyStart++; } } - while (block.length && !block[0].trim()) block.shift(); - html += `
${inline(summary)}
${renderMarkdown(block.join("\n"), lineOffset + index + 1)}
`; + while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++; + const body = lines.slice(bodyStart, end).join("\n"); + html += `
${inline(summary)}
${renderMarkdown(body, lineOffset + bodyStart)}
`; index = end; continue; } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index b206bbc..4f88ad0 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -920,15 +920,23 @@ export function startNoteEditor(adapter) { preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => { const sourceLine = Number(node.dataset.sourceLine); if (!Number.isFinite(sourceLine) || sourceLine < 1 || !node.getClientRects().length) return; - const rect = node.getBoundingClientRect(); + const isDetails = node.matches("details.markdown-details"); + const geometryNode = isDetails && node.open ? node.querySelector(":scope > summary") : node; + if (!geometryNode?.getClientRects().length) return; + const rect = geometryNode.getBoundingClientRect(); + const declaredEnd = Number(node.dataset.sourceEndLine); + const sourceEndLine = isDetails && !node.open && Number.isFinite(declaredEnd) && declaredEnd >= sourceLine + ? declaredEnd + : sourceLine; positions.push({ node, sourceLine, + sourceEndLine, top: rect.top - previewRect.top + preview.scrollTop, bottom: rect.bottom - previewRect.top + preview.scrollTop, }); }); - return positions; + return positions.sort((left, right) => left.top - right.top || left.sourceLine - right.sourceLine); } function previewScrollAnchor() { @@ -940,11 +948,15 @@ export function startNoteEditor(adapter) { let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5); if (currentIndex < 0) currentIndex = positions.length - 1; const current = positions[currentIndex]; - const next = positions.slice(currentIndex + 1).find(position => position.sourceLine > current.sourceLine); - const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, current.bottom - current.top))); - const sourceLine = next - ? current.sourceLine + progress * (next.sourceLine - current.sourceLine) - : current.sourceLine; + if (current.sourceEndLine > current.sourceLine) { + const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, current.bottom - current.top))); + const sourceLine = current.sourceLine + progress * (current.sourceEndLine - current.sourceLine); + return { sourceLine, ...state }; + } + const next = positions.slice(currentIndex + 1).find(position => position.sourceLine > current.sourceEndLine && position.top > current.top + 0.5); + if (!next) return { sourceLine: current.sourceLine, ...state }; + const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, next.top - current.top))); + const sourceLine = current.sourceEndLine + progress * (next.sourceLine - current.sourceEndLine); return { sourceLine, ...state }; } @@ -982,13 +994,18 @@ export function startNoteEditor(adapter) { return; } const sourceLine = Math.max(1, anchor.sourceLine); - const exact = positions.find(position => position.sourceLine === sourceLine); - let targetTop = exact?.top; + const covering = positions.find(position => position.sourceLine <= sourceLine && sourceLine <= position.sourceEndLine); + let targetTop; + if (covering) { + const sourceSpan = covering.sourceEndLine - covering.sourceLine; + const progress = sourceSpan > 0 ? (sourceLine - covering.sourceLine) / sourceSpan : 0; + targetTop = covering.top + progress * Math.max(0, covering.bottom - covering.top); + } if (!Number.isFinite(targetTop)) { - const before = [...positions].reverse().find(position => position.sourceLine <= sourceLine); + const before = [...positions].reverse().find(position => position.sourceEndLine <= sourceLine); const after = positions.find(position => position.sourceLine >= sourceLine); - if (before && after && after.sourceLine > before.sourceLine) { - const progress = (sourceLine - before.sourceLine) / (after.sourceLine - before.sourceLine); + if (before && after && after.sourceLine > before.sourceEndLine) { + const progress = (sourceLine - before.sourceEndLine) / (after.sourceLine - before.sourceEndLine); targetTop = before.top + progress * (after.top - before.top); } else targetTop = (before || after)?.top; } @@ -1053,9 +1070,15 @@ export function startNoteEditor(adapter) { function renderNow() { const previewViewport = pendingPreviewViewport; pendingPreviewViewport = null; + const openDetailsLines = uiState.mode === "markdown" + ? new Set([...preview.querySelectorAll("details.markdown-details[open][data-source-line]")].map(node => node.dataset.sourceLine)) + : new Set(); if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); + preview.querySelectorAll("details.markdown-details[data-source-line]").forEach(node => { + node.open = openDetailsLines.has(node.dataset.sourceLine); + }); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; renderMermaid(); @@ -1143,6 +1166,8 @@ export function startNoteEditor(adapter) { function restoreHistorySnapshot(snapshot) { if (!snapshot || !canEditDocument()) return; const previous = editor.value; + const viewportScrollTop = editor.scrollTop; + const viewportScrollLeft = editor.scrollLeft; const maxOffset = snapshot.content.length; const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset); const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset); @@ -1152,8 +1177,8 @@ export function startNoteEditor(adapter) { previousContent = snapshot.content; editor.setSelectionRange(selectionStart, selectionEnd, snapshot.selectionDirection || "none"); render(); - editor.scrollTop = snapshot.scrollTop || 0; - editor.scrollLeft = snapshot.scrollLeft || 0; + editor.scrollTop = viewportScrollTop; + editor.scrollLeft = viewportScrollLeft; syncEditorLayers(); applyingHistory = false; editor.focus({ preventScroll: true });