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
27 changes: 24 additions & 3 deletions src-mdviewer/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let _lastReceivedSyncId = -1;
let _suppressContentChange = false;
let _scrollFromCM = false;
let _scrollFromViewer = false;
let _scrollFromViewerTimer = null;
let _suppressScrollToLine = false;
let _baseURL = "";
let _cursorPosBeforeEdit = null; // cursor position before current edit batch
Expand Down Expand Up @@ -513,6 +514,11 @@ export function initBridge() {
}
if (bestEl) {
const sourceLine = parseInt(bestEl.getAttribute("data-source-line"), 10);
// Mark the viewer as the scroll origin so CM's echo back
// through handleScrollToLine is suppressed.
_scrollFromViewer = true;
if (_scrollFromViewerTimer) clearTimeout(_scrollFromViewerTimer);
_scrollFromViewerTimer = setTimeout(() => { _scrollFromViewer = false; }, 400);
sendToParent("mdviewrScrollSync", { sourceLine, fromScroll: true });
}
});
Expand Down Expand Up @@ -1057,9 +1063,9 @@ function handleScrollToLine(data) {
// 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;
// Ignore scroll-based sync that originated from the viewer itself
// (feedback loop: viewer scroll/click → CM scroll → scroll sync back).
if (fromScroll && _scrollFromViewer) return;

const viewer = document.getElementById("viewer-content");
if (!viewer) return;
Expand Down Expand Up @@ -1401,8 +1407,23 @@ function _sendSelectionToParent() {
}, 200);
}

// Events the iframe initiates that can cause CM to scroll. We mark the iframe
// as the scroll origin so CM's resulting scroll-handler echo (forwarded back as
// MDVIEWR_SCROLL_TO_LINE) gets suppressed by the guard in handleScrollToLine.
const _viewerInitiatedScrollEvents = new Set([
"mdviewrScrollSync",
"mdviewrCursorLine",
"mdviewrSelectionSync",
"embeddedIframeFocusEditor"
]);

