From f3cf9b07c7cfde642c021c44923b28f753d7baa8 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 08:19:12 +0530 Subject: [PATCH 01/13] fix: prevent non-markdown files from rendering in md viewer Guard _loadMdviewrPreview to check that the current document is actually a markdown file before activating MarkdownSync. Fixes race where async getPreviewDetails returns stale markdown details but the editor has already switched to a binary/json file, causing its content to be rendered as markdown. Add tests verifying: binary/json files don't render in md viewer, last md preview stays visible for non-previewable files, and md preview resumes correctly when switching back to md. --- docs/API-Reference/command/Menus.md | 7 ++ .../Phoenix-live-preview/main.js | 6 ++ .../test-data.json | 1 + test/spec/md-editor-edit-more-integ-test.js | 92 ++++++++++++++++++- 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 test/spec/LiveDevelopment-Markdown-test-files/test-data.json diff --git a/docs/API-Reference/command/Menus.md b/docs/API-Reference/command/Menus.md index bfe202cd26..3c4b72e83c 100644 --- a/docs/API-Reference/command/Menus.md +++ b/docs/API-Reference/command/Menus.md @@ -612,6 +612,13 @@ Commands, which control a MenuItem's name, enabled state, and checked state. | --- | --- | --- | | id | string | unique identifier for context menu. Core context menus in Brackets use a simple title as an id. Extensions should use the following format: "author.myextension.mycontextmenu name" | + + +## \_initHamburgerMenu() +Hamburger menu: when the titlebar is too narrow to fit all menu items on one row, +overflow items are hidden and a hamburger button appears with a dropdown listing them. + +**Kind**: global function ## "EVENT_BEFORE_CONTEXT_MENU_OPEN" diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index b9af7cd2fe..9dc183b0ba 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -869,6 +869,11 @@ define(function (require, exports, module) { if (!currentDoc) { return; } + // Only render markdown files — skip binary, json, and other non-markdown files + // (can happen when previewDetails is stale from async getPreviewDetails) + if (!utils.isMarkdownFile(currentDoc.file.fullPath)) { + return; + } const mdFileURL = encodeURI(previewDetails.URL); const baseURL = mdFileURL.substring(0, mdFileURL.lastIndexOf("/") + 1); @@ -1179,6 +1184,7 @@ define(function (require, exports, module) { if (_isMdviewrActive && urlPinned) { return; } + if(!LivePreviewSettings.isUsingCustomServer() && !LiveDevelopment.isActive() && (panel.isVisible() || StaticServer.hasActiveLivePreviews())) { // we do this only once after project switch if live preview for a doc is not active. diff --git a/test/spec/LiveDevelopment-Markdown-test-files/test-data.json b/test/spec/LiveDevelopment-Markdown-test-files/test-data.json new file mode 100644 index 0000000000..03cf4bcad4 --- /dev/null +++ b/test/spec/LiveDevelopment-Markdown-test-files/test-data.json @@ -0,0 +1 @@ +{"name": "test", "value": 42} diff --git a/test/spec/md-editor-edit-more-integ-test.js b/test/spec/md-editor-edit-more-integ-test.js index b282354218..bf3e3bb6a0 100644 --- a/test/spec/md-editor-edit-more-integ-test.js +++ b/test/spec/md-editor-edit-more-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, beforeAll, afterAll, awaitsFor, it, awaitsForDone, expect*/ +/*global describe, beforeAll, afterAll, awaitsFor, awaits, it, awaitsForDone, expect*/ define(function (require, exports, module) { @@ -447,5 +447,95 @@ define(function (require, exports, module) { execSearchTests("edit", _enterEditMode); execSearchTests("reader", _enterReaderMode); }); + + describe("Non-markdown file preview behavior", function () { + + function _getMdPreviewH1() { + const mdDoc = _getMdIFrameDoc(); + if (!mdDoc) { return null; } + const h1 = mdDoc.querySelector("#viewer-content h1"); + return h1 ? h1.textContent : null; + } + + it("should not render binary/json files in md viewer when switching from md", async function () { + // Open a markdown file first + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["doc1.md"]), + "open doc1.md"); + await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + + // Remember what the md viewer is showing + const mdH1Before = _getMdPreviewH1(); + expect(mdH1Before).not.toBeNull(); + + // Switch to a JSON file (non-markdown, non-previewable) + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["test-data.json"]), + "open test-data.json"); + // Negative assertion: wait for any async switch to settle + await awaits(500); + + // The md viewer should still show the last md file's content, + // NOT the json file's content + const mdH1After = _getMdPreviewH1(); + expect(mdH1After).toBe(mdH1Before); + + // The md viewer content should NOT contain json data + const mdDoc = _getMdIFrameDoc(); + const viewerText = mdDoc.getElementById("viewer-content").textContent; + expect(viewerText).not.toContain('"name"'); + expect(viewerText).not.toContain('"value"'); + + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "force close test-data.json"); + }, 15000); + + it("should resume md preview when switching back to md from non-md file", async function () { + // Open doc1.md + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["doc1.md"]), + "open doc1.md"); + await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + const doc1H1 = _getMdPreviewH1(); + + // Switch to JSON + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["test-data.json"]), + "open test-data.json"); + await awaits(500); + + // Switch to doc2.md — should show doc2 content, not doc1 or json + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["doc2.md"]), + "open doc2.md"); + await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + + const doc2H1 = _getMdPreviewH1(); + expect(doc2H1).not.toBeNull(); + expect(doc2H1).not.toBe(doc1H1); + + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "force close doc2.md"); + }, 15000); + + it("should keep last md preview when switching to HTML file", async function () { + // Open a markdown file + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["doc1.md"]), + "open doc1.md"); + await _waitForMdPreviewReady(EditorManager.getActiveEditor()); + const mdH1 = _getMdPreviewH1(); + + // Switch to HTML — live preview should switch to HTML, hiding md viewer + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]), + "open simple.html"); + await awaits(500); + + // MD iframe should be hidden (HTML live preview takes over) + const mdIFrame = _getMdPreviewIFrame(); + const mdHidden = !mdIFrame || mdIFrame.style.display === "none"; + // OR if md viewer stayed visible, its content should be the last md file + if (!mdHidden) { + expect(_getMdPreviewH1()).toBe(mdH1); + } + + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "force close simple.html"); + }, 15000); + }); }); }); From 79696f0f2138cc11c630eb6cd174cba6dbaa27bf Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 08:25:19 +0530 Subject: [PATCH 02/13] fix(mdviewer): disable spellcheck and autocorrect in edit mode Set spellcheck=false, autocorrect=off, autocapitalize=off on viewer-content when entering edit mode to prevent browser squiggly underlines on contenteditable text. --- src-mdviewer/src/components/editor.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index 69be97e081..4fa9e17549 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -1553,6 +1553,9 @@ export function initEditor() { function enterEditMode(content) { content.setAttribute("contenteditable", "true"); + content.setAttribute("spellcheck", "false"); + content.setAttribute("autocorrect", "off"); + content.setAttribute("autocapitalize", "off"); content.classList.add("editing"); document.execCommand("defaultParagraphSeparator", false, "p"); From b0db69debde111890219878987a8ac049ee83374 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 08:47:56 +0530 Subject: [PATCH 03/13] feat(mdviewer): add Content-Security-Policy to block injected scripts Add CSP meta tag to md viewer iframe HTML: - script-src 'self': blocks inline scripts and external script URLs from markdown content, only allows the bundled mdviewer JS - connect-src 'self': blocks fetch/XHR to external URLs, preventing data exfiltration even if a script somehow executes - img-src allows external URLs (needed for markdown images) - style-src allows self + unsafe-inline (needed for element positioning) Combined with the existing iframe sandbox (no allow-same-origin), this provides defense-in-depth against malicious markdown content. --- src-mdviewer/index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-mdviewer/index.html b/src-mdviewer/index.html index dda1893dc2..a03bf493c3 100644 --- a/src-mdviewer/index.html +++ b/src-mdviewer/index.html @@ -4,6 +4,13 @@ + From b91af251c9fa6e8ccfcd1a1e93e874ca2bbaf1d2 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 09:01:23 +0530 Subject: [PATCH 04/13] feat(mdviewer): add clipboard permissions and table context menu paste Add allow-clipboard-read/write sandbox flags and permissions policy on the md viewer iframe for Chromium context menu paste support. Hide paste menu items on browsers where Clipboard API is blocked (Safari/Firefox sandboxed iframes). Add cut/copy/paste items to the table right-click context menu. --- src-mdviewer/src/components/context-menu.js | 42 ++++++++++++++----- src-mdviewer/src/components/editor.js | 41 +++++++++++++++++- .../Phoenix-live-preview/main.js | 5 ++- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src-mdviewer/src/components/context-menu.js b/src-mdviewer/src/components/context-menu.js index c73fb47cc2..48d168fbce 100644 --- a/src-mdviewer/src/components/context-menu.js +++ b/src-mdviewer/src/components/context-menu.js @@ -8,6 +8,24 @@ let menu = null; let cleanupFns = []; let savedRange = null; +// Detect clipboard API availability (blocked in Safari/Firefox sandboxed iframes) +let _clipboardApiSupported = null; +function _isClipboardApiAvailable() { + if (_clipboardApiSupported !== null) { return _clipboardApiSupported; } + if (!navigator.clipboard || !navigator.clipboard.readText) { + _clipboardApiSupported = false; + return false; + } + // Probe by calling readText — if permissions policy blocks it, it throws synchronously + // or rejects immediately. Cache the result after first context menu open. + navigator.clipboard.readText() + .then(() => { _clipboardApiSupported = true; }) + .catch(() => { _clipboardApiSupported = false; }); + // Optimistic for first open on Chromium; will correct on next open if blocked + _clipboardApiSupported = true; + return _clipboardApiSupported; +} + export function initContextMenu() { menu = document.getElementById("context-menu"); if (!menu) return; @@ -141,16 +159,20 @@ function buildItems(ctx) { }); } - items.push({ - label: t("context.paste"), - shortcut: `${modLabel}+V`, - action: () => pasteFromClipboard(false) - }); - items.push({ - label: t("context.paste_plain"), - shortcut: `${modLabel}+\u21E7+V`, - action: () => pasteFromClipboard(true) - }); + // Clipboard API paste only works in Chromium with permissions policy. + // In Safari/Firefox sandboxed iframes, it's blocked. Users can still Ctrl/Cmd+V. + if (_isClipboardApiAvailable()) { + items.push({ + label: t("context.paste"), + shortcut: `${modLabel}+V`, + action: () => pasteFromClipboard(false) + }); + items.push({ + label: t("context.paste_plain"), + shortcut: `${modLabel}+\u21E7+V`, + action: () => pasteFromClipboard(true) + }); + } items.push({ divider: true }); diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index 4fa9e17549..f4e670be52 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -1024,7 +1024,44 @@ function showTableContextMenu(x, y, ctx, contentEl) { const menu = document.getElementById("table-context-menu"); if (!menu) return; - const items = [ + const sel = window.getSelection(); + const hasSelection = sel && !sel.isCollapsed; + + const items = []; + + // Cut/Copy/Paste + if (hasSelection) { + items.push({ + label: t("context.cut") || "Cut", + action: () => { document.execCommand("cut"); } + }); + items.push({ + label: t("context.copy") || "Copy", + action: () => { document.execCommand("copy"); } + }); + } + if (navigator.clipboard && navigator.clipboard.readText) { + items.push({ + label: t("context.paste") || "Paste", + action: async () => { + contentEl.focus({ preventScroll: true }); + try { + const text = await navigator.clipboard.readText(); + if (text) { + document.execCommand("insertText", false, text.replace(/[\r\n]+/g, " ").trim()); + } + } catch { + document.execCommand("paste"); + } + } + }); + } + if (hasSelection || (navigator.clipboard && navigator.clipboard.readText)) { + items.push({ divider: true }); + } + + // Table operations + items.push( { label: t("table.add_row_above"), action: () => { flushSnapshot(contentEl); addTableRow(ctx.table, null, ctx.tr); dispatchInputEvent(contentEl); } }, { label: t("table.add_row_below"), action: () => { flushSnapshot(contentEl); addTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, { label: t("table.add_col_left"), action: () => { flushSnapshot(contentEl); addTableColumn(ctx.table, ctx.colIdx - 1); dispatchInputEvent(contentEl); } }, @@ -1034,7 +1071,7 @@ function showTableContextMenu(x, y, ctx, contentEl) { { label: t("table.delete_col"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, { divider: true }, { label: t("table.delete_table"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTable(ctx.table); dispatchInputEvent(contentEl); } } - ]; + ); menu.innerHTML = ""; items.forEach((item) => { diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 9dc183b0ba..51523aaf9d 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -141,12 +141,13 @@ define(function (require, exports, module) { // no allow-forms, allow-pointer-lock (not needed for markdown editing). // Communication works via MarkdownSync's own message handler (bypasses EventManager origin check). const _mdSandboxAttr = Phoenix.isTestWindow ? "" : - 'sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals"'; + 'sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals allow-clipboard-read allow-clipboard-write"'; const MDVIEWR_IFRAME_HTML = ` `; From 47dd18e0cb8a71ebbc8635d144006fef557183c2 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 13:13:13 +0530 Subject: [PATCH 05/13] fix(mdviewer): preserve newlines in code blocks and improve line sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix code block editing: normalize
→ \n before Prism re-highlights to prevent newline collapse in contenteditable code blocks - Re-enable _annotateCodeBlockLines for per-line cursor sync in code blocks - Add _updateSourceLineAttrs to recompute data-source-line after edits using CM's actual text (via MDVIEWR_SOURCE_LINES message) for accuracy - Fix stale annotation detection: re-annotate when line numbers drift - Fix
paragraph cursor sync: count
before cursor in _getSourceLineFromElement for exact CM line within soft-break paragraphs - Move \n to end of line spans (not start) so blank lines are clickable --- src-mdviewer/src/bridge.js | 21 +++- src-mdviewer/src/components/editor.js | 105 +++++++++++++++++- src-mdviewer/src/components/viewer.js | 28 ++++- .../Phoenix-live-preview/MarkdownSync.js | 28 ++++- 4 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 72392b521b..4274119c8c 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -282,6 +282,9 @@ export function initBridge() { case "MDVIEWR_RERENDER_CONTENT": handleRerenderContent(data); break; + case "MDVIEWR_SOURCE_LINES": + emit("editor:source-lines", data.markdown); + break; case "MDVIEWR_TOOLBAR_STATE": if (data.state) { emit("editor:selection-state", data.state); @@ -925,10 +928,26 @@ function _restoreCursorPosition(contentEl, pos) { } function _getSourceLineFromElement(el) { + // Use the current selection to determine exact position within
paragraphs + const sel = window.getSelection(); + const cursorNode = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null; + while (el && el !== document.body) { const attr = el.getAttribute && el.getAttribute("data-source-line"); if (attr != null) { - return parseInt(attr, 10); + let line = parseInt(attr, 10); + // For paragraphs with
(soft line breaks), count how many + //
elements precede the cursor to get the exact CM line. + if (el.tagName === "P" && cursorNode && el.querySelector("br")) { + const brs = el.querySelectorAll("br"); + for (const br of brs) { + const pos = br.compareDocumentPosition(cursorNode); + if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) { + line++; + } + } + } + return line; } el = el.parentElement; } diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index f4e670be52..49ad2daa97 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -10,7 +10,7 @@ import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-men import { initLinkPopover, destroyLinkPopover } from "./link-popover.js"; import { initImagePopover, destroyImagePopover } from "./image-popover.js"; import { initLangPicker, destroyLangPicker, isLangPickerDropdownOpen } from "./lang-picker.js"; -import { highlightCode, renderAfterHTML, normalizeCodeLanguages } from "./viewer.js"; +import { highlightCode, renderAfterHTML, normalizeCodeLanguages, _annotateCodeBlockLines } from "./viewer.js"; import { initMermaidEditor, destroyMermaidEditor, insertMermaidBlock, attachOverlays } from "./mermaid-editor.js"; const devLog = import.meta.env.DEV ? console.log.bind(console, "[editor]") : () => {}; @@ -1507,6 +1507,23 @@ export function convertToMarkdown(contentEl) { const clone = contentEl.cloneNode(true); clone.querySelectorAll(".code-copy-btn").forEach((btn) => btn.remove()); clone.querySelectorAll(".table-row-handles, .table-col-handles, .table-add-row-btn, .table-col-add-btn").forEach((el) => el.remove()); + // Fix code blocks: replace
with \n and unwrap data-source-line spans. + // In contenteditable, Enter inside a span inserts
instead of \n. + // Turndown needs plain text with \n for correct fenced code block output. + clone.querySelectorAll("pre code").forEach((code) => { + code.querySelectorAll("br").forEach((br) => { + br.replaceWith("\n"); + }); + // Unwrap data-source-line spans (inline them into the code element) + code.querySelectorAll("span[data-source-line]").forEach((span) => { + while (span.firstChild) { + span.parentNode.insertBefore(span.firstChild, span); + } + span.remove(); + }); + // Also unwrap any Prism token spans — get plain text for Turndown + code.textContent = code.textContent; + }); // Unwrap

inside

  • — marked renders "loose" lists with

    wrapping, // but Turndown converts that to blank lines between items. Unwrapping makes tight lists. clone.querySelectorAll("li > p").forEach((p) => { @@ -1534,6 +1551,67 @@ export function convertToMarkdown(contentEl) { let contentChangeTimer = null; const CONTENT_CHANGE_DEBOUNCE = 300; +/** + * Re-compute data-source-line attributes on top-level block elements + * by walking the generated markdown and mapping line numbers back to DOM nodes. + * This keeps scroll sync working after edits in the viewer. + */ +function _updateSourceLineAttrs(contentEl, markdown) { + const mdLines = markdown.split("\n"); + const children = contentEl.children; + let mdLineIdx = 0; + + for (let i = 0; i < children.length; i++) { + const el = children[i]; + // Skip UI elements (handles, overlays, etc.) + if (el.classList.contains("table-row-handles") || + el.classList.contains("table-col-handles") || + el.classList.contains("table-add-row-btn") || + el.classList.contains("table-col-add-btn") || + el.classList.contains("cursor-sync-highlight")) { + continue; + } + + // Skip blank lines in markdown to find the next block + while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() === "") { + mdLineIdx++; + } + if (mdLineIdx >= mdLines.length) break; + + // Assign line number (1-based) + el.setAttribute("data-source-line", String(mdLineIdx + 1)); + + // Advance past this element's markdown lines + const tag = el.tagName; + if (tag === "PRE" || (tag === "DIV" && el.classList.contains("table-wrapper"))) { + // Code blocks: find closing ``` or end of fenced block + // Tables: find end of table rows + const startLine = mdLineIdx; + mdLineIdx++; + if (tag === "PRE") { + // Skip to closing ``` + while (mdLineIdx < mdLines.length && !mdLines[mdLineIdx].match(/^```\s*$/)) { + mdLineIdx++; + } + mdLineIdx++; // skip the closing ``` + } else { + // Table: skip while lines start with | + while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].startsWith("|")) { + mdLineIdx++; + } + } + } else { + // Single-line or multi-line block: advance to next blank line. + // Paragraphs with
    (soft line breaks) are a single block — + // the data-source-line on the

    points to the block's start. + mdLineIdx++; + while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") { + mdLineIdx++; + } + } + } +} + function emitContentChange(contentEl) { clearTimeout(contentChangeTimer); contentChangeTimer = setTimeout(() => { @@ -1549,6 +1627,16 @@ function getContentEl() { export function initEditor() { turndown = createTurndown(); + // When CM sends back its actual text after an edit, use it to update + // data-source-line attributes. This is more accurate than using the + // markdown from convertToMarkdown, which may differ in formatting. + on("editor:source-lines", (cmMarkdown) => { + const content = getContentEl(); + if (!content) return; + _updateSourceLineAttrs(content, cmMarkdown); + _annotateCodeBlockLines(); + }); + on("state:editMode", (editing) => { const content = getContentEl(); if (!content) return; @@ -1666,19 +1754,30 @@ function enterEditMode(content) { ? sel.anchorNode.closest("pre") : sel.anchorNode.parentElement?.closest("pre"); if (pre && content.contains(pre)) { + // Debounced re-highlighting for code blocks. + // First normalize
    → \n (contenteditable inserts
    + // for Enter, but Prism reads textContent which ignores
    ). + // Then re-run Prism with cursor preservation. clearTimeout(codeHighlightTimer); codeHighlightTimer = setTimeout(() => { const code = pre.querySelector("code"); if (!code) return; + // Step 1: normalize
    → \n BEFORE anything else + code.querySelectorAll("br").forEach(br => br.replaceWith("\n")); + // Step 2: save cursor AFTER normalization + const off = getCursorOffset(content); + // Step 3: apply language class + Prism highlight const lang = pre.getAttribute("data-language"); if (lang && !code.className.includes(`language-${lang}`)) { code.className = `language-${lang}`; } if (code.className.includes("language-")) { - const off = getCursorOffset(content); Prism.highlightElement(code); - restoreCursor(content, off); } + // Step 4: re-annotate code block lines for scroll sync + _annotateCodeBlockLines(); + // Step 5: restore cursor + restoreCursor(content, off); }, 500); } } diff --git a/src-mdviewer/src/components/viewer.js b/src-mdviewer/src/components/viewer.js index b004e810fc..23d9bf80a9 100644 --- a/src-mdviewer/src/components/viewer.js +++ b/src-mdviewer/src/components/viewer.js @@ -74,6 +74,7 @@ export function initViewer() { const newContent = document.createElement("div"); newContent.innerHTML = parseResult.html; + morphdom(content, newContent, { childrenOnly: true }); // Restore saved image nodes — find new by src and swap @@ -200,17 +201,34 @@ export function highlightCode() { * enabling per-line cursor sync for code blocks. * Must run AFTER Prism highlighting since Prism replaces innerHTML. */ -function _annotateCodeBlockLines() { +export function _annotateCodeBlockLines() { // Process all pre elements, not just those with data-source-line // (morphdom may strip the attr on first render) const pres = document.querySelectorAll("#viewer-content pre"); pres.forEach((pre) => { const code = pre.querySelector("code"); if (!code) return; - // Already annotated? - if (code.querySelector("span[data-source-line]")) return; let preSourceLine = parseInt(pre.getAttribute("data-source-line"), 10); + // If already annotated, check if the line numbers are still correct. + // If pre has a data-source-line and annotations exist, compare the + // expected first line with the actual first annotation. + const existingSpan = code.querySelector("span[data-source-line]"); + if (existingSpan) { + if (isNaN(preSourceLine)) return; // can't verify, keep existing + const expectedFirst = String(preSourceLine + 1); + if (existingSpan.getAttribute("data-source-line") === expectedFirst) { + return; // annotations are up to date + } + // Stale annotations — unwrap them before re-annotating + code.querySelectorAll("span[data-source-line]").forEach((span) => { + while (span.firstChild) { + span.parentNode.insertBefore(span.firstChild, span); + } + span.remove(); + }); + code.normalize(); // merge adjacent text nodes + } if (isNaN(preSourceLine)) { // Fallback: find the nearest preceding sibling with data-source-line // and estimate this pre's line from it @@ -243,12 +261,12 @@ function _annotateCodeBlockLines() { const parts = text.split("\n"); for (let i = 0; i < parts.length; i++) { if (i > 0) { - // Close current line span, start new one with the newline inside it + // Close current line: append \n to END of current span + currentLine.appendChild(document.createTextNode("\n")); fragment.appendChild(currentLine); lineIdx++; currentLine = document.createElement("span"); currentLine.setAttribute("data-source-line", String(codeStartLine + lineIdx)); - currentLine.appendChild(document.createTextNode("\n")); } if (parts[i]) { currentLine.appendChild(document.createTextNode(parts[i])); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 233af1959c..b510590b3a 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -358,12 +358,17 @@ define(function (require, exports, module) { return; } + const mdText = _doc.getText(); iframeWindow.postMessage({ type: "MDVIEWR_SWITCH_FILE", - markdown: _doc.getText(), + markdown: mdText, baseURL: _baseURL, filePath: _doc.file.fullPath }, "*"); + iframeWindow.postMessage({ + type: "MDVIEWR_SOURCE_LINES", + markdown: mdText + }, "*"); } function _sendContent() { @@ -375,12 +380,19 @@ define(function (require, exports, module) { return; } + const mdText = _doc.getText(); iframeWindow.postMessage({ type: "MDVIEWR_SET_CONTENT", - markdown: _doc.getText(), + markdown: mdText, baseURL: _baseURL, filePath: _doc.file.fullPath }, "*"); + // Send source line mapping so the iframe can annotate sub-element + // lines (e.g.
    within paragraphs) on first load. + iframeWindow.postMessage({ + type: "MDVIEWR_SOURCE_LINES", + markdown: mdText + }, "*"); } function _sendUpdate(changeOrigin) { @@ -501,6 +513,18 @@ define(function (require, exports, module) { _applyDiffToEditor(markdown); + // Send back the actual CM text so the iframe can compute accurate + // data-source-line attributes. The markdown from convertToMarkdown + // may differ slightly from CM's content (e.g. table formatting), + // causing line number drift if used directly. + const iframeWindow = _getIframeWindow(); + if (iframeWindow && _doc) { + iframeWindow.postMessage({ + type: "MDVIEWR_SOURCE_LINES", + markdown: _doc.getText() + }, "*"); + } + // Push cursor position for undo/redo restore if (data.cursorPos) { _cursorUndoStack.push(data.cursorPos); From 0ef3462be29d63e36a89e6f8a434a6d1497ea501 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 13:47:36 +0530 Subject: [PATCH 06/13] fix(mdviewer): code block scroll jump, br line sync, lang-picker fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix scroll jump when clicking in code blocks: remove data-source-line from

     in early-return path of _annotateCodeBlockLines
    - Fix scroll-to-top on click in edit mode: block fromScroll sync from CM
      to prevent feedback loop (click → CM scroll → scroll sync back → jump)
    - Add per-line highlight for 
    paragraphs: wrap specific line content in cursor-sync-highlight span instead of highlighting whole

    - Track _lastHighlightTargetLine for accurate re-apply after re-renders - Fix _getSourceLineFromElement to count
    before cursor for exact line - Lang-picker: reposition upward when dropdown clipped at bottom - Lang-picker: fix blur with transform:none when visible --- src-mdviewer/src/bridge.js | 113 +++++++++++++++++++-- src-mdviewer/src/components/lang-picker.js | 17 +++- src-mdviewer/src/components/viewer.js | 6 +- src-mdviewer/src/styles/editor.css | 2 +- 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 4274119c8c..c6c3992d6f 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -958,6 +958,11 @@ function handleScrollToLine(data) { const { line, fromScroll, tableCol } = data; if (line == null) return; + // In edit mode, ignore scroll-based sync from CM to prevent feedback + // loops (click in viewer → CM scroll → scroll sync back → viewer jumps). + // Only cursor-based sync (fromScroll=false) should reposition the viewer. + if (fromScroll && getState().editMode) return; + const viewer = document.getElementById("viewer-content"); if (!viewer) return; @@ -983,35 +988,96 @@ function handleScrollToLine(data) { } } + // For paragraphs with
    (soft line breaks), find the specific visual + // line within the paragraph by counting
    elements. The target line + // minus the paragraph's start line gives the
    offset. + let scrollTarget = bestEl; + if (bestEl.tagName === "P" && bestLine < line) { + const brOffset = line - bestLine; + const brs = bestEl.querySelectorAll("br"); + if (brOffset > 0 && brOffset <= brs.length) { + // Use the
    element as scroll target — it's at the right + // vertical position for the specific line within the paragraph. + scrollTarget = brs[brOffset - 1]; + } + } + const container = document.getElementById("app-viewer"); if (!container) return; const containerRect = container.getBoundingClientRect(); - const elRect = bestEl.getBoundingClientRect(); + const elRect = scrollTarget.getBoundingClientRect(); // Suppress viewer→CM scroll feedback for any CM-initiated scroll _scrollFromCM = true; if (fromScroll) { // Sync scroll: always align to top, even if visible - bestEl.scrollIntoView({ behavior: "instant", block: "start" }); + scrollTarget.scrollIntoView({ behavior: "instant", block: "start" }); } else { // Cursor-based scroll: only scroll if not visible, center it const isVisible = elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom; if (!isVisible) { - bestEl.scrollIntoView({ behavior: "instant", block: "center" }); + scrollTarget.scrollIntoView({ behavior: "instant", block: "center" }); } } setTimeout(() => { _scrollFromCM = false; }, 200); // Persistent highlight on the element corresponding to the CM cursor. // Only show when CM has focus (not when viewer has focus). - const prev = viewer.querySelector(".cursor-sync-highlight"); - if (prev) { prev.classList.remove("cursor-sync-highlight"); } - bestEl.classList.add("cursor-sync-highlight"); + _removeCursorHighlight(viewer); + + // For
    paragraphs, wrap only the specific line's content in a + // highlight span instead of highlighting the whole

    . + if (bestEl.tagName === "P" && bestEl.querySelector("br")) { + const brOffset = line - bestLine; + const brs = bestEl.querySelectorAll("br"); + const span = document.createElement("span"); + span.className = "cursor-sync-highlight cursor-sync-br-line"; + if (brOffset === 0) { + // First line: wrap nodes before the first
    + let node = bestEl.firstChild; + while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) { + const toMove = node; + node = node.nextSibling; + span.appendChild(toMove); + } + bestEl.insertBefore(span, bestEl.firstChild); + } else if (brOffset > 0 && brOffset <= brs.length) { + // Subsequent lines: wrap nodes after the target
    + const targetBr = brs[brOffset - 1]; + let next = targetBr.nextSibling; + while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) { + const toMove = next; + next = next.nextSibling; + span.appendChild(toMove); + } + targetBr.parentNode.insertBefore(span, targetBr.nextSibling); + } else { + bestEl.classList.add("cursor-sync-highlight"); + } + } else { + bestEl.classList.add("cursor-sync-highlight"); + } _lastHighlightSourceLine = bestLine; + _lastHighlightTargetLine = line; +} + +function _removeCursorHighlight(viewer) { + const prev = viewer.querySelector(".cursor-sync-highlight"); + if (!prev) return; + // If highlight was a wrapper span for a
    line, unwrap it + if (prev.classList.contains("cursor-sync-br-line")) { + while (prev.firstChild) { + prev.parentNode.insertBefore(prev.firstChild, prev); + } + prev.remove(); + } else { + prev.classList.remove("cursor-sync-highlight"); + } } // Track last highlighted source line so we can re-apply after re-renders let _lastHighlightSourceLine = null; +let _lastHighlightTargetLine = null; function _reapplyCursorSyncHighlight() { if (_lastHighlightSourceLine == null) return; @@ -1019,6 +1085,7 @@ function _reapplyCursorSyncHighlight() { if (!viewer) return; // Don't re-apply if viewer has focus (user is editing in viewer) if (viewer.contains(document.activeElement)) return; + _removeCursorHighlight(viewer); const elements = viewer.querySelectorAll("[data-source-line]"); let bestEl = null; let bestLine = -1; @@ -1029,9 +1096,36 @@ function _reapplyCursorSyncHighlight() { bestEl = el; } } - if (bestEl) { - bestEl.classList.add("cursor-sync-highlight"); + if (!bestEl) return; + const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine; + // Handle
    paragraph sub-line highlighting + if (bestEl.tagName === "P" && bestEl.querySelector("br")) { + const brOffset = targetLine - bestLine; + const brs = bestEl.querySelectorAll("br"); + const span = document.createElement("span"); + span.className = "cursor-sync-highlight cursor-sync-br-line"; + if (brOffset === 0) { + let node = bestEl.firstChild; + while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) { + const toMove = node; + node = node.nextSibling; + span.appendChild(toMove); + } + bestEl.insertBefore(span, bestEl.firstChild); + return; + } else if (brOffset > 0 && brOffset <= brs.length) { + const targetBr = brs[brOffset - 1]; + let next = targetBr.nextSibling; + while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) { + const toMove = next; + next = next.nextSibling; + span.appendChild(toMove); + } + targetBr.parentNode.insertBefore(span, targetBr.nextSibling); + return; + } } + bestEl.classList.add("cursor-sync-highlight"); } // Re-apply cursor sync highlight after content re-renders (e.g. typing in CM) @@ -1044,8 +1138,7 @@ on("file:rendered", () => { document.addEventListener("focusin", (e) => { const viewer = document.getElementById("viewer-content"); if (viewer && viewer.contains(e.target)) { - const prev = viewer.querySelector(".cursor-sync-highlight"); - if (prev) { prev.classList.remove("cursor-sync-highlight"); } + _removeCursorHighlight(viewer); _lastHighlightSourceLine = null; } }); diff --git a/src-mdviewer/src/components/lang-picker.js b/src-mdviewer/src/components/lang-picker.js index aa06b524dd..f8b93ad2f9 100644 --- a/src-mdviewer/src/components/lang-picker.js +++ b/src-mdviewer/src/components/lang-picker.js @@ -215,7 +215,17 @@ function openDropdown() { dropdownOpen = true; filterQuery = ""; const dropdown = picker.querySelector(".lang-picker-dropdown"); - if (dropdown) dropdown.classList.add("open"); + if (dropdown) { + dropdown.classList.add("open"); + // If picker + dropdown would be clipped below, move picker up + requestAnimationFrame(() => { + const pickerRect = picker.getBoundingClientRect(); + if (pickerRect.bottom > window.innerHeight - 4) { + const overflow = pickerRect.bottom - window.innerHeight + 8; + picker.style.top = (parseFloat(picker.style.top) - overflow) + "px"; + } + }); + } updateSearchDisplay(); populateList(""); } @@ -239,11 +249,12 @@ function show(preEl) { // Position near top-left of

       const rect = preEl.getBoundingClientRect();
    +  const pickerH = picker.offsetHeight || 32;
       const pickerW = picker.offsetWidth || 180;
       let left = rect.left;
    -  let top = rect.top - (picker.offsetHeight || 32) - 6;
    +  let top = rect.top - pickerH - 6;
     
    -  // If too close to top, show below the pre's top edge
    +  // If not enough space above, show below the pre's top edge
       if (top < 4) {
         top = rect.top + 4;
       }
    diff --git a/src-mdviewer/src/components/viewer.js b/src-mdviewer/src/components/viewer.js
    index 23d9bf80a9..2eaaf1212c 100644
    --- a/src-mdviewer/src/components/viewer.js
    +++ b/src-mdviewer/src/components/viewer.js
    @@ -218,7 +218,11 @@ export function _annotateCodeBlockLines() {
                 if (isNaN(preSourceLine)) return; // can't verify, keep existing
                 const expectedFirst = String(preSourceLine + 1);
                 if (existingSpan.getAttribute("data-source-line") === expectedFirst) {
    -                return; // annotations are up to date
    +                // Annotations are up to date — still remove data-source-line
    +                // from 
     so clicks on empty space don't report the block
    +                // start line instead of the per-line annotation.
    +                pre.removeAttribute("data-source-line");
    +                return;
                 }
                 // Stale annotations — unwrap them before re-annotating
                 code.querySelectorAll("span[data-source-line]").forEach((span) => {
    diff --git a/src-mdviewer/src/styles/editor.css b/src-mdviewer/src/styles/editor.css
    index 449a3afd8c..38c50252af 100644
    --- a/src-mdviewer/src/styles/editor.css
    +++ b/src-mdviewer/src/styles/editor.css
    @@ -496,7 +496,7 @@
     .lang-picker.visible {
       opacity: 1;
       pointer-events: auto;
    -  transform: translateY(0);
    +  transform: none;
     }
     
     .lang-picker-trigger {
    
    From 0f0f90104a34ecc6bb92770d98aa7c6847263efc Mon Sep 17 00:00:00 2001
    From: abose 
    Date: Sun, 5 Apr 2026 14:03:20 +0530
    Subject: [PATCH 07/13] fix(mdviewer): cursor jump on formatBlock and edit-mode
     scroll sync
    
    - Fix cursor jumping to previous line after applying heading format on
      empty lines: place cursor inside the newly created block element
    - Block CM scroll-to-line when viewer has focus in edit mode to prevent
      highlight span creation from displacing the cursor
    ---
     src-mdviewer/src/bridge.js            |  7 ++++++-
     src-mdviewer/src/components/editor.js | 26 +++++++++++++++++++++++++-
     2 files changed, 31 insertions(+), 2 deletions(-)
    
    diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
    index c6c3992d6f..fde031638f 100644
    --- a/src-mdviewer/src/bridge.js
    +++ b/src-mdviewer/src/bridge.js
    @@ -960,12 +960,17 @@ function handleScrollToLine(data) {
     
         // In edit mode, ignore scroll-based sync from CM to prevent feedback
         // loops (click in viewer → CM scroll → scroll sync back → viewer jumps).
    -    // Only cursor-based sync (fromScroll=false) should reposition the viewer.
         if (fromScroll && getState().editMode) return;
     
         const viewer = document.getElementById("viewer-content");
         if (!viewer) return;
     
    +    // In edit mode, skip CM cursor sync when the viewer has focus — the user
    +    // is actively editing and highlight span creation/removal would displace
    +    // the cursor.
    +    if (getState().editMode && viewer.contains(document.activeElement)) return;
    +    if (!viewer) return;
    +
         const elements = viewer.querySelectorAll("[data-source-line]");
         let bestEl = null;
         let bestLine = -1;
    diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js
    index 49ad2daa97..70d2841159 100644
    --- a/src-mdviewer/src/components/editor.js
    +++ b/src-mdviewer/src/components/editor.js
    @@ -402,9 +402,33 @@ export function executeFormat(contentEl, command, value) {
             case "underline":
                 document.execCommand(command, false, null);
                 break;
    -        case "formatBlock":
    +        case "formatBlock": {
                 document.execCommand("formatBlock", false, value);
    +            // After formatBlock on an empty element, the browser may lose
    +            // cursor position. Find the new block and place cursor inside it.
    +            const sel2 = window.getSelection();
    +            if (sel2 && sel2.rangeCount) {
    +                let block = sel2.anchorNode;
    +                if (block?.nodeType === Node.TEXT_NODE) block = block.parentElement;
    +                // If cursor ended up outside the target block type, find it
    +                const targetTag = value.replace(/[<>/]/g, "").toUpperCase();
    +                if (block && block.tagName !== targetTag) {
    +                    // Look for the newly created block near the cursor
    +                    const allBlocks = contentEl.querySelectorAll(targetTag);
    +                    for (const b of allBlocks) {
    +                        if (b.textContent.trim() === "" || b.contains(sel2.anchorNode)) {
    +                            const r = document.createRange();
    +                            r.setStart(b, 0);
    +                            r.collapse(true);
    +                            sel2.removeAllRanges();
    +                            sel2.addRange(r);
    +                            break;
    +                        }
    +                    }
    +                }
    +            }
                 break;
    +        }
             case "createLink": {
                 if (value) {
                     document.execCommand("createLink", false, value);
    
    From ce0880dae0418be16d560c2b2f31dea38331a8f3 Mon Sep 17 00:00:00 2001
    From: abose 
    Date: Sun, 5 Apr 2026 14:51:51 +0530
    Subject: [PATCH 08/13] refactor(mdviewer): replace code block line annotations
     with \n counting
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Remove _annotateCodeBlockLines entirely — per-line cursor sync in code
    blocks now uses \n counting at click time (same approach as 
    in paragraphs). This eliminates DOM mutation of code block content, avoids Prism re-highlight conflicts, and simplifies the codebase. - bridge.js: count \n before cursor in _getSourceLineFromElement for PRE - bridge.js: add positioned overlay div for per-line code block highlight - bridge.js: simplify _reapplyCursorSyncHighlight to reuse handleScrollToLine - bridge.js: temporary marker span for scroll-to-line in code blocks - editor.js: remove _annotateCodeBlockLines import and calls - viewer.js: delete _annotateCodeBlockLines function (~110 lines removed) --- src-mdviewer/src/bridge.js | 208 ++++++++++++++++++-------- src-mdviewer/src/components/editor.js | 18 +-- src-mdviewer/src/components/viewer.js | 116 +------------- 3 files changed, 153 insertions(+), 189 deletions(-) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index fde031638f..7021a13d90 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -936,14 +936,32 @@ function _getSourceLineFromElement(el) { const attr = el.getAttribute && el.getAttribute("data-source-line"); if (attr != null) { let line = parseInt(attr, 10); - // For paragraphs with
    (soft line breaks), count how many - //
    elements precede the cursor to get the exact CM line. - if (el.tagName === "P" && cursorNode && el.querySelector("br")) { - const brs = el.querySelectorAll("br"); - for (const br of brs) { - const pos = br.compareDocumentPosition(cursorNode); - if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) { - line++; + if (cursorNode) { + // For paragraphs with
    (soft line breaks), count
    + // elements before the cursor for the exact CM line. + if (el.tagName === "P" && el.querySelector("br")) { + const brs = el.querySelectorAll("br"); + for (const br of brs) { + const pos = br.compareDocumentPosition(cursorNode); + if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) { + line++; + } + } + } + // For code blocks, count \n before cursor in textContent. + // data-source-line on
     points to the ``` fence line,
    +                // so first code line = line + 1, each \n increments.
    +                if (el.tagName === "PRE") {
    +                    const code = el.querySelector("code") || el;
    +                    try {
    +                        const range = document.createRange();
    +                        range.setStart(code, 0);
    +                        range.setEnd(sel.getRangeAt(0).startContainer, sel.getRangeAt(0).startOffset);
    +                        const textBefore = range.toString();
    +                        const newlines = (textBefore.match(/\n/g) || []).length;
    +                        line += 1 + newlines; // +1 for the ``` fence line
    +                    } catch (_e) {
    +                        line += 1; // fallback: first code line
                         }
                     }
                 }
    @@ -993,17 +1011,62 @@ function handleScrollToLine(data) {
             }
         }
     
    -    // For paragraphs with 
    (soft line breaks), find the specific visual - // line within the paragraph by counting
    elements. The target line - // minus the paragraph's start line gives the
    offset. + // For multi-line blocks, find the specific visual line to scroll to. let scrollTarget = bestEl; - if (bestEl.tagName === "P" && bestLine < line) { - const brOffset = line - bestLine; - const brs = bestEl.querySelectorAll("br"); - if (brOffset > 0 && brOffset <= brs.length) { - // Use the
    element as scroll target — it's at the right - // vertical position for the specific line within the paragraph. - scrollTarget = brs[brOffset - 1]; + if (bestLine < line) { + if (bestEl.tagName === "P") { + // Paragraphs with
    : use the
    element as scroll target. + const brOffset = line - bestLine; + const brs = bestEl.querySelectorAll("br"); + if (brOffset > 0 && brOffset <= brs.length) { + scrollTarget = brs[brOffset - 1]; + } + } else if (bestEl.tagName === "PRE") { + // Code blocks: find the text node containing the target \n + // and create a temporary span to scroll to. + const codeLineOffset = line - bestLine - 1; // -1 for ``` fence + const code = bestEl.querySelector("code") || bestEl; + const text = code.textContent; + let nlCount = 0; + let charIdx = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") { + if (nlCount === codeLineOffset) { + charIdx = i + 1; + break; + } + nlCount++; + } + } + // Walk text nodes to find the one containing charIdx + const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); + let offset = 0; + let targetNode = null; + while (walker.nextNode()) { + const len = walker.currentNode.textContent.length; + if (offset + len >= charIdx) { + targetNode = walker.currentNode; + break; + } + offset += len; + } + if (targetNode) { + // Insert a temporary marker to scroll to, then remove it + const marker = document.createElement("span"); + const splitAt = charIdx - offset; + if (splitAt > 0 && splitAt < targetNode.textContent.length) { + targetNode.splitText(splitAt); + targetNode.parentNode.insertBefore(marker, targetNode.nextSibling); + } else { + targetNode.parentNode.insertBefore(marker, targetNode); + } + scrollTarget = marker; + // Clean up after scroll + requestAnimationFrame(() => { + marker.remove(); + code.normalize(); + }); + } } } @@ -1059,6 +1122,63 @@ function handleScrollToLine(data) { } else { bestEl.classList.add("cursor-sync-highlight"); } + } else if (bestEl.tagName === "PRE" && bestLine < line) { + // Code blocks: use a positioned overlay at the target line's height. + // We can't wrap text without breaking Prism token spans. + const code = bestEl.querySelector("code") || bestEl; + const codeLineOffset = line - bestLine - 1; // -1 for ``` fence + // Find the character position of the target line + const text = code.textContent; + let charPos = 0; + let nlCount = 0; + for (let i = 0; i < text.length && nlCount < codeLineOffset; i++) { + if (text[i] === "\n") nlCount++; + charPos = i + 1; + } + // Create a range spanning the target line to get its rect + try { + const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); + let offset = 0; + let startNode = null, startOff = 0, endNode = null, endOff = 0; + while (walker.nextNode()) { + const node = walker.currentNode; + const len = node.textContent.length; + if (!startNode && offset + len >= charPos) { + startNode = node; + startOff = charPos - offset; + } + // Find end of this line (next \n or end of text) + const lineEnd = text.indexOf("\n", charPos); + const endPos = lineEnd === -1 ? text.length : lineEnd; + if (!endNode && offset + len >= endPos) { + endNode = node; + endOff = endPos - offset; + } + if (startNode && endNode) break; + offset += len; + } + if (startNode && endNode) { + const lineRange = document.createRange(); + lineRange.setStart(startNode, startOff); + lineRange.setEnd(endNode, endOff); + const lineRect = lineRange.getClientRects()[0]; + if (lineRect) { + const preRect = bestEl.getBoundingClientRect(); + const overlay = document.createElement("div"); + overlay.className = "cursor-sync-highlight cursor-sync-code-line"; + overlay.style.position = "absolute"; + overlay.style.left = "0"; + overlay.style.right = "0"; + overlay.style.top = (lineRect.top - preRect.top) + "px"; + overlay.style.height = lineRect.height + "px"; + overlay.style.pointerEvents = "none"; + bestEl.style.position = "relative"; + bestEl.appendChild(overlay); + } + } + } catch (_e) { + bestEl.classList.add("cursor-sync-highlight"); + } } else { bestEl.classList.add("cursor-sync-highlight"); } @@ -1069,6 +1189,11 @@ function handleScrollToLine(data) { function _removeCursorHighlight(viewer) { const prev = viewer.querySelector(".cursor-sync-highlight"); if (!prev) return; + // If highlight was a code block line overlay, just remove it + if (prev.classList.contains("cursor-sync-code-line")) { + prev.remove(); + return; + } // If highlight was a wrapper span for a
    line, unwrap it if (prev.classList.contains("cursor-sync-br-line")) { while (prev.firstChild) { @@ -1085,52 +1210,13 @@ let _lastHighlightSourceLine = null; let _lastHighlightTargetLine = null; function _reapplyCursorSyncHighlight() { - if (_lastHighlightSourceLine == null) return; + if (_lastHighlightTargetLine == null) return; const viewer = document.getElementById("viewer-content"); if (!viewer) return; - // Don't re-apply if viewer has focus (user is editing in viewer) if (viewer.contains(document.activeElement)) return; - _removeCursorHighlight(viewer); - const elements = viewer.querySelectorAll("[data-source-line]"); - let bestEl = null; - let bestLine = -1; - for (const el of elements) { - const srcLine = parseInt(el.getAttribute("data-source-line"), 10); - if (srcLine <= _lastHighlightSourceLine && srcLine > bestLine) { - bestLine = srcLine; - bestEl = el; - } - } - if (!bestEl) return; - const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine; - // Handle
    paragraph sub-line highlighting - if (bestEl.tagName === "P" && bestEl.querySelector("br")) { - const brOffset = targetLine - bestLine; - const brs = bestEl.querySelectorAll("br"); - const span = document.createElement("span"); - span.className = "cursor-sync-highlight cursor-sync-br-line"; - if (brOffset === 0) { - let node = bestEl.firstChild; - while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) { - const toMove = node; - node = node.nextSibling; - span.appendChild(toMove); - } - bestEl.insertBefore(span, bestEl.firstChild); - return; - } else if (brOffset > 0 && brOffset <= brs.length) { - const targetBr = brs[brOffset - 1]; - let next = targetBr.nextSibling; - while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) { - const toMove = next; - next = next.nextSibling; - span.appendChild(toMove); - } - targetBr.parentNode.insertBefore(span, targetBr.nextSibling); - return; - } - } - bestEl.classList.add("cursor-sync-highlight"); + // Re-use handleScrollToLine to apply the highlight (no scroll needed + // since the element is already in view after a re-render). + handleScrollToLine({ line: _lastHighlightTargetLine, fromScroll: false }); } // Re-apply cursor sync highlight after content re-renders (e.g. typing in CM) diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index 70d2841159..2e80649a85 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -10,7 +10,7 @@ import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-men import { initLinkPopover, destroyLinkPopover } from "./link-popover.js"; import { initImagePopover, destroyImagePopover } from "./image-popover.js"; import { initLangPicker, destroyLangPicker, isLangPickerDropdownOpen } from "./lang-picker.js"; -import { highlightCode, renderAfterHTML, normalizeCodeLanguages, _annotateCodeBlockLines } from "./viewer.js"; +import { highlightCode, renderAfterHTML, normalizeCodeLanguages } from "./viewer.js"; import { initMermaidEditor, destroyMermaidEditor, insertMermaidBlock, attachOverlays } from "./mermaid-editor.js"; const devLog = import.meta.env.DEV ? console.log.bind(console, "[editor]") : () => {}; @@ -1531,21 +1531,14 @@ export function convertToMarkdown(contentEl) { const clone = contentEl.cloneNode(true); clone.querySelectorAll(".code-copy-btn").forEach((btn) => btn.remove()); clone.querySelectorAll(".table-row-handles, .table-col-handles, .table-add-row-btn, .table-col-add-btn").forEach((el) => el.remove()); - // Fix code blocks: replace
    with \n and unwrap data-source-line spans. + // Fix code blocks: replace
    with \n and flatten to plain text. // In contenteditable, Enter inside a span inserts
    instead of \n. // Turndown needs plain text with \n for correct fenced code block output. clone.querySelectorAll("pre code").forEach((code) => { code.querySelectorAll("br").forEach((br) => { br.replaceWith("\n"); }); - // Unwrap data-source-line spans (inline them into the code element) - code.querySelectorAll("span[data-source-line]").forEach((span) => { - while (span.firstChild) { - span.parentNode.insertBefore(span.firstChild, span); - } - span.remove(); - }); - // Also unwrap any Prism token spans — get plain text for Turndown + // Unwrap Prism token spans — get plain text for Turndown code.textContent = code.textContent; }); // Unwrap

    inside

  • — marked renders "loose" lists with

    wrapping, @@ -1658,7 +1651,6 @@ export function initEditor() { const content = getContentEl(); if (!content) return; _updateSourceLineAttrs(content, cmMarkdown); - _annotateCodeBlockLines(); }); on("state:editMode", (editing) => { @@ -1798,9 +1790,7 @@ function enterEditMode(content) { if (code.className.includes("language-")) { Prism.highlightElement(code); } - // Step 4: re-annotate code block lines for scroll sync - _annotateCodeBlockLines(); - // Step 5: restore cursor + // Step 4: restore cursor restoreCursor(content, off); }, 500); } diff --git a/src-mdviewer/src/components/viewer.js b/src-mdviewer/src/components/viewer.js index 2eaaf1212c..47cf5f3148 100644 --- a/src-mdviewer/src/components/viewer.js +++ b/src-mdviewer/src/components/viewer.js @@ -192,122 +192,10 @@ export function highlightCode() { pre.setAttribute("autocapitalize", "off"); }); - // After Prism highlighting, add per-line data-source-line spans inside code blocks - _annotateCodeBlockLines(); + // Note: per-line data-source-line spans inside code blocks are no longer + // needed — line detection uses \n counting at click time instead. } -/** - * Wrap each line in highlighted code blocks with a span that has data-source-line, - * enabling per-line cursor sync for code blocks. - * Must run AFTER Prism highlighting since Prism replaces innerHTML. - */ -export function _annotateCodeBlockLines() { - // Process all pre elements, not just those with data-source-line - // (morphdom may strip the attr on first render) - const pres = document.querySelectorAll("#viewer-content pre"); - pres.forEach((pre) => { - const code = pre.querySelector("code"); - if (!code) return; - - let preSourceLine = parseInt(pre.getAttribute("data-source-line"), 10); - // If already annotated, check if the line numbers are still correct. - // If pre has a data-source-line and annotations exist, compare the - // expected first line with the actual first annotation. - const existingSpan = code.querySelector("span[data-source-line]"); - if (existingSpan) { - if (isNaN(preSourceLine)) return; // can't verify, keep existing - const expectedFirst = String(preSourceLine + 1); - if (existingSpan.getAttribute("data-source-line") === expectedFirst) { - // Annotations are up to date — still remove data-source-line - // from

     so clicks on empty space don't report the block
    -                // start line instead of the per-line annotation.
    -                pre.removeAttribute("data-source-line");
    -                return;
    -            }
    -            // Stale annotations — unwrap them before re-annotating
    -            code.querySelectorAll("span[data-source-line]").forEach((span) => {
    -                while (span.firstChild) {
    -                    span.parentNode.insertBefore(span.firstChild, span);
    -                }
    -                span.remove();
    -            });
    -            code.normalize(); // merge adjacent text nodes
    -        }
    -        if (isNaN(preSourceLine)) {
    -            // Fallback: find the nearest preceding sibling with data-source-line
    -            // and estimate this pre's line from it
    -            let prev = pre.previousElementSibling;
    -            while (prev && !prev.hasAttribute("data-source-line")) {
    -                prev = prev.previousElementSibling;
    -            }
    -            if (prev) {
    -                const prevLine = parseInt(prev.getAttribute("data-source-line"), 10);
    -                // Rough estimate: count text lines in the previous element
    -                const prevText = prev.textContent || "";
    -                const prevLines = (prevText.match(/\n/g) || []).length + 1;
    -                preSourceLine = prevLine + prevLines + 1; // +1 for blank line between
    -            } else {
    -                return; // Can't determine line, skip
    -            }
    -        }
    -        // Code content starts after the ``` line
    -        const codeStartLine = preSourceLine + 1;
    -
    -        // Split the code's child nodes by newlines and wrap each line
    -        const fragment = document.createDocumentFragment();
    -        let currentLine = document.createElement("span");
    -        currentLine.setAttribute("data-source-line", String(codeStartLine));
    -        let lineIdx = 0;
    -
    -        function processNode(node) {
    -            if (node.nodeType === Node.TEXT_NODE) {
    -                const text = node.textContent;
    -                const parts = text.split("\n");
    -                for (let i = 0; i < parts.length; i++) {
    -                    if (i > 0) {
    -                        // Close current line: append \n to END of current span
    -                        currentLine.appendChild(document.createTextNode("\n"));
    -                        fragment.appendChild(currentLine);
    -                        lineIdx++;
    -                        currentLine = document.createElement("span");
    -                        currentLine.setAttribute("data-source-line", String(codeStartLine + lineIdx));
    -                    }
    -                    if (parts[i]) {
    -                        currentLine.appendChild(document.createTextNode(parts[i]));
    -                    }
    -                }
    -            } else if (node.nodeType === Node.ELEMENT_NODE) {
    -                // Check if this element contains newlines
    -                const text = node.textContent;
    -                if (!text.includes("\n")) {
    -                    // No newlines — append the whole element to current line
    -                    currentLine.appendChild(node.cloneNode(true));
    -                } else {
    -                    // Element spans multiple lines — process children
    -                    for (const child of Array.from(node.childNodes)) {
    -                        processNode(child);
    -                    }
    -                }
    -            }
    -        }
    -
    -        const children = Array.from(code.childNodes);
    -        for (const child of children) {
    -            processNode(child);
    -        }
    -        // Append the last line
    -        if (currentLine.childNodes.length > 0) {
    -            fragment.appendChild(currentLine);
    -        }
    -
    -        code.innerHTML = "";
    -        code.appendChild(fragment);
    -
    -        // Remove data-source-line from 
     so clicking empty areas inside the
    -        // code block doesn't fall through to the block's start line
    -        pre.removeAttribute("data-source-line");
    -    });
    -}
     
     function addCopyButtons() {
         const pres = document.querySelectorAll("#viewer-content pre");
    
    From f54ffb6180436eb9420d15ffd03508f269703c77 Mon Sep 17 00:00:00 2001
    From: abose 
    Date: Sun, 5 Apr 2026 14:59:23 +0530
    Subject: [PATCH 09/13] fix(mdviewer): prefer specific list items and force
     light theme
    
    - Use >= instead of > in data-source-line search so child elements (li)
      override their parent containers (ul/ol) with the same line number
    - Force light theme in md viewer for paper-like appearance regardless of
      editor theme (infrastructure preserved for future dark mode support)
    ---
     src-mdviewer/src/bridge.js | 15 +++++++--------
     1 file changed, 7 insertions(+), 8 deletions(-)
    
    diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
    index 7021a13d90..de9f94d16e 100644
    --- a/src-mdviewer/src/bridge.js
    +++ b/src-mdviewer/src/bridge.js
    @@ -799,13 +799,12 @@ function handleReloadFile(data) {
     
     function handleSetTheme(data) {
         const { theme } = data;
    -    document.documentElement.setAttribute("data-theme", theme);
    -    if (theme === "dark") {
    -        document.documentElement.style.colorScheme = "dark";
    -    } else {
    -        document.documentElement.style.colorScheme = "light";
    -    }
    -    setState({ theme });
    +    // Force light theme for a paper-like appearance regardless of editor theme.
    +    // The theme infrastructure is preserved for future use.
    +    const appliedTheme = "light";
    +    document.documentElement.setAttribute("data-theme", appliedTheme);
    +    document.documentElement.style.colorScheme = "light";
    +    setState({ theme: appliedTheme });
     }
     
     function handleSetEditMode(data) {
    @@ -994,7 +993,7 @@ function handleScrollToLine(data) {
         let bestLine = -1;
         for (const el of elements) {
             const srcLine = parseInt(el.getAttribute("data-source-line"), 10);
    -        if (srcLine <= line && srcLine > bestLine) {
    +        if (srcLine <= line && srcLine >= bestLine) {
                 bestLine = srcLine;
                 bestEl = el;
             }
    
    From 7ec20bc72ef36f97a9c55c8fec1330ab98b0770f Mon Sep 17 00:00:00 2001
    From: abose 
    Date: Sun, 5 Apr 2026 15:28:32 +0530
    Subject: [PATCH 10/13] fix(mdviewer): scroll sync feedback loop, theme reflow,
     lang-picker scroll
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - Fix CM→viewer scroll sync in edit mode: use _scrollFromViewer flag to
      block only feedback loops, not all scroll-based sync
    - Skip highlight (not scroll) when viewer has focus to prevent cursor
      displacement while still allowing scroll restoration
    - Prevent theme reflow on reload by skipping if already applied
    - Hide lang-picker on scroll
    ---
     src-mdviewer/src/bridge.js                 | 21 +++++++++++----------
     src-mdviewer/src/components/lang-picker.js |  8 ++++++++
     2 files changed, 19 insertions(+), 10 deletions(-)
    
    diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
    index de9f94d16e..461c27e0bc 100644
    --- a/src-mdviewer/src/bridge.js
    +++ b/src-mdviewer/src/bridge.js
    @@ -14,6 +14,7 @@ let _syncId = 0;
     let _lastReceivedSyncId = -1;
     let _suppressContentChange = false;
     let _scrollFromCM = false;
    +let _scrollFromViewer = false;
     let _baseURL = "";
     let _cursorPosBeforeEdit = null; // cursor position before current edit batch
     let _cursorPosDirty = false; // true after content changes, reset when emitted
    @@ -426,6 +427,8 @@ export function initBridge() {
             const sourceLine = _getSourceLineFromElement(e.target);
             if (getState().editMode) {
                 if (sourceLine != null) {
    +                _scrollFromViewer = true;
    +                setTimeout(() => { _scrollFromViewer = false; }, 500);
                     sendToParent("mdviewrScrollSync", { sourceLine });
                 }
                 return;
    @@ -798,10 +801,11 @@ function handleReloadFile(data) {
     // --- Theme, edit mode, locale ---
     
     function handleSetTheme(data) {
    -    const { theme } = data;
         // Force light theme for a paper-like appearance regardless of editor theme.
         // The theme infrastructure is preserved for future use.
         const appliedTheme = "light";
    +    // Skip if already applied to avoid reflows that can reset scroll position
    +    if (document.documentElement.getAttribute("data-theme") === appliedTheme) return;
         document.documentElement.setAttribute("data-theme", appliedTheme);
         document.documentElement.style.colorScheme = "light";
         setState({ theme: appliedTheme });
    @@ -975,18 +979,14 @@ function handleScrollToLine(data) {
         const { line, fromScroll, tableCol } = data;
         if (line == null) return;
     
    -    // In edit mode, ignore scroll-based sync from CM to prevent feedback
    -    // loops (click in viewer → CM scroll → scroll sync back → viewer jumps).
    -    if (fromScroll && getState().editMode) return;
    +    // In edit mode, ignore scroll-based sync that originated from the viewer
    +    // itself (feedback loop: viewer click → CM scroll → scroll sync back).
    +    if (fromScroll && getState().editMode && _scrollFromViewer) return;
     
         const viewer = document.getElementById("viewer-content");
         if (!viewer) return;
     
    -    // In edit mode, skip CM cursor sync when the viewer has focus — the user
    -    // is actively editing and highlight span creation/removal would displace
    -    // the cursor.
    -    if (getState().editMode && viewer.contains(document.activeElement)) return;
    -    if (!viewer) return;
    +    const skipHighlight = getState().editMode && viewer.contains(document.activeElement);
     
         const elements = viewer.querySelectorAll("[data-source-line]");
         let bestEl = null;
    @@ -1089,7 +1089,8 @@ function handleScrollToLine(data) {
         setTimeout(() => { _scrollFromCM = false; }, 200);
     
         // Persistent highlight on the element corresponding to the CM cursor.
    -    // Only show when CM has focus (not when viewer has focus).
    +    // Skip highlight when viewer has focus to avoid cursor displacement.
    +    if (skipHighlight) return;
         _removeCursorHighlight(viewer);
     
         // For 
    paragraphs, wrap only the specific line's content in a diff --git a/src-mdviewer/src/components/lang-picker.js b/src-mdviewer/src/components/lang-picker.js index f8b93ad2f9..868aac3fcc 100644 --- a/src-mdviewer/src/components/lang-picker.js +++ b/src-mdviewer/src/components/lang-picker.js @@ -329,6 +329,12 @@ export function isLangPickerDropdownOpen() { return dropdownOpen; } +function onScroll() { + if (picker && picker.classList.contains("visible")) { + hide(); + } +} + export function initLangPicker(editorEl) { contentEl = editorEl; buildPicker(); @@ -338,6 +344,7 @@ export function initLangPicker(editorEl) { document.addEventListener("selectionchange", updatePosition); contentEl.addEventListener("mouseup", updatePosition); + document.addEventListener("scroll", onScroll, true); contentEl.addEventListener("keyup", updatePosition); document.addEventListener("mousedown", onDocumentMousedown); } @@ -351,6 +358,7 @@ export function destroyLangPicker() { } document.removeEventListener("selectionchange", updatePosition); document.removeEventListener("mousedown", onDocumentMousedown); + document.removeEventListener("scroll", onScroll, true); if (picker) picker.innerHTML = ""; contentEl = null; currentPre = null; From 03080b3337064636f28e3969cca9d23be8b6c171 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 16:14:45 +0530 Subject: [PATCH 11/13] fix(mdviewer): scroll restore, format bar in code blocks, test stability - Fix scroll position not restored after reload: send MDVIEWR_SOURCE_LINES only after edit-mode content changes, not during initial load/reload where it would cause reflows that reset scroll position - Force light theme via no-op handleSetTheme (HTML default is light) - Hide format bar when selection is inside code blocks - Add beforeEach cleanup in Document Cache test suite to prevent state leakage between scroll position tests --- src-mdviewer/src/bridge.js | 10 +++------- src-mdviewer/src/components/format-bar.js | 7 +++++++ .../Phoenix-live-preview/MarkdownSync.js | 16 ++-------------- test/spec/md-editor-integ-test.js | 9 ++++++++- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 461c27e0bc..54d29ed41a 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -800,15 +800,11 @@ function handleReloadFile(data) { // --- Theme, edit mode, locale --- -function handleSetTheme(data) { +function handleSetTheme(_data) { // Force light theme for a paper-like appearance regardless of editor theme. // The theme infrastructure is preserved for future use. - const appliedTheme = "light"; - // Skip if already applied to avoid reflows that can reset scroll position - if (document.documentElement.getAttribute("data-theme") === appliedTheme) return; - document.documentElement.setAttribute("data-theme", appliedTheme); - document.documentElement.style.colorScheme = "light"; - setState({ theme: appliedTheme }); + // Theme is set in index.html (data-theme="light") so no action needed here. + // Avoid setting attributes/styles to prevent reflows that reset scroll position. } function handleSetEditMode(data) { diff --git a/src-mdviewer/src/components/format-bar.js b/src-mdviewer/src/components/format-bar.js index ab6f0f13da..bd884a8d2c 100644 --- a/src-mdviewer/src/components/format-bar.js +++ b/src-mdviewer/src/components/format-bar.js @@ -224,6 +224,13 @@ function updatePosition() { hide(); return; } + // Skip if selection is inside a code block — formatting doesn't apply + const anchorEl = sel.anchorNode.nodeType === Node.ELEMENT_NODE + ? sel.anchorNode : sel.anchorNode.parentElement; + if (anchorEl && anchorEl.closest("pre")) { + hide(); + return; + } // Require some meaningful selection length const text = sel.toString(); if (text.length < 2) { diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index b510590b3a..29c7fe6b38 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -358,17 +358,12 @@ define(function (require, exports, module) { return; } - const mdText = _doc.getText(); iframeWindow.postMessage({ type: "MDVIEWR_SWITCH_FILE", - markdown: mdText, + markdown: _doc.getText(), baseURL: _baseURL, filePath: _doc.file.fullPath }, "*"); - iframeWindow.postMessage({ - type: "MDVIEWR_SOURCE_LINES", - markdown: mdText - }, "*"); } function _sendContent() { @@ -380,19 +375,12 @@ define(function (require, exports, module) { return; } - const mdText = _doc.getText(); iframeWindow.postMessage({ type: "MDVIEWR_SET_CONTENT", - markdown: mdText, + markdown: _doc.getText(), baseURL: _baseURL, filePath: _doc.file.fullPath }, "*"); - // Send source line mapping so the iframe can annotate sub-element - // lines (e.g.
    within paragraphs) on first load. - iframeWindow.postMessage({ - type: "MDVIEWR_SOURCE_LINES", - markdown: mdText - }, "*"); } function _sendUpdate(changeOrigin) { diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js index 64bf2b27d6..d0da270c3b 100644 --- a/test/spec/md-editor-integ-test.js +++ b/test/spec/md-editor-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, beforeAll, afterAll, awaitsFor, it, awaitsForDone, expect, awaits*/ +/*global describe, beforeAll, beforeEach, afterAll, awaitsFor, it, awaitsForDone, expect, awaits*/ define(function (require, exports, module) { @@ -629,6 +629,13 @@ define(function (require, exports, module) { } }, 30000); + beforeEach(async function () { + // Reset scroll and close files between tests to prevent state leakage + _setViewerScrollTop(0); + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "close all between cache tests"); + }, 10000); + it("should switch between MD files with viewer showing correct content", async function () { await _openMdFileAndWaitForPreview("doc1.md"); await awaitsFor(() => _getViewerH1Text().includes("Document One"), From ec112302739bda7425ca1120b6327f79a706f3ae Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 16:35:34 +0530 Subject: [PATCH 12/13] fix(mdviewer): checkbox styling, format bar in code blocks, cursor fixes - Fix task list checkbox styling: use li:has(> input[checkbox]) instead of missing task-list-item class (marked v17 doesn't add it) - Hide bullet points on task list items, add min-height for empty items - Hide format bar when selection is inside code blocks - Prevent cursor from being placed before checkbox in task list items - Backspace after checkbox removes it, converting to regular bullet --- src-mdviewer/src/components/editor.js | 52 ++++++++++++++++++++++++++- src-mdviewer/src/styles/markdown.css | 20 +++++++---- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index 2e80649a85..ea7cb2260f 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -2099,6 +2099,32 @@ function enterEditMode(content) { return; } + // Backspace right after a checkbox → remove the checkbox + if (e.key === "Backspace" && !mod) { + const sel2 = window.getSelection(); + if (sel2 && sel2.isCollapsed && sel2.rangeCount) { + const range2 = sel2.getRangeAt(0); + const li = (range2.startContainer.nodeType === Node.TEXT_NODE + ? range2.startContainer.parentElement : range2.startContainer)?.closest("li"); + if (li) { + const cb = li.querySelector('input[type="checkbox"]'); + if (cb) { + // Check if cursor is right after the checkbox + const cbIdx = Array.from(li.childNodes).indexOf(cb); + const isAfterCb = (range2.startContainer === li && range2.startOffset === cbIdx + 1) || + (range2.startContainer.nodeType === Node.TEXT_NODE && + range2.startOffset === 0 && range2.startContainer.previousSibling === cb); + if (isAfterCb) { + e.preventDefault(); + cb.remove(); + content.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + } + } + } + } + // Backspace at start of heading → convert to paragraph if (e.key === "Backspace" && !mod) { const sel2 = window.getSelection(); @@ -2340,7 +2366,31 @@ function enterEditMode(content) { }; content.addEventListener("keydown", keydownHandler); - selectionHandler = () => broadcastSelectionState(); + selectionHandler = () => { + // Prevent cursor from being placed before a checkbox in task list items. + // In contenteditable, the caret can land at offset 0 in an
  • before + // the , which looks wrong (cursor appears before the bullet). + const sel = window.getSelection(); + if (sel && sel.isCollapsed && sel.anchorNode) { + const li = sel.anchorNode.nodeType === Node.ELEMENT_NODE + ? sel.anchorNode.closest("li") + : sel.anchorNode.parentElement?.closest("li"); + if (li) { + const cb = li.querySelector('input[type="checkbox"]'); + if (cb && (sel.anchorNode === li && sel.anchorOffset === 0 || + sel.anchorNode === cb || cb.contains(sel.anchorNode))) { + const r = document.createRange(); + // Place cursor right after the checkbox + const cbIdx = Array.from(li.childNodes).indexOf(cb); + r.setStart(li, cbIdx + 1); + r.collapse(true); + sel.removeAllRanges(); + sel.addRange(r); + } + } + } + broadcastSelectionState(); + }; document.addEventListener("selectionchange", selectionHandler); selectionFallbackMouseUp = () => broadcastSelectionState(); selectionFallbackKeyUp = () => broadcastSelectionState(); diff --git a/src-mdviewer/src/styles/markdown.css b/src-mdviewer/src/styles/markdown.css index fd5f97b2a1..22289a69ee 100644 --- a/src-mdviewer/src/styles/markdown.css +++ b/src-mdviewer/src/styles/markdown.css @@ -313,24 +313,32 @@ } /* Task lists */ -.markdown-body .contains-task-list { +/* Task list: target li elements that contain a checkbox directly, + since marked v17 does not add task-list-item/contains-task-list classes. */ +.markdown-body li:has(> input[type="checkbox"]) { list-style: none; - padding-inline-start: 0; -} - -.markdown-body .task-list-item { position: relative; padding-inline-start: 1.75em; + margin-inline-start: -1.75em; + min-height: 1.5em; } -.markdown-body .task-list-item input[type="checkbox"] { +.markdown-body li > input[type="checkbox"] { position: absolute; inset-inline-start: 0; top: 0.3em; width: 1em; height: 1em; + margin: 0; accent-color: var(--color-accent); pointer-events: none; + cursor: default; +} + +/* In edit mode, checkboxes are enabled */ +.markdown-body li > input[type="checkbox"]:not([disabled]) { + pointer-events: auto; + cursor: pointer; } /* Horizontal rule */ From 3dc6600f252bb6e92f74119ef48dc1e3c78ff578 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Apr 2026 18:28:03 +0530 Subject: [PATCH 13/13] fix(mdviewer): preserve scroll position on file switch in edit mode Suppress handleScrollToLine for 500ms after file switch to prevent CM cursor activity from overriding the doc cache's restored scroll position. --- src-mdviewer/src/bridge.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 54d29ed41a..102901dc9f 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -15,6 +15,7 @@ let _lastReceivedSyncId = -1; let _suppressContentChange = false; let _scrollFromCM = false; let _scrollFromViewer = false; +let _suppressScrollToLine = false; let _baseURL = ""; let _cursorPosBeforeEdit = null; // cursor position before current edit batch let _cursorPosDirty = false; // true after content changes, reset when emitted @@ -657,6 +658,11 @@ function handleSwitchFile(data) { _suppressContentChange = true; + // Suppress scroll-to-line from CM during file switch — the doc cache + // restores the correct scroll position; CM cursor activity would override it. + _suppressScrollToLine = true; + setTimeout(() => { _suppressScrollToLine = false; }, 500); + // Edit mode is global for the md editor frame — preserve it across file switches const wasEditMode = getState().editMode; @@ -975,6 +981,9 @@ function handleScrollToLine(data) { const { line, fromScroll, tableCol } = data; if (line == null) return; + // Suppress during file switch — doc cache restores the correct scroll + if (_suppressScrollToLine) return; + // In edit mode, ignore scroll-based sync that originated from the viewer // itself (feedback loop: viewer click → CM scroll → scroll sync back). if (fromScroll && getState().editMode && _scrollFromViewer) return;