@@ -1227,5 +1227,270 @@ define(function (require, exports, module) {
12271227 }
12281228 } , 10000 ) ;
12291229 } ) ;
1230+
1231+ describe ( "Heading Editing" , function ( ) {
1232+
1233+ const ORIGINAL_HEADING_MD = "# Heading One\n\nSome paragraph text.\n\n" +
1234+ "## Heading Two\n\nAnother paragraph.\n\n### Heading Three\n\nFinal paragraph.\n" ;
1235+
1236+ beforeAll ( async function ( ) {
1237+ await awaitsForDone ( SpecRunnerUtils . openProjectFiles ( [ "heading-test.md" ] ) ,
1238+ "open heading-test.md" ) ;
1239+ await _waitForMdPreviewReady ( EditorManager . getActiveEditor ( ) ) ;
1240+ await _enterEditMode ( ) ;
1241+ } , 15000 ) ;
1242+
1243+ beforeEach ( async function ( ) {
1244+ const editor = EditorManager . getActiveEditor ( ) ;
1245+ if ( editor ) {
1246+ editor . document . setText ( ORIGINAL_HEADING_MD ) ;
1247+ await awaitsFor ( ( ) => {
1248+ const win = _getMdIFrameWin ( ) ;
1249+ return win && win . __getCurrentContent &&
1250+ win . __getCurrentContent ( ) === ORIGINAL_HEADING_MD ;
1251+ } , "viewer to sync with reset content" ) ;
1252+ const win = _getMdIFrameWin ( ) ;
1253+ await awaitsFor ( ( ) => {
1254+ return win && win . __isSuppressingContentChange &&
1255+ ! win . __isSuppressingContentChange ( ) ;
1256+ } , "content suppression to clear" ) ;
1257+ if ( win && win . __setEditModeForTest ) {
1258+ win . __setEditModeForTest ( false ) ;
1259+ win . __setEditModeForTest ( true ) ;
1260+ }
1261+ await awaitsFor ( ( ) => {
1262+ const mdDoc = _getMdIFrameDoc ( ) ;
1263+ const content = mdDoc && mdDoc . getElementById ( "viewer-content" ) ;
1264+ return content && content . classList . contains ( "editing" ) ;
1265+ } , "edit mode to reactivate" ) ;
1266+ }
1267+ } ) ;
1268+
1269+ afterAll ( async function ( ) {
1270+ const editor = EditorManager . getActiveEditor ( ) ;
1271+ if ( editor ) {
1272+ editor . document . setText ( ORIGINAL_HEADING_MD ) ;
1273+ }
1274+ await awaitsForDone ( CommandManager . execute ( Commands . FILE_CLOSE , { _forceClose : true } ) ,
1275+ "force close heading-test.md" ) ;
1276+ } ) ;
1277+
1278+ function _findHeading ( tag , text ) {
1279+ const mdDoc = _getMdIFrameDoc ( ) ;
1280+ const headings = mdDoc . querySelectorAll ( "#viewer-content " + tag ) ;
1281+ for ( const h of headings ) {
1282+ if ( h . textContent . includes ( text ) ) {
1283+ return h ;
1284+ }
1285+ }
1286+ return null ;
1287+ }
1288+
1289+ function _placeCursorAt ( el , offset ) {
1290+ const mdDoc = _getMdIFrameDoc ( ) ;
1291+ const win = _getMdIFrameWin ( ) ;
1292+ const range = mdDoc . createRange ( ) ;
1293+ const textNode = el . firstChild && el . firstChild . nodeType === Node . TEXT_NODE
1294+ ? el . firstChild : el ;
1295+ if ( textNode . nodeType === Node . TEXT_NODE ) {
1296+ range . setStart ( textNode , Math . min ( offset , textNode . textContent . length ) ) ;
1297+ } else {
1298+ range . setStart ( el , 0 ) ;
1299+ }
1300+ range . collapse ( true ) ;
1301+ const sel = win . getSelection ( ) ;
1302+ sel . removeAllRanges ( ) ;
1303+ sel . addRange ( range ) ;
1304+ }
1305+
1306+ function _placeCursorAtEnd ( el ) {
1307+ const mdDoc = _getMdIFrameDoc ( ) ;
1308+ const win = _getMdIFrameWin ( ) ;
1309+ const range = mdDoc . createRange ( ) ;
1310+ range . selectNodeContents ( el ) ;
1311+ range . collapse ( false ) ;
1312+ const sel = win . getSelection ( ) ;
1313+ sel . removeAllRanges ( ) ;
1314+ sel . addRange ( range ) ;
1315+ }
1316+
1317+ function _dispatchKey ( key , options ) {
1318+ const mdDoc = _getMdIFrameDoc ( ) ;
1319+ const content = mdDoc . getElementById ( "viewer-content" ) ;
1320+ content . dispatchEvent ( new KeyboardEvent ( "keydown" , {
1321+ key : key ,
1322+ code : options && options . code || key ,
1323+ keyCode : options && options . keyCode || 0 ,
1324+ shiftKey : ! ! ( options && options . shiftKey ) ,
1325+ ctrlKey : false ,
1326+ metaKey : false ,
1327+ bubbles : true ,
1328+ cancelable : true
1329+ } ) ) ;
1330+ }
1331+
1332+ function _getCursorElement ( ) {
1333+ const win = _getMdIFrameWin ( ) ;
1334+ const sel = win . getSelection ( ) ;
1335+ if ( ! sel || ! sel . rangeCount ) { return null ; }
1336+ let node = sel . anchorNode ;
1337+ if ( node && node . nodeType === Node . TEXT_NODE ) { node = node . parentElement ; }
1338+ return node ;
1339+ }
1340+
1341+ it ( "should Enter at start of heading insert empty p above" , async function ( ) {
1342+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1343+ expect ( h2 ) . not . toBeNull ( ) ;
1344+ const prevSibling = h2 . previousElementSibling ;
1345+
1346+ _placeCursorAt ( h2 , 0 ) ;
1347+ _dispatchKey ( "Enter" ) ;
1348+
1349+ // New <p> should be inserted above the heading
1350+ await awaitsFor ( ( ) => {
1351+ const newPrev = h2 . previousElementSibling ;
1352+ return newPrev && newPrev !== prevSibling && newPrev . tagName === "P" ;
1353+ } , "empty p to be inserted above heading" ) ;
1354+
1355+ // Heading text should be unchanged
1356+ expect ( h2 . textContent ) . toContain ( "Heading Two" ) ;
1357+
1358+ // Cursor should remain on the heading
1359+ const curEl = _getCursorElement ( ) ;
1360+ expect ( curEl && curEl . closest ( "h2" ) ) . toBe ( h2 ) ;
1361+ } , 10000 ) ;
1362+
1363+ it ( "should Enter in middle of heading split into heading and p" , async function ( ) {
1364+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1365+ expect ( h2 ) . not . toBeNull ( ) ;
1366+
1367+ // Place cursor after "Heading " (offset 8)
1368+ _placeCursorAt ( h2 , 8 ) ;
1369+ _dispatchKey ( "Enter" ) ;
1370+
1371+ // Heading should now contain only "Heading "
1372+ await awaitsFor ( ( ) => {
1373+ return h2 . textContent . trim ( ) === "Heading" ;
1374+ } , "heading to contain only text before cursor" ) ;
1375+
1376+ // Next sibling should be a <p> with "Two"
1377+ const nextP = h2 . nextElementSibling ;
1378+ expect ( nextP ) . not . toBeNull ( ) ;
1379+ expect ( nextP . tagName ) . toBe ( "P" ) ;
1380+ expect ( nextP . textContent . trim ( ) ) . toBe ( "Two" ) ;
1381+
1382+ // Cursor should be in the new paragraph
1383+ const curEl = _getCursorElement ( ) ;
1384+ expect ( curEl && curEl . closest ( "p" ) ) . toBe ( nextP ) ;
1385+ } , 10000 ) ;
1386+
1387+ it ( "should Enter at end of heading create empty p below" , async function ( ) {
1388+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1389+ expect ( h2 ) . not . toBeNull ( ) ;
1390+
1391+ _placeCursorAtEnd ( h2 ) ;
1392+ _dispatchKey ( "Enter" ) ;
1393+
1394+ // New <p> should appear after the heading
1395+ await awaitsFor ( ( ) => {
1396+ const nextEl = h2 . nextElementSibling ;
1397+ return nextEl && nextEl . tagName === "P" &&
1398+ nextEl . textContent . trim ( ) === "" ;
1399+ } , "empty p to be created below heading" ) ;
1400+
1401+ // Heading text should be unchanged
1402+ expect ( h2 . textContent ) . toContain ( "Heading Two" ) ;
1403+
1404+ // Cursor should be in the new paragraph
1405+ const curEl = _getCursorElement ( ) ;
1406+ expect ( curEl && curEl . closest ( "p" ) === h2 . nextElementSibling ) . toBeTrue ( ) ;
1407+ } , 10000 ) ;
1408+
1409+ it ( "should Shift+Enter in heading create empty p below without moving content" , async function ( ) {
1410+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1411+ expect ( h2 ) . not . toBeNull ( ) ;
1412+ const originalText = h2 . textContent ;
1413+
1414+ _placeCursorAt ( h2 , 4 ) ; // middle of heading
1415+ _dispatchKey ( "Enter" , { shiftKey : true } ) ;
1416+
1417+ // New empty <p> should appear after heading
1418+ await awaitsFor ( ( ) => {
1419+ const nextEl = h2 . nextElementSibling ;
1420+ return nextEl && nextEl . tagName === "P" ;
1421+ } , "p to be created below heading on Shift+Enter" ) ;
1422+
1423+ // Heading text should be untouched (not split)
1424+ expect ( h2 . textContent ) . toBe ( originalText ) ;
1425+
1426+ // Cursor should be in the new paragraph
1427+ const curEl = _getCursorElement ( ) ;
1428+ expect ( curEl && ! curEl . closest ( "h2" ) ) . toBeTrue ( ) ;
1429+ } , 10000 ) ;
1430+
1431+ it ( "should Backspace at start of heading convert to paragraph" , async function ( ) {
1432+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1433+ expect ( h2 ) . not . toBeNull ( ) ;
1434+
1435+ _placeCursorAt ( h2 , 0 ) ;
1436+ _dispatchKey ( "Backspace" , { code : "Backspace" , keyCode : 8 } ) ;
1437+
1438+ // Heading should be replaced with a <p>
1439+ await awaitsFor ( ( ) => {
1440+ const mdDoc = _getMdIFrameDoc ( ) ;
1441+ // h2 with "Heading Two" should be gone
1442+ const h2s = mdDoc . querySelectorAll ( "#viewer-content h2" ) ;
1443+ for ( const h of h2s ) {
1444+ if ( h . textContent . includes ( "Heading Two" ) ) { return false ; }
1445+ }
1446+ // A <p> with the heading text should exist
1447+ const ps = mdDoc . querySelectorAll ( "#viewer-content p" ) ;
1448+ for ( const p of ps ) {
1449+ if ( p . textContent . includes ( "Heading Two" ) ) { return true ; }
1450+ }
1451+ return false ;
1452+ } , "heading to be converted to paragraph" ) ;
1453+ } , 10000 ) ;
1454+
1455+ it ( "should Backspace at start of heading preserve content and cursor" , async function ( ) {
1456+ const h3 = _findHeading ( "h3" , "Heading Three" ) ;
1457+ expect ( h3 ) . not . toBeNull ( ) ;
1458+ const headingText = h3 . textContent ;
1459+
1460+ _placeCursorAt ( h3 , 0 ) ;
1461+ _dispatchKey ( "Backspace" , { code : "Backspace" , keyCode : 8 } ) ;
1462+
1463+ // Content should be preserved in a <p>
1464+ await awaitsFor ( ( ) => {
1465+ const mdDoc = _getMdIFrameDoc ( ) ;
1466+ const ps = mdDoc . querySelectorAll ( "#viewer-content p" ) ;
1467+ for ( const p of ps ) {
1468+ if ( p . textContent === headingText ) { return true ; }
1469+ }
1470+ return false ;
1471+ } , "heading content to be preserved in paragraph" ) ;
1472+
1473+ // Cursor should be at start of the new paragraph
1474+ const curEl = _getCursorElement ( ) ;
1475+ expect ( curEl && curEl . closest ( "p" ) ) . not . toBeNull ( ) ;
1476+ expect ( curEl . closest ( "p" ) . textContent ) . toContain ( "Heading Three" ) ;
1477+ } , 10000 ) ;
1478+
1479+ it ( "should Backspace in middle of heading work normally" , async function ( ) {
1480+ const h2 = _findHeading ( "h2" , "Heading Two" ) ;
1481+ expect ( h2 ) . not . toBeNull ( ) ;
1482+
1483+ // Place cursor at offset 4 (after "Head")
1484+ _placeCursorAt ( h2 , 4 ) ;
1485+
1486+ // Press Backspace — should delete a character, NOT convert heading
1487+ _dispatchKey ( "Backspace" , { code : "Backspace" , keyCode : 8 } ) ;
1488+
1489+ // Heading should still be an h2 (not converted to p)
1490+ // The keydown handler only converts when cursor is at start
1491+ // Browser default behavior handles mid-heading backspace
1492+ expect ( h2 . tagName ) . toBe ( "H2" ) ;
1493+ } , 10000 ) ;
1494+ } ) ;
12301495 } ) ;
12311496} ) ;
0 commit comments