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-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 @@ + diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 72392b521b..102901dc9f 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -14,6 +14,8 @@ let _syncId = 0; 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 @@ -282,6 +284,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); @@ -423,6 +428,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; @@ -651,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; @@ -794,15 +806,11 @@ function handleReloadFile(data) { // --- Theme, edit mode, locale --- -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 }); +function handleSetTheme(_data) { + // Force light theme for a paper-like appearance regardless of editor theme. + // The theme infrastructure is preserved for future use. + // 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) { @@ -925,10 +933,44 @@ 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); + 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
+                    }
+                }
+            }
+            return line;
         }
         el = el.parentElement;
     }
@@ -939,15 +981,24 @@ 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;
+
     const viewer = document.getElementById("viewer-content");
     if (!viewer) return;
 
+    const skipHighlight = getState().editMode && viewer.contains(document.activeElement);
+
     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 <= line && srcLine > bestLine) {
+        if (srcLine <= line && srcLine >= bestLine) {
             bestLine = srcLine;
             bestEl = el;
         }
@@ -964,55 +1015,213 @@ function handleScrollToLine(data) {
         }
     }
 
+    // For multi-line blocks, find the specific visual line to scroll to.
+    let scrollTarget = bestEl;
+    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(); + }); + } + } + } + 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"); + // 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 + // 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 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"); + } _lastHighlightSourceLine = bestLine; + _lastHighlightTargetLine = line; +} + +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) { + 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; + 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; - 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) { - 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) @@ -1025,8 +1234,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/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 69be97e081..ea7cb2260f 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); @@ -1024,7 +1048,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 +1095,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) => { @@ -1470,6 +1531,16 @@ 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 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 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) => { @@ -1497,6 +1568,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(() => { @@ -1512,6 +1644,15 @@ 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); + }); + on("state:editMode", (editing) => { const content = getContentEl(); if (!content) return; @@ -1553,6 +1694,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"); @@ -1626,19 +1770,28 @@ 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: restore cursor + restoreCursor(content, off); }, 500); } } @@ -1946,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(); @@ -2187,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/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-mdviewer/src/components/lang-picker.js b/src-mdviewer/src/components/lang-picker.js index aa06b524dd..868aac3fcc 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;
       }
    @@ -318,6 +329,12 @@ export function isLangPickerDropdownOpen() {
       return dropdownOpen;
     }
     
    +function onScroll() {
    +  if (picker && picker.classList.contains("visible")) {
    +    hide();
    +  }
    +}
    +
     export function initLangPicker(editorEl) {
       contentEl = editorEl;
       buildPicker();
    @@ -327,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);
     }
    @@ -340,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;
    diff --git a/src-mdviewer/src/components/viewer.js b/src-mdviewer/src/components/viewer.js
    index b004e810fc..47cf5f3148 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
    @@ -191,101 +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.
    - */
    -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 (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 span, start new one with the newline inside it
    -                        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]));
    -                    }
    -                }
    -            } 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");
    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 {
    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 */
    diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
    index 233af1959c..29c7fe6b38 100644
    --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
    +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
    @@ -501,6 +501,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);
    diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js
    index b9af7cd2fe..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 = `
         
         `;
     
    @@ -869,6 +870,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 +1185,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);
    +        });
         });
     });
    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"),