@@ -286,6 +286,255 @@ This is a fully client-side application. Your content never leaves your browser
286286
287287 markdownEditor . value = sampleMarkdown ;
288288
289+ // ========================================
290+ // DOCUMENT TABS & SESSION MANAGEMENT
291+ // ========================================
292+
293+ const STORAGE_KEY = 'markdownViewerTabs' ;
294+ const ACTIVE_TAB_KEY = 'markdownViewerActiveTab' ;
295+ let tabs = [ ] ;
296+ let activeTabId = null ;
297+ let draggedTabId = null ;
298+ let saveTabStateTimeout = null ;
299+ let updateTitleTimeout = null ;
300+
301+ function loadTabsFromStorage ( ) {
302+ try {
303+ return JSON . parse ( localStorage . getItem ( STORAGE_KEY ) ) || [ ] ;
304+ } catch ( e ) {
305+ return [ ] ;
306+ }
307+ }
308+
309+ function saveTabsToStorage ( tabsArr ) {
310+ try {
311+ localStorage . setItem ( STORAGE_KEY , JSON . stringify ( tabsArr ) ) ;
312+ } catch ( e ) {
313+ console . warn ( 'Failed to save tabs to localStorage:' , e ) ;
314+ }
315+ }
316+
317+ function loadActiveTabId ( ) {
318+ return localStorage . getItem ( ACTIVE_TAB_KEY ) ;
319+ }
320+
321+ function saveActiveTabId ( id ) {
322+ localStorage . setItem ( ACTIVE_TAB_KEY , id ) ;
323+ }
324+
325+ function deriveTitleFromContent ( content ) {
326+ if ( ! content ) return 'Untitled' ;
327+ const lines = content . split ( '\n' ) ;
328+ for ( let i = 0 ; i < lines . length ; i ++ ) {
329+ const h1Match = lines [ i ] . match ( / ^ # \s + ( .+ ) / ) ;
330+ if ( h1Match ) return h1Match [ 1 ] . trim ( ) ;
331+ }
332+ for ( let i = 0 ; i < lines . length ; i ++ ) {
333+ const trimmed = lines [ i ] . trim ( ) ;
334+ if ( trimmed ) return trimmed . substring ( 0 , 30 ) ;
335+ }
336+ return 'Untitled' ;
337+ }
338+
339+ function createTab ( content , title , viewMode ) {
340+ if ( content === undefined ) content = '' ;
341+ if ( title === undefined ) title = null ;
342+ if ( viewMode === undefined ) viewMode = 'split' ;
343+ return {
344+ id : 'tab_' + Date . now ( ) + '_' + Math . random ( ) . toString ( 36 ) . substring ( 2 , 8 ) ,
345+ title : title || deriveTitleFromContent ( content ) || 'Untitled' ,
346+ content : content ,
347+ scrollPos : 0 ,
348+ viewMode : viewMode ,
349+ createdAt : Date . now ( )
350+ } ;
351+ }
352+
353+ function renderTabBar ( tabsArr , currentActiveTabId ) {
354+ const tabList = document . getElementById ( 'tab-list' ) ;
355+ if ( ! tabList ) return ;
356+ tabList . innerHTML = '' ;
357+ tabsArr . forEach ( function ( tab ) {
358+ const item = document . createElement ( 'div' ) ;
359+ item . className = 'tab-item' + ( tab . id === currentActiveTabId ? ' active' : '' ) ;
360+ item . setAttribute ( 'data-tab-id' , tab . id ) ;
361+ item . setAttribute ( 'role' , 'tab' ) ;
362+ item . setAttribute ( 'aria-selected' , tab . id === currentActiveTabId ? 'true' : 'false' ) ;
363+ item . setAttribute ( 'draggable' , 'true' ) ;
364+
365+ const titleSpan = document . createElement ( 'span' ) ;
366+ titleSpan . className = 'tab-title' ;
367+ titleSpan . textContent = tab . title || 'Untitled' ;
368+ titleSpan . title = tab . title || 'Untitled' ;
369+
370+ const closeBtn = document . createElement ( 'button' ) ;
371+ closeBtn . className = 'tab-close-btn' ;
372+ closeBtn . setAttribute ( 'aria-label' , 'Close tab' ) ;
373+ closeBtn . innerHTML = '<i class="bi bi-x"></i>' ;
374+ if ( tabsArr . length === 1 ) {
375+ closeBtn . style . visibility = 'hidden' ;
376+ }
377+
378+ item . appendChild ( titleSpan ) ;
379+ item . appendChild ( closeBtn ) ;
380+
381+ item . addEventListener ( 'click' , function ( ) {
382+ switchTab ( tab . id ) ;
383+ } ) ;
384+
385+ closeBtn . addEventListener ( 'click' , function ( e ) {
386+ e . stopPropagation ( ) ;
387+ closeTab ( tab . id ) ;
388+ } ) ;
389+
390+ item . addEventListener ( 'dragstart' , function ( ) {
391+ draggedTabId = tab . id ;
392+ setTimeout ( function ( ) { item . classList . add ( 'dragging' ) ; } , 0 ) ;
393+ } ) ;
394+
395+ item . addEventListener ( 'dragend' , function ( ) {
396+ item . classList . remove ( 'dragging' ) ;
397+ draggedTabId = null ;
398+ } ) ;
399+
400+ item . addEventListener ( 'dragover' , function ( e ) {
401+ e . preventDefault ( ) ;
402+ item . classList . add ( 'drag-over' ) ;
403+ } ) ;
404+
405+ item . addEventListener ( 'dragleave' , function ( ) {
406+ item . classList . remove ( 'drag-over' ) ;
407+ } ) ;
408+
409+ item . addEventListener ( 'drop' , function ( e ) {
410+ e . preventDefault ( ) ;
411+ item . classList . remove ( 'drag-over' ) ;
412+ if ( ! draggedTabId || draggedTabId === tab . id ) return ;
413+ const fromIdx = tabs . findIndex ( function ( t ) { return t . id === draggedTabId ; } ) ;
414+ const toIdx = tabs . findIndex ( function ( t ) { return t . id === tab . id ; } ) ;
415+ if ( fromIdx === - 1 || toIdx === - 1 ) return ;
416+ const moved = tabs . splice ( fromIdx , 1 ) [ 0 ] ;
417+ tabs . splice ( toIdx , 0 , moved ) ;
418+ saveTabsToStorage ( tabs ) ;
419+ renderTabBar ( tabs , activeTabId ) ;
420+ } ) ;
421+
422+ tabList . appendChild ( item ) ;
423+ } ) ;
424+
425+ // Auto-scroll active tab into view
426+ const activeItem = tabList . querySelector ( '.tab-item.active' ) ;
427+ if ( activeItem ) {
428+ activeItem . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ;
429+ }
430+ }
431+
432+ function saveCurrentTabState ( ) {
433+ const tab = tabs . find ( function ( t ) { return t . id === activeTabId ; } ) ;
434+ if ( ! tab ) return ;
435+ tab . content = markdownEditor . value ;
436+ tab . scrollPos = markdownEditor . scrollTop ;
437+ tab . viewMode = currentViewMode || 'split' ;
438+ saveTabsToStorage ( tabs ) ;
439+ }
440+
441+ function restoreViewMode ( mode ) {
442+ currentViewMode = null ;
443+ setViewMode ( mode || 'split' ) ;
444+ }
445+
446+ function updateActiveTabTitle ( ) {
447+ const tab = tabs . find ( function ( t ) { return t . id === activeTabId ; } ) ;
448+ if ( ! tab ) return ;
449+ const newTitle = deriveTitleFromContent ( markdownEditor . value ) || 'Untitled' ;
450+ if ( newTitle === tab . title ) return ;
451+ tab . title = newTitle ;
452+ saveTabsToStorage ( tabs ) ;
453+ const tabList = document . getElementById ( 'tab-list' ) ;
454+ if ( tabList ) {
455+ const item = tabList . querySelector ( '[data-tab-id="' + activeTabId + '"]' ) ;
456+ if ( item ) {
457+ const titleSpan = item . querySelector ( '.tab-title' ) ;
458+ if ( titleSpan ) {
459+ titleSpan . textContent = newTitle ;
460+ titleSpan . title = newTitle ;
461+ }
462+ }
463+ }
464+ }
465+
466+ function switchTab ( tabId ) {
467+ if ( tabId === activeTabId ) return ;
468+ saveCurrentTabState ( ) ;
469+ activeTabId = tabId ;
470+ saveActiveTabId ( activeTabId ) ;
471+ const tab = tabs . find ( function ( t ) { return t . id === tabId ; } ) ;
472+ if ( ! tab ) return ;
473+ markdownEditor . value = tab . content ;
474+ restoreViewMode ( tab . viewMode ) ;
475+ renderMarkdown ( ) ;
476+ requestAnimationFrame ( function ( ) {
477+ markdownEditor . scrollTop = tab . scrollPos || 0 ;
478+ } ) ;
479+ renderTabBar ( tabs , activeTabId ) ;
480+ }
481+
482+ function newTab ( content , title ) {
483+ if ( content === undefined ) content = '' ;
484+ if ( tabs . length >= 20 ) {
485+ alert ( 'Maximum of 20 tabs reached. Please close an existing tab to open a new one.' ) ;
486+ return ;
487+ }
488+ const tab = createTab ( content , title ) ;
489+ tabs . push ( tab ) ;
490+ switchTab ( tab . id ) ;
491+ markdownEditor . focus ( ) ;
492+ }
493+
494+ function closeTab ( tabId ) {
495+ if ( tabs . length <= 1 ) return ;
496+ const idx = tabs . findIndex ( function ( t ) { return t . id === tabId ; } ) ;
497+ if ( idx === - 1 ) return ;
498+ tabs . splice ( idx , 1 ) ;
499+ if ( activeTabId === tabId ) {
500+ const newIdx = Math . max ( 0 , idx - 1 ) ;
501+ activeTabId = tabs [ newIdx ] . id ;
502+ saveActiveTabId ( activeTabId ) ;
503+ const newActiveTab = tabs [ newIdx ] ;
504+ markdownEditor . value = newActiveTab . content ;
505+ restoreViewMode ( newActiveTab . viewMode ) ;
506+ renderMarkdown ( ) ;
507+ requestAnimationFrame ( function ( ) {
508+ markdownEditor . scrollTop = newActiveTab . scrollPos || 0 ;
509+ } ) ;
510+ }
511+ saveTabsToStorage ( tabs ) ;
512+ renderTabBar ( tabs , activeTabId ) ;
513+ }
514+
515+ function initTabs ( ) {
516+ tabs = loadTabsFromStorage ( ) ;
517+ activeTabId = loadActiveTabId ( ) ;
518+ if ( tabs . length === 0 ) {
519+ const tab = createTab ( sampleMarkdown ) ;
520+ tabs . push ( tab ) ;
521+ activeTabId = tab . id ;
522+ saveTabsToStorage ( tabs ) ;
523+ saveActiveTabId ( activeTabId ) ;
524+ } else if ( ! tabs . find ( function ( t ) { return t . id === activeTabId ; } ) ) {
525+ activeTabId = tabs [ 0 ] . id ;
526+ saveActiveTabId ( activeTabId ) ;
527+ }
528+ const activeTab = tabs . find ( function ( t ) { return t . id === activeTabId ; } ) ;
529+ markdownEditor . value = activeTab . content ;
530+ restoreViewMode ( activeTab . viewMode ) ;
531+ renderMarkdown ( ) ;
532+ requestAnimationFrame ( function ( ) {
533+ markdownEditor . scrollTop = activeTab . scrollPos || 0 ;
534+ } ) ;
535+ renderTabBar ( tabs , activeTabId ) ;
536+ }
537+
289538 function renderMarkdown ( ) {
290539 try {
291540 const markdown = markdownEditor . value ;
@@ -338,8 +587,7 @@ This is a fully client-side application. Your content never leaves your browser
338587 function importMarkdownFile ( file ) {
339588 const reader = new FileReader ( ) ;
340589 reader . onload = function ( e ) {
341- markdownEditor . value = e . target . result ;
342- renderMarkdown ( ) ;
590+ newTab ( e . target . result , file . name . replace ( / \. m d $ / i, '' ) ) ;
343591 dropzone . style . display = "none" ;
344592 } ;
345593 reader . readAsText ( file ) ;
@@ -683,12 +931,9 @@ This is a fully client-side application. Your content never leaves your browser
683931 mobileThemeToggle . innerHTML = themeToggle . innerHTML + " Toggle Dark Mode" ;
684932 } ) ;
685933
686- renderMarkdown ( ) ;
934+ initTabs ( ) ;
687935 updateMobileStats ( ) ;
688936
689- // Initialize view mode - Story 1.1
690- contentContainer . classList . add ( 'view-split' ) ;
691-
692937 // Initialize resizer - Story 1.3
693938 initResizer ( ) ;
694939
@@ -697,6 +942,7 @@ This is a fully client-side application. Your content never leaves your browser
697942 btn . addEventListener ( 'click' , function ( ) {
698943 const mode = this . getAttribute ( 'data-mode' ) ;
699944 setViewMode ( mode ) ;
945+ saveCurrentTabState ( ) ;
700946 } ) ;
701947 } ) ;
702948
@@ -705,11 +951,18 @@ This is a fully client-side application. Your content never leaves your browser
705951 btn . addEventListener ( 'click' , function ( ) {
706952 const mode = this . getAttribute ( 'data-mode' ) ;
707953 setViewMode ( mode ) ;
954+ saveCurrentTabState ( ) ;
708955 closeMobileMenu ( ) ;
709956 } ) ;
710957 } ) ;
711958
712- markdownEditor . addEventListener ( "input" , debouncedRender ) ;
959+ markdownEditor . addEventListener ( "input" , function ( ) {
960+ debouncedRender ( ) ;
961+ clearTimeout ( saveTabStateTimeout ) ;
962+ saveTabStateTimeout = setTimeout ( saveCurrentTabState , 500 ) ;
963+ clearTimeout ( updateTitleTimeout ) ;
964+ updateTitleTimeout = setTimeout ( updateActiveTabTitle , 800 ) ;
965+ } ) ;
713966
714967 // Tab key handler to insert indentation instead of moving focus
715968 markdownEditor . addEventListener ( "keydown" , function ( e ) {
@@ -1606,6 +1859,8 @@ This is a fully client-side application. Your content never leaves your browser
16061859 const decoded = decodeMarkdownFromShare ( encoded ) ;
16071860 markdownEditor . value = decoded ;
16081861 renderMarkdown ( ) ;
1862+ saveCurrentTabState ( ) ;
1863+ updateActiveTabTitle ( ) ;
16091864 } catch ( e ) {
16101865 console . error ( "Failed to load shared content:" , e ) ;
16111866 alert ( "The shared URL could not be decoded. It may be corrupted or incomplete." ) ;
@@ -1691,12 +1946,28 @@ This is a fully client-side application. Your content never leaves your browser
16911946 toggleSyncScrolling ( ) ;
16921947 }
16931948 }
1949+ // New tab
1950+ if ( ( e . ctrlKey || e . metaKey ) && e . key === "t" ) {
1951+ e . preventDefault ( ) ;
1952+ newTab ( ) ;
1953+ }
1954+ // Close tab
1955+ if ( ( e . ctrlKey || e . metaKey ) && e . key === "w" ) {
1956+ if ( tabs . length > 1 ) {
1957+ e . preventDefault ( ) ;
1958+ closeTab ( activeTabId ) ;
1959+ }
1960+ }
16941961 // Close Mermaid zoom modal with Escape
16951962 if ( e . key === "Escape" ) {
16961963 closeMermaidModal ( ) ;
16971964 }
16981965 } ) ;
16991966
1967+ document . getElementById ( 'tab-new-btn' ) . addEventListener ( 'click' , function ( ) {
1968+ newTab ( ) ;
1969+ } ) ;
1970+
17001971 // ========================================
17011972 // MERMAID DIAGRAM TOOLBAR
17021973 // ========================================
0 commit comments