@@ -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