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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/API-Reference/command/Menus.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,13 @@ Commands, which control a MenuItem's name, enabled state, and checked state.
| --- | --- | --- |
| id | <code>string</code> | 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" |

<a name="_initHamburgerMenu"></a>

## \_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
<a name="event_EVENT_BEFORE_CONTEXT_MENU_OPEN"></a>

## "EVENT_BEFORE_CONTEXT_MENU_OPEN"
Expand Down
7 changes: 7 additions & 0 deletions src-mdviewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: phtauri: https: http:;
font-src 'self' data:;
connect-src 'self';" />
<script type="module" src="/src/embedded-main.js"></script>
</head>
<body>
Expand Down
278 changes: 243 additions & 35 deletions src-mdviewer/src/bridge.js

Large diffs are not rendered by default.

42 changes: 32 additions & 10 deletions src-mdviewer/src/components/context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });

Expand Down
215 changes: 209 additions & 6 deletions src-mdviewer/src/components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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); } },
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 <br> with \n and flatten to plain text.
// In contenteditable, Enter inside a span inserts <br> 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 <p> inside <li> — marked renders "loose" lists with <p> wrapping,
// but Turndown converts that to blank lines between items. Unwrapping makes tight lists.
clone.querySelectorAll("li > p").forEach((p) => {
Expand Down Expand Up @@ -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 <br> (soft line breaks) are a single block —
// the data-source-line on the <p> points to the block's start.
mdLineIdx++;
while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") {
mdLineIdx++;
}
}
}
}

function emitContentChange(contentEl) {
clearTimeout(contentChangeTimer);
contentChangeTimer = setTimeout(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 <br> → \n (contenteditable inserts <br>
// for Enter, but Prism reads textContent which ignores <br>).
// Then re-run Prism with cursor preservation.
clearTimeout(codeHighlightTimer);
codeHighlightTimer = setTimeout(() => {
const code = pre.querySelector("code");
if (!code) return;
// Step 1: normalize <br> → \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);
}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 <li> before
// the <input>, 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();
Expand Down
7 changes: 7 additions & 0 deletions src-mdviewer/src/components/format-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading