Skip to content

Commit 80bbdcc

Browse files
feat: implement tab action menu with dropdown for rename, duplicate, and delete actions
1 parent a92b9ed commit 80bbdcc

4 files changed

Lines changed: 262 additions & 194 deletions

File tree

desktop-app/resources/js/script.js

Lines changed: 130 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -517,9 +517,124 @@ This is a fully client-side application. Your content never leaves your browser
517517
};
518518
}
519519

520+
function closeTabMenus() {
521+
document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) {
522+
btn.classList.remove('open');
523+
btn.setAttribute('aria-expanded', 'false');
524+
});
525+
document.querySelectorAll('.tab-menu-dropdown.open').forEach(function(dropdown) {
526+
dropdown.classList.remove('open');
527+
});
528+
}
529+
530+
function removeTabMenuDropdowns() {
531+
document.querySelectorAll('.tab-menu-dropdown[data-tab-menu-dropdown="true"]').forEach(function(dropdown) {
532+
dropdown.remove();
533+
});
534+
}
535+
536+
function positionTabMenu(menuBtn, dropdown) {
537+
const rect = menuBtn.getBoundingClientRect();
538+
const margin = 8;
539+
const dropdownWidth = dropdown.offsetWidth || 130;
540+
const dropdownHeight = dropdown.offsetHeight || 110;
541+
let left = rect.right - dropdownWidth;
542+
let top = rect.bottom + 4;
543+
544+
left = Math.max(margin, Math.min(left, window.innerWidth - dropdownWidth - margin));
545+
if (top + dropdownHeight > window.innerHeight - margin) {
546+
top = Math.max(margin, rect.top - dropdownHeight - 4);
547+
}
548+
549+
dropdown.style.top = top + 'px';
550+
dropdown.style.left = left + 'px';
551+
dropdown.style.right = 'auto';
552+
}
553+
554+
function runTabMenuAction(tabId, action, isMobileMenu) {
555+
if (action === 'rename') {
556+
if (isMobileMenu) closeMobileMenu();
557+
renameTab(tabId);
558+
} else if (action === 'duplicate') {
559+
duplicateTab(tabId);
560+
if (isMobileMenu) closeMobileMenu();
561+
} else if (action === 'delete') {
562+
deleteTab(tabId);
563+
}
564+
}
565+
566+
function createTabActionMenu(tab, options) {
567+
const isMobileMenu = options && options.isMobileMenu;
568+
const menuIdPrefix = options && options.menuIdPrefix ? options.menuIdPrefix : 'tab-menu';
569+
const menuId = menuIdPrefix + '-' + tab.id;
570+
571+
const menuBtn = document.createElement('button');
572+
menuBtn.type = 'button';
573+
menuBtn.className = 'tab-menu-btn';
574+
menuBtn.setAttribute('aria-label', 'File options for ' + (tab.title || 'Untitled'));
575+
menuBtn.setAttribute('aria-haspopup', 'menu');
576+
menuBtn.setAttribute('aria-expanded', 'false');
577+
menuBtn.setAttribute('aria-controls', menuId);
578+
menuBtn.setAttribute('draggable', 'false');
579+
menuBtn.title = 'File options';
580+
menuBtn.innerHTML = '⋯';
581+
582+
const dropdown = document.createElement('div');
583+
dropdown.id = menuId;
584+
dropdown.className = 'tab-menu-dropdown';
585+
dropdown.setAttribute('data-tab-menu-dropdown', 'true');
586+
dropdown.setAttribute('role', 'menu');
587+
dropdown.innerHTML =
588+
'<button type="button" class="tab-menu-item" role="menuitem" data-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
589+
'<button type="button" class="tab-menu-item" role="menuitem" data-action="duplicate"><i class="bi bi-files"></i> Duplicate</button>' +
590+
'<button type="button" class="tab-menu-item tab-menu-item-danger" role="menuitem" data-action="delete"><i class="bi bi-trash"></i> Delete</button>';
591+
592+
menuBtn.addEventListener('click', function(e) {
593+
e.preventDefault();
594+
e.stopPropagation();
595+
const shouldOpen = !menuBtn.classList.contains('open');
596+
closeTabMenus();
597+
if (shouldOpen) {
598+
menuBtn.classList.add('open');
599+
menuBtn.setAttribute('aria-expanded', 'true');
600+
dropdown.classList.add('open');
601+
positionTabMenu(menuBtn, dropdown);
602+
}
603+
});
604+
605+
menuBtn.addEventListener('mousedown', function(e) {
606+
e.stopPropagation();
607+
});
608+
609+
menuBtn.addEventListener('dragstart', function(e) {
610+
e.preventDefault();
611+
e.stopPropagation();
612+
});
613+
614+
dropdown.addEventListener('click', function(e) {
615+
e.stopPropagation();
616+
});
617+
618+
dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) {
619+
actionBtn.addEventListener('click', function(e) {
620+
e.preventDefault();
621+
e.stopPropagation();
622+
const action = actionBtn.getAttribute('data-action');
623+
closeTabMenus();
624+
runTabMenuAction(tab.id, action, isMobileMenu);
625+
});
626+
});
627+
628+
document.body.appendChild(dropdown);
629+
630+
return { button: menuBtn, dropdown: dropdown };
631+
}
632+
520633
function renderTabBar(tabsArr, currentActiveTabId) {
521634
const tabList = document.getElementById('tab-list');
522635
if (!tabList) return;
636+
closeTabMenus();
637+
removeTabMenuDropdowns();
523638
tabList.innerHTML = '';
524639
tabsArr.forEach(function(tab) {
525640
const item = document.createElement('div');
@@ -534,53 +649,10 @@ This is a fully client-side application. Your content never leaves your browser
534649
titleSpan.textContent = tab.title || 'Untitled';
535650
titleSpan.title = tab.title || 'Untitled';
536651

537-
// Three-dot menu button
538-
const menuBtn = document.createElement('button');
539-
menuBtn.className = 'tab-menu-btn';
540-
menuBtn.setAttribute('aria-label', 'File options');
541-
menuBtn.title = 'File options';
542-
menuBtn.innerHTML = '&#8943;';
543-
544-
// Dropdown
545-
const dropdown = document.createElement('div');
546-
dropdown.className = 'tab-menu-dropdown';
547-
dropdown.innerHTML =
548-
'<button class="tab-menu-item" data-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
549-
'<button class="tab-menu-item" data-action="duplicate"><i class="bi bi-files"></i> Duplicate</button>' +
550-
'<button class="tab-menu-item tab-menu-item-danger" data-action="delete"><i class="bi bi-trash"></i> Delete</button>';
551-
552-
menuBtn.appendChild(dropdown);
553-
554-
menuBtn.addEventListener('click', function(e) {
555-
e.stopPropagation();
556-
// Close all other open dropdowns first
557-
document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) {
558-
if (btn !== menuBtn) btn.classList.remove('open');
559-
});
560-
menuBtn.classList.toggle('open');
561-
// Position the dropdown relative to the viewport so it escapes the
562-
// overflow scroll container on .tab-list
563-
if (menuBtn.classList.contains('open')) {
564-
var rect = menuBtn.getBoundingClientRect();
565-
dropdown.style.top = (rect.bottom + 4) + 'px';
566-
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
567-
dropdown.style.left = 'auto';
568-
}
569-
});
570-
571-
dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) {
572-
actionBtn.addEventListener('click', function(e) {
573-
e.stopPropagation();
574-
menuBtn.classList.remove('open');
575-
const action = actionBtn.getAttribute('data-action');
576-
if (action === 'rename') renameTab(tab.id);
577-
else if (action === 'duplicate') duplicateTab(tab.id);
578-
else if (action === 'delete') deleteTab(tab.id);
579-
});
580-
});
652+
const tabMenu = createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' });
581653

