Skip to content

Commit 55d244b

Browse files
committed
feat(mdviewer): blockquote toggle and nest/unnest with Tab
Quote toolbar button now lifts only the cursor's paragraph out of a blockquote (splitting it so neighboring quoted lines stay quoted), instead of unwrapping the whole blockquote. Tab/Shift+Tab inside a blockquote nest deeper / lift one level. Lists and tables are checked first so their existing Tab behavior is unchanged. Normalize blockquotes that contain loose text (no block wrapper) by wrapping their contents in <p> before manipulation, and handle the case where the cursor's anchorNode is the blockquote element itself (which happens right after execCommand wraps a paragraph) by resolving to the correct child via anchorOffset. Without this, the toggle stops working after an unquote → re-quote sequence.
1 parent 1024181 commit 55d244b

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

src-mdviewer/src/components/editor.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,69 @@ export function executeFormat(contentEl, command, value) {
403403
document.execCommand(command, false, null);
404404
break;
405405
case "formatBlock": {
406+
// Toggle blockquote: if cursor is inside a blockquote and user
407+
// clicks the blockquote button, lift only the cursor's block out
408+
// (splitting the blockquote so other lines stay quoted).
409+
if (value === "<blockquote>") {
410+
const sel0 = window.getSelection();
411+
let n0 = sel0?.anchorNode;
412+
if (n0?.nodeType === Node.TEXT_NODE) n0 = n0.parentElement;
413+
const innerBq = n0?.closest("blockquote");
414+
if (innerBq && contentEl.contains(innerBq)) {
415+
// Normalize: if blockquote has loose text/inline children
416+
// (no block element wrapper), wrap them in a <p> so we have
417+
// a stable cursorBlock to lift out.
418+
if (!innerBq.querySelector("p, h1, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table, hr, div")) {
419+
const wrap = document.createElement("p");
420+
while (innerBq.firstChild) wrap.appendChild(innerBq.firstChild);
421+
innerBq.appendChild(wrap);
422+
}
423+
let cursorBlock = n0;
424+
// If anchor is the blockquote itself (e.g. right after
425+
// execCommand wraps), pick the child at anchor offset.
426+
if (cursorBlock === innerBq) {
427+
const offset = sel0.anchorOffset || 0;
428+
cursorBlock = innerBq.childNodes[offset]
429+
|| innerBq.firstElementChild;
430+
if (cursorBlock && cursorBlock.nodeType !== Node.ELEMENT_NODE) {
431+
cursorBlock = innerBq.firstElementChild;
432+
}
433+
} else {
434+
while (cursorBlock && cursorBlock.parentNode !== innerBq) {
435+
cursorBlock = cursorBlock.parentNode;
436+
}
437+
}
438+
if (cursorBlock) {
439+
const parent = innerBq.parentNode;
440+
const afterNodes = [];
441+
let nx = cursorBlock.nextSibling;
442+
while (nx) {
443+
const next = nx.nextSibling;
444+
afterNodes.push(nx);
445+
nx = next;
446+
}
447+
parent.insertBefore(cursorBlock, innerBq.nextSibling);
448+
if (afterNodes.length > 0) {
449+
const newBq = document.createElement("blockquote");
450+
for (const an of afterNodes) newBq.appendChild(an);
451+
parent.insertBefore(newBq, cursorBlock.nextSibling);
452+
}
453+
if (!innerBq.querySelector("*") && !innerBq.textContent.trim()) {
454+
innerBq.remove();
455+
}
456+
const sel1 = window.getSelection();
457+
if (sel1 && contentEl.contains(cursorBlock)) {
458+
const r = document.createRange();
459+
r.selectNodeContents(cursorBlock);
460+
r.collapse(false);
461+
sel1.removeAllRanges();
462+
sel1.addRange(r);
463+
}
464+
contentEl.dispatchEvent(new Event("input", { bubbles: true }));
465+
break;
466+
}
467+
}
468+
}
406469
document.execCommand("formatBlock", false, value);
407470
// After formatBlock on an empty element, the browser may lose
408471
// cursor position. Find the new block and place cursor inside it.
@@ -2071,6 +2134,74 @@ function enterEditMode(content) {
20712134
return;
20722135
}
20732136

2137+
// Blockquote nesting: Tab nests deeper, Shift+Tab lifts one level.
2138+
// Only triggers when cursor is in a blockquote AND not in a list/table
2139+
// (those were handled above and returned early).
2140+
{
2141+
const sel4 = window.getSelection();
2142+
let cursorNode = sel4?.anchorNode;
2143+
if (cursorNode?.nodeType === Node.TEXT_NODE) cursorNode = cursorNode.parentElement;
2144+
const innerBq = cursorNode?.closest("blockquote");
2145+
if (innerBq && content.contains(innerBq)) {
2146+
// Normalize: if blockquote has loose text children only,
2147+
// wrap them in a <p> so we have a stable block to nest.
2148+
if (!innerBq.querySelector("p, h1, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table, hr, div")) {
2149+
const wrap = document.createElement("p");
2150+
while (innerBq.firstChild) wrap.appendChild(innerBq.firstChild);
2151+
innerBq.appendChild(wrap);
2152+
}
2153+
// Find the direct child of innerBq that contains the cursor
2154+
let cursorBlock = cursorNode;
2155+
if (cursorBlock === innerBq) {
2156+
const offset = sel4.anchorOffset || 0;
2157+
cursorBlock = innerBq.childNodes[offset]
2158+
|| innerBq.firstElementChild;
2159+
if (cursorBlock && cursorBlock.nodeType !== Node.ELEMENT_NODE) {
2160+
cursorBlock = innerBq.firstElementChild;
2161+
}
2162+
} else {
2163+
while (cursorBlock && cursorBlock.parentNode !== innerBq) {
2164+
cursorBlock = cursorBlock.parentNode;
2165+
}
2166+
}
2167+
if (cursorBlock) {
2168+
e.preventDefault();
2169+
flushSnapshot(content);
2170+
const savedOffset = getCursorOffset(cursorBlock);
2171+
if (e.shiftKey) {
2172+
// Lift: split innerBq around cursorBlock and move it out
2173+
const parent = innerBq.parentNode;
2174+
const afterNodes = [];
2175+
let n = cursorBlock.nextSibling;
2176+
while (n) {
2177+
const next = n.nextSibling;
2178+
afterNodes.push(n);
2179+
n = next;
2180+
}
2181+
parent.insertBefore(cursorBlock, innerBq.nextSibling);
2182+
if (afterNodes.length > 0) {
2183+
const newBq = document.createElement("blockquote");
2184+
for (const an of afterNodes) newBq.appendChild(an);
2185+
parent.insertBefore(newBq, cursorBlock.nextSibling);
2186+
}
2187+
// Remove innerBq if it has no element children left
2188+
// (only stray whitespace text nodes from formatting).
2189+
if (!innerBq.querySelector("*") && !innerBq.textContent.trim()) {
2190+
innerBq.remove();
2191+
}
2192+
} else {
2193+
// Nest deeper: wrap cursorBlock in a new blockquote
2194+
const newBq = document.createElement("blockquote");
2195+
innerBq.insertBefore(newBq, cursorBlock);
2196+
newBq.appendChild(cursorBlock);
2197+
}
2198+
restoreCursor(cursorBlock, savedOffset);
2199+
content.dispatchEvent(new Event("input", { bubbles: true }));
2200+
return;
2201+
}
2202+
}
2203+
}
2204+
20742205
// Regular text: insert 4 spaces
20752206
e.preventDefault();
20762207
if (!e.shiftKey) {

0 commit comments

Comments
 (0)