function sendToParent(eventName, payload) {
if (!window.parent || window.parent === window) return;
if (_viewerInitiatedScrollEvents.has(eventName)) {
_scrollFromViewer = true;
if (_scrollFromViewerTimer) clearTimeout(_scrollFromViewerTimer);
_scrollFromViewerTimer = setTimeout(() => { _scrollFromViewer = false; }, 400);
}
window.parent.postMessage({
type: "MDVIEWR_EVENT",
eventName,
Expand Down
131 changes: 131 additions & 0 deletions src-mdviewer/src/components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,69 @@ export function executeFormat(contentEl, command, value) {
document.execCommand(command, false, null);
break;
case "formatBlock": {
// Toggle blockquote: if cursor is inside a blockquote and user
// clicks the blockquote button, lift only the cursor's block out
// (splitting the blockquote so other lines stay quoted).
if (value === "<blockquote>") {
const sel0 = window.getSelection();
let n0 = sel0?.anchorNode;
if (n0?.nodeType === Node.TEXT_NODE) n0 = n0.parentElement;
const innerBq = n0?.closest("blockquote");
if (innerBq && contentEl.contains(innerBq)) {
// Normalize: if blockquote has loose text/inline children
// (no block element wrapper), wrap them in a <p> so we have
// a stable cursorBlock to lift out.
if (!innerBq.querySelector("p, h1, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table, hr, div")) {
const wrap = document.createElement("p");
while (innerBq.firstChild) wrap.appendChild(innerBq.firstChild);
innerBq.appendChild(wrap);
}
let cursorBlock = n0;
// If anchor is the blockquote itself (e.g. right after
// execCommand wraps), pick the child at anchor offset.
if (cursorBlock === innerBq) {
const offset = sel0.anchorOffset || 0;
cursorBlock = innerBq.childNodes[offset]
|| innerBq.firstElementChild;
if (cursorBlock && cursorBlock.nodeType !== Node.ELEMENT_NODE) {
cursorBlock = innerBq.firstElementChild;
}
} else {
while (cursorBlock && cursorBlock.parentNode !== innerBq) {
cursorBlock = cursorBlock.parentNode;
}
}
if (cursorBlock) {
const parent = innerBq.parentNode;
const afterNodes = [];
let nx = cursorBlock.nextSibling;
while (nx) {
const next = nx.nextSibling;
afterNodes.push(nx);
nx = next;
}
parent.insertBefore(cursorBlock, innerBq.nextSibling);
if (afterNodes.length > 0) {
const newBq = document.createElement("blockquote");
for (const an of afterNodes) newBq.appendChild(an);
parent.insertBefore(newBq, cursorBlock.nextSibling);
}
if (!innerBq.querySelector("*") && !innerBq.textContent.trim()) {
innerBq.remove();
}
const sel1 = window.getSelection();
if (sel1 && contentEl.contains(cursorBlock)) {
const r = document.createRange();
r.selectNodeContents(cursorBlock);
r.collapse(false);
sel1.removeAllRanges();
sel1.addRange(r);
}
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
break;
}
}
}
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.
Expand Down Expand Up @@ -2071,6 +2134,74 @@ function enterEditMode(content) {
return;
}

// Blockquote nesting: Tab nests deeper, Shift+Tab lifts one level.
// Only triggers when cursor is in a blockquote AND not in a list/table
// (those were handled above and returned early).
{
const sel4 = window.getSelection();
let cursorNode = sel4?.anchorNode;
if (cursorNode?.nodeType === Node.TEXT_NODE) cursorNode = cursorNode.parentElement;
const innerBq = cursorNode?.closest("blockquote");
if (innerBq && content.contains(innerBq)) {
// Normalize: if blockquote has loose text children only,
// wrap them in a <p> so we have a stable block to nest.
if (!innerBq.querySelector("p, h1, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table, hr, div")) {
const wrap = document.createElement("p");
while (innerBq.firstChild) wrap.appendChild(innerBq.firstChild);
innerBq.appendChild(wrap);
}
// Find the direct child of innerBq that contains the cursor
let cursorBlock = cursorNode;
if (cursorBlock === innerBq) {
const offset = sel4.anchorOffset || 0;
cursorBlock = innerBq.childNodes[offset]
|| innerBq.firstElementChild;
if (cursorBlock && cursorBlock.nodeType !== Node.ELEMENT_NODE) {
cursorBlock = innerBq.firstElementChild;
}
} else {
while (cursorBlock && cursorBlock.parentNode !== innerBq) {
cursorBlock = cursorBlock.parentNode;
}
}
if (cursorBlock) {
e.preventDefault();
flushSnapshot(content);
const savedOffset = getCursorOffset(cursorBlock);
if (e.shiftKey) {
// Lift: split innerBq around cursorBlock and move it out
const parent = innerBq.parentNode;
const afterNodes = [];
let n = cursorBlock.nextSibling;
while (n) {
const next = n.nextSibling;
afterNodes.push(n);
n = next;
}
parent.insertBefore(cursorBlock, innerBq.nextSibling);
if (afterNodes.length > 0) {
const newBq = document.createElement("blockquote");
for (const an of afterNodes) newBq.appendChild(an);
parent.insertBefore(newBq, cursorBlock.nextSibling);
}
// Remove innerBq if it has no element children left
// (only stray whitespace text nodes from formatting).
if (!innerBq.querySelector("*") && !innerBq.textContent.trim()) {
innerBq.remove();
}
} else {
// Nest deeper: wrap cursorBlock in a new blockquote
const newBq = document.createElement("blockquote");
innerBq.insertBefore(newBq, cursorBlock);
newBq.appendChild(cursorBlock);
}
restoreCursor(cursorBlock, savedOffset);
content.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
}
}

// Regular text: insert 4 spaces
e.preventDefault();
if (!e.shiftKey) {
Expand Down
Loading
Loading