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 @@
+
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
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
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"),