582654
item.appendChild(titleSpan);
583-
item.appendChild(menuBtn);
655+
item.appendChild(tabMenu.button);
584656

585657
item.addEventListener('click', function() {
586658
switchTab(tab.id);
@@ -655,56 +727,13 @@ This is a fully client-side application. Your content never leaves your browser
655727
titleSpan.textContent = tab.title || 'Untitled';
656728
titleSpan.title = tab.title || 'Untitled';
657729

658-
// Three-dot menu button (same as desktop)
659-
const menuBtn = document.createElement('button');
660-
menuBtn.className = 'tab-menu-btn';
661-
menuBtn.setAttribute('aria-label', 'File options');
662-
menuBtn.title = 'File options';
663-
menuBtn.innerHTML = '&#8943;';
664-
665-
// Dropdown (same as desktop)
666-
const dropdown = document.createElement('div');
667-
dropdown.className = 'tab-menu-dropdown';
668-
dropdown.innerHTML =
669-
'<button class="tab-menu-item" data-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
670-
'<button class="tab-menu-item" data-action="duplicate"><i class="bi bi-files"></i> Duplicate</button>' +
671-
'<button class="tab-menu-item tab-menu-item-danger" data-action="delete"><i class="bi bi-trash"></i> Delete</button>';
672-
673-
menuBtn.appendChild(dropdown);
674-
675-
menuBtn.addEventListener('click', function(e) {
676-
e.stopPropagation();
677-
document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) {
678-
if (btn !== menuBtn) btn.classList.remove('open');
679-
});
680-
menuBtn.classList.toggle('open');
681-
if (menuBtn.classList.contains('open')) {
682-
const rect = menuBtn.getBoundingClientRect();
683-
dropdown.style.top = (rect.bottom + 4) + 'px';
684-
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
685-
dropdown.style.left = 'auto';
686-
}
687-
});
688-
689-
dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) {
690-
actionBtn.addEventListener('click', function(e) {
691-
e.stopPropagation();
692-
menuBtn.classList.remove('open');
693-
const action = actionBtn.getAttribute('data-action');
694-
if (action === 'rename') {
695-
closeMobileMenu();
696-
renameTab(tab.id);
697-
} else if (action === 'duplicate') {
698-
duplicateTab(tab.id);
699-
closeMobileMenu();
700-
} else if (action === 'delete') {
701-
deleteTab(tab.id);
702-
}
703-
});
730+
const tabMenu = createTabActionMenu(tab, {
731+
isMobileMenu: true,
732+
menuIdPrefix: 'mobile-tab-menu'
704733
});
705734

706735
item.appendChild(titleSpan);
707-
item.appendChild(menuBtn);
736+
item.appendChild(tabMenu.button);
708737

709738
item.addEventListener('click', function() {
710739
switchTab(tab.id);
@@ -717,9 +746,7 @@ This is a fully client-side application. Your content never leaves your browser
717746

718747
// Close any open tab dropdown when clicking elsewhere in the document
719748
document.addEventListener('click', function() {
720-
document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) {
721-
btn.classList.remove('open');
722-
});
749+
closeTabMenus();
723750
});
724751

725752
function saveCurrentTabState() {
@@ -850,12 +877,18 @@ This is a fully client-side application. Your content never leaves your browser
850877
alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.');
851878
return;
852879
}
880+
const shouldSwitchToDuplicate = tabId === activeTabId;
853881
saveCurrentTabState();
854882
const dupTitle = tab.title + ' (copy)';
855883
const dup = createTab(tab.content, dupTitle, tab.viewMode);
856884
const idx = tabs.findIndex(function(t) { return t.id === tabId; });
857885
tabs.splice(idx + 1, 0, dup);
858-
switchTab(dup.id);
886+
if (shouldSwitchToDuplicate) {
887+
switchTab(dup.id);
888+
} else {
889+
saveTabsToStorage(tabs);
890+
renderTabBar(tabs, activeTabId);
891+
}
859892
}
860893

861894
function resetAllTabs() {
@@ -2862,6 +2895,7 @@ This is a fully client-side application. Your content never leaves your browser
28622895
}
28632896
// Close Mermaid zoom modal with Escape
28642897
if (e.key === "Escape") {
2898+
closeTabMenus();
28652899
closeMermaidModal();
28662900
}
28672901
});

desktop-app/resources/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1598,7 +1598,7 @@ a:focus {
15981598
flex-direction: column;
15991599
}
16001600

1601-
.tab-menu-btn.open .tab-menu-dropdown {
1601+
.tab-menu-dropdown.open {
16021602
display: flex;
16031603
}
16041604

0 commit comments

Comments
 (0)