Skip to content

Commit 2fed8d5

Browse files
Merge pull request #40 from ThisIs-Developer/copilot/add-document-tabs-session-management
feat: Multi-tab workspace with localStorage session persistence
2 parents 6b93bab + d8510fc commit 2fed8d5

3 files changed

Lines changed: 457 additions & 7 deletions

File tree

index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ <h5>Menu</h5>
210210
</div>
211211
</header>
212212

213+
<!-- Tab Bar -->
214+
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
215+
<div class="tab-list" id="tab-list"></div>
216+
<button class="tab-new-btn" id="tab-new-btn" title="New Tab (Ctrl+T)" aria-label="Open new tab">
217+
<i class="bi bi-plus-lg"></i>
218+
</button>
219+
</div>
220+
213221
<div id="dropzone" class="dropzone container-fluid mt-2">
214222
<button id="close-dropzone" class="close-btn" title="Close dropzone">
215223
<i class="bi bi-x-lg"></i>

script.js

Lines changed: 278 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.md$/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

Comments
 (0)