Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
139 changes: 128 additions & 11 deletions src-mdviewer/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ export function initBridge() {
case "MDVIEWR_RERENDER_CONTENT":
handleRerenderContent(data);
break;
case "MDVIEWR_SOURCE_LINES":
emit("editor:source-lines", data.markdown);
break;
case "MDVIEWR_TOOLBAR_STATE":
if (data.state) {
emit("editor:selection-state", data.state);
Expand Down Expand Up @@ -925,10 +928,26 @@ function _restoreCursorPosition(contentEl, pos) {
}

function _getSourceLineFromElement(el) {
// Use the current selection to determine exact position within <br> paragraphs
const sel = window.getSelection();
const cursorNode = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null;

while (el && el !== document.body) {
const attr = el.getAttribute && el.getAttribute("data-source-line");
if (attr != null) {
return parseInt(attr, 10);
let line = parseInt(attr, 10);
// For paragraphs with <br> (soft line breaks), count how many
// <br> elements precede the cursor to get the exact CM line.
if (el.tagName === "P" && cursorNode && el.querySelector("br")) {
const brs = el.querySelectorAll("br");
for (const br of brs) {
const pos = br.compareDocumentPosition(cursorNode);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) {
line++;
}
}
}
return line;
}
el = el.parentElement;
}
Expand All @@ -939,9 +958,19 @@ function handleScrollToLine(data) {
const { line, fromScroll, tableCol } = data;
if (line == null) return;

// In edit mode, ignore scroll-based sync from CM to prevent feedback
// loops (click in viewer → CM scroll → scroll sync back → viewer jumps).
if (fromScroll && getState().editMode) return;

const viewer = document.getElementById("viewer-content");
if (!viewer) return;

// In edit mode, skip CM cursor sync when the viewer has focus — the user
// is actively editing and highlight span creation/removal would displace
// the cursor.
if (getState().editMode && viewer.contains(document.activeElement)) return;
if (!viewer) return;

const elements = viewer.querySelectorAll("[data-source-line]");
let bestEl = null;
let bestLine = -1;
Expand All @@ -964,42 +993,104 @@ function handleScrollToLine(data) {
}
}

// For paragraphs with <br> (soft line breaks), find the specific visual
// line within the paragraph by counting <br> elements. The target line
// minus the paragraph's start line gives the <br> offset.
let scrollTarget = bestEl;
if (bestEl.tagName === "P" && bestLine < line) {
const brOffset = line - bestLine;
const brs = bestEl.querySelectorAll("br");
if (brOffset > 0 && brOffset <= brs.length) {
// Use the <br> element as scroll target — it's at the right
// vertical position for the specific line within the paragraph.
scrollTarget = brs[brOffset - 1];
}
}

const container = document.getElementById("app-viewer");
if (!container) return;
const containerRect = container.getBoundingClientRect();
const elRect = bestEl.getBoundingClientRect();
const elRect = scrollTarget.getBoundingClientRect();

// Suppress viewer→CM scroll feedback for any CM-initiated scroll
_scrollFromCM = true;
if (fromScroll) {
// Sync scroll: always align to top, even if visible
bestEl.scrollIntoView({ behavior: "instant", block: "start" });
scrollTarget.scrollIntoView({ behavior: "instant", block: "start" });
} else {
// Cursor-based scroll: only scroll if not visible, center it
const isVisible = elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
if (!isVisible) {
bestEl.scrollIntoView({ behavior: "instant", block: "center" });
scrollTarget.scrollIntoView({ behavior: "instant", block: "center" });
}
}
setTimeout(() => { _scrollFromCM = false; }, 200);

// Persistent highlight on the element corresponding to the CM cursor.
// Only show when CM has focus (not when viewer has focus).
const prev = viewer.querySelector(".cursor-sync-highlight");
if (prev) { prev.classList.remove("cursor-sync-highlight"); }
bestEl.classList.add("cursor-sync-highlight");
_removeCursorHighlight(viewer);

// For <br> paragraphs, wrap only the specific line's content in a
// highlight span instead of highlighting the whole <p>.
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 <br>
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 <br>
const targetBr = brs[brOffset - 1];
let next = targetBr.nextSibling;
while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) {
const toMove = next;
next = next.nextSibling;
span.appendChild(toMove);
}
targetBr.parentNode.insertBefore(span, targetBr.nextSibling);
} else {
bestEl.classList.add("cursor-sync-highlight");
}
} else {
bestEl.classList.add("cursor-sync-highlight");
}
_lastHighlightSourceLine = bestLine;
_lastHighlightTargetLine = line;
}

function _removeCursorHighlight(viewer) {
const prev = viewer.querySelector(".cursor-sync-highlight");
if (!prev) return;
// If highlight was a wrapper span for a <br> 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;
const viewer = document.getElementById("viewer-content");
if (!viewer) return;
// Don't re-apply if viewer has focus (user is editing in viewer)
if (viewer.contains(document.activeElement)) return;
_removeCursorHighlight(viewer);
const elements = viewer.querySelectorAll("[data-source-line]");
let bestEl = null;
let bestLine = -1;
Expand All @@ -1010,9 +1101,36 @@ function _reapplyCursorSyncHighlight() {
bestEl = el;
}
}
if (bestEl) {
bestEl.classList.add("cursor-sync-highlight");
if (!bestEl) return;
const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine;
// Handle <br> paragraph sub-line highlighting
if (bestEl.tagName === "P" && bestEl.querySelector("br")) {
const brOffset = targetLine - bestLine;
const brs = bestEl.querySelectorAll("br");
const span = document.createElement("span");
span.className = "cursor-sync-highlight cursor-sync-br-line";
if (brOffset === 0) {
let node = bestEl.firstChild;
while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) {
const toMove = node;
node = node.nextSibling;
span.appendChild(toMove);
}
bestEl.insertBefore(span, bestEl.firstChild);
return;
} else if (brOffset > 0 && brOffset <= brs.length) {
const targetBr = brs[brOffset - 1];
let next = targetBr.nextSibling;
while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) {
const toMove = next;
next = next.nextSibling;
span.appendChild(toMove);
}
targetBr.parentNode.insertBefore(span, targetBr.nextSibling);
return;
}
}
bestEl.classList.add("cursor-sync-highlight");
}

// Re-apply cursor sync highlight after content re-renders (e.g. typing in CM)
Expand All @@ -1025,8 +1143,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;
}
});
Expand Down
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
Loading
Loading