From fb26921e111cb9215f4dfd4e29abe06f5242a4bb Mon Sep 17 00:00:00 2001 From: abaevbog Date: Fri, 22 Dec 2023 01:06:42 -0500 Subject: [PATCH] Opened tabs menu with a search bar - Matching substrings from tab's title are bolded. - Drag-and-drop functionality to reorder tabs. - ArrowUp/ArrowDown navigate the tab's titles. Tab/Shift-Tab move across both titles and close buttons. Enter from filter field focuses the first tab's title. Home/PageUp focuses the filter field. End/PageDown focuses the last tab's title. ArrowUp/ArrowDown from the filter focus the last/first tab's title respectively. ArrowUp from the first tab or ArrowDown from the last tab focus the filter. --- chrome/content/zotero/components/tabBar.jsx | 2 +- chrome/content/zotero/contextPane.js | 1 + chrome/content/zotero/tabs.js | 340 ++++++++++++++++++++ chrome/content/zotero/zoteroPane.js | 4 +- chrome/content/zotero/zoteroPane.xhtml | 19 +- chrome/locale/en-US/zotero/zotero.ftl | 6 +- scss/_zotero.scss | 1 + scss/components/_clicky.scss | 6 + scss/components/_tabsMenu.scss | 51 +++ scss/components/_toolbarbutton.scss | 2 +- test/tests/zoteroPaneTest.js | 4 +- 11 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 scss/components/_tabsMenu.scss diff --git a/chrome/content/zotero/components/tabBar.jsx b/chrome/content/zotero/components/tabBar.jsx index e994cc0895..8371d11553 100644 --- a/chrome/content/zotero/components/tabBar.jsx +++ b/chrome/content/zotero/components/tabBar.jsx @@ -283,7 +283,7 @@ const TabBar = forwardRef(function (props, ref) { } else { // On tab go back to opened tabs menu - document.getElementById('zotero-tb-opened-tabs').focus(); + document.getElementById('zotero-tb-tabs-menu').focus(); } event.preventDefault(); event.stopPropagation(); diff --git a/chrome/content/zotero/contextPane.js b/chrome/content/zotero/contextPane.js index dbf7343404..2d2602c8f1 100644 --- a/chrome/content/zotero/contextPane.js +++ b/chrome/content/zotero/contextPane.js @@ -221,6 +221,7 @@ var ZoteroContextPane = new function () { _tabCover.classList.add('hidden'); // Focus reader pages view if context pane note editor is not selected if (Zotero_Tabs.selectedID == reader.tabID + && !Zotero_Tabs.isTabsMenuVisible() && (!document.activeElement || !document.activeElement.closest('.context-node iframe[id="editor-view"]'))) { if (!Zotero_Tabs.focusOptions?.keepTabFocused) { diff --git a/chrome/content/zotero/tabs.js b/chrome/content/zotero/tabs.js index 4ca49e8a2e..813f399123 100644 --- a/chrome/content/zotero/tabs.js +++ b/chrome/content/zotero/tabs.js @@ -61,6 +61,14 @@ var Zotero_Tabs = new function () { get: () => this._focusOptions }); + Object.defineProperty(this, 'tabsMenuList', { + get: () => document.getElementById('zotero-tabs-menu-list') + }); + + Object.defineProperty(this, 'tabsMenuPanel', { + get: () => document.getElementById('zotero-tabs-menu-panel') + }); + this._tabBarRef = React.createRef(); this._tabs = [{ id: 'zotero-pane', @@ -71,6 +79,9 @@ var Zotero_Tabs = new function () { this._prevSelectedID = null; this._history = []; this._focusOptions = {}; + this._tabsMenuFilter = ""; + this._tabsMenuFocusedIndex = 0; + this._tabsMenuIgnoreMouseover = false; this._unloadInterval = setInterval(() => { this.unloadUnusedTabs(); @@ -119,6 +130,12 @@ var Zotero_Tabs = new function () { document.title = (tab.title.length ? tab.title + ' - ' : '') + Zotero.appName; // Hide any tab `title` tooltips that might be open window.Zotero_Tooltip.stop(); + if (this.isTabsMenuVisible()) { + this.refreshTabsMenuList(); + if (document.activeElement.id !== "zotero-tabs-menu-filter") { + focusTabsMenuEntry(); + } + } }; this.getTabIDByItemID = function (itemID) { @@ -738,4 +755,327 @@ var Zotero_Tabs = new function () { Services.focus.MOVEFOCUS_BACKWARD, 0); } }; + + /** + * @param {title} String - Tab's title + * @returns with bold substrings of title matching this._tabsMenuFilter + */ + let createTabsMenuLabel = (title) => { + let xhtmlNS = "http://www.w3.org/1999/xhtml"; + let desc = document.createXULElement('description'); + + let regex = new RegExp(`(${this._tabsMenuFilter})`, 'gi'); + let matches = title.matchAll(regex); + + let lastIndex = 0; + + for (let match of matches) { + if (match.index > lastIndex) { + // Add preceding text + desc.appendChild(document.createTextNode(title.substring(lastIndex, match.index))); + } + // Add matched text wrapped in + let b = document.createElementNS(xhtmlNS, 'b'); + b.textContent = match[0]; + desc.appendChild(b); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < title.length) { + // Add remaining text + desc.appendChild(document.createTextNode(title.substring(lastIndex))); + } + return desc; + }; + + this.isTabsMenuVisible = () => { + return ['showing', 'open'].includes(this.tabsMenuPanel.state); + }; + + /** + * Create the list of opened tabs in tabs menu. + */ + this.refreshTabsMenuList = () => { + // Empty existing nodes + this.tabsMenuList.replaceChildren(); + let index = 1; + for (let tab of this._tabs) { + // Skip tabs when we have no itemID, like library + if (!tab.data?.itemID) { + continue; + } + // Skip tabs whose title wasn't added yet + if (tab.title == "") { + continue; + } + // Filter tabs that do not match the filter + if (!tab.title.toLowerCase().includes(this._tabsMenuFilter)) { + continue; + } + // Top-level entry of the opened tabs array + let row = document.createXULElement('toolbaritem'); + let rowIndex = this._tabs.findIndex(x => x.id === tab.id); + row.setAttribute("index", rowIndex); + // Cross button to close a tab + let closeButton = document.createXULElement('toolbarbutton'); + closeButton.setAttribute('class', 'zotero-tabs-menu-entry zotero-clicky-cross close'); + closeButton.setAttribute('data-l10n-id', 'zotero-tabs-menu-close-button'); + closeButton.addEventListener("command", () => { + // Keep the focus on the cross at the same spot + if (this._tabsMenuFocusedIndex == this.tabsMenuList.childElementCount * 2) { + this._tabsMenuFocusedIndex = Math.max(this._tabsMenuFocusedIndex - 2, 0); + } + this.close(tab.id); + }); + + // Title of the tab + let tabName = document.createXULElement('toolbarbutton'); + tabName.setAttribute('flex', '1'); + tabName.setAttribute('class', 'zotero-tabs-menu-entry title'); + tabName.setAttribute('tabindex', `${index++}`); + tabName.setAttribute('aria-label', tab.title); + + closeButton.setAttribute('tabindex', `${index++}`); + + // Item type icon + let span = document.createElement("span"); + span.className = "icon icon-css cell-icon"; + span.classList.add("icon-item-type"); + let item = Zotero.Items.get(tab.data.itemID); + let dataTypeLabel = ""; + if (item.isPDFAttachment()) { + dataTypeLabel = "attachmentPDF"; + } + else if (item.isEPUBAttachment()) { + dataTypeLabel = "attachmentEPUB"; + } + else if (item.isSnapshotAttachment()) { + dataTypeLabel = "attachmentSnapshot"; + } + else if (item.isFileAttachment()) { + dataTypeLabel = "attachmentFile"; + } + span.setAttribute("data-item-type", dataTypeLabel); + tabName.appendChild(span); + // Actual label with bolded substrings matching the filter + let tabLabel = createTabsMenuLabel(tab.title, this._tabsMenuFilter); + tabLabel.setAttribute('flex', 1); + tabName.appendChild(tabLabel); + + // Selected tab is bold + if (tab.id == this._selectedID) { + tabName.classList.add('selected'); + } + // Onclick, go to selected tab + close popup + tabName.addEventListener("command", () => { + this.tabsMenuPanel.hidePopup(); + this.select(tab.id); + }); + // Manually handle hover effects as a workaround for a likely mozilla bug that + // keeps :hover at the location of dragstart after drop. + for (let node of [tabName, closeButton]) { + node.addEventListener('mouseenter', (_) => { + if (this._tabsMenuIgnoreMouseover) { + return; + } + node.classList.add('hover'); + // If the mouse moves over a tab, send focus back to the panel + // to avoid having two fields that appear greyed out. + if (document.activeElement.id !== "zotero-tabs-menu-filter") { + this._tabsMenuFocusedIndex = -1; + this.tabsMenuPanel.focus(); + } + }); + node.addEventListener('mouseleave', (_) => { + node.classList.remove('hover'); + }); + } + + row.appendChild(tabName); + row.appendChild(closeButton); + + row.addEventListener("dragstart", (e) => { + // No drag-drop on the cross button + if (e.target.classList.contains("close")) { + e.preventDefault(); + e.stopPropagation(); + return; + } + e.dataTransfer.setData('zotero/tab', tab.id); + setTimeout(() => { + row.classList.remove("hover"); + row.setAttribute("id", "zotero-tabs-menu-dragged"); + }); + }); + + + row.addEventListener('dragover', (e) => { + e.preventDefault(); + let tabId = e.dataTransfer.getData("zotero/tab"); + if (!tabId || tab.id == "zotero-pane") { + return false; + } + if (row.getAttribute("id") == "zotero-tabs-menu-dragged") { + return true; + } + let placeholder = document.getElementById("zotero-tabs-menu-dragged"); + if (row.previousSibling?.id == placeholder.id) { + // If the placeholder exists before the row, swap the placeholder and the row + row.parentNode.insertBefore(row, placeholder); + placeholder.setAttribute("index", parseInt(row.getAttribute("index")) + 1); + } + else { + // Insert placeholder before the row + row.parentNode.insertBefore(placeholder, row); + placeholder.setAttribute("index", parseInt(row.getAttribute("index"))); + } + return false; + }); + + row.addEventListener('drop', (e) => { + let tabId = e.dataTransfer.getData("zotero/tab"); + this.move(tabId, parseInt(row.getAttribute("index"))); + }); + + row.addEventListener('dragend', (_) => { + // If this.move() wasn't called, just re-render the menu + if (document.getElementById("zotero-tabs-menu-dragged")) { + this.refreshTabsMenuList(); + } + }); + this.tabsMenuList.appendChild(row); + } + }; + + this.showTabsMenu = function (button) { + this.tabsMenuPanel.openPopup(button, "after_start", -20, -2, false, false); + }; + + this.handleTabsMenuHiding = function (event) { + if (event.originalTarget.id != 'zotero-tabs-menu-panel') return; + + // Empty out the filter input field + let menuFilter = document.getElementById('zotero-tabs-menu-filter'); + menuFilter.value = ""; + this._tabsMenuFilter = ""; + this.refreshTabsMenuList(); + }; + + this.handleTabsMenuShown = function (_) { + // Default panel's padding is inside of a shadow dom, so it's easier to cancel it here + this.tabsMenuPanel.shadowRoot.firstChild.style = 'padding: 0px !important; margin: 0px !important'; + focusTabsMenuEntry(0); + }; + + /** + * Record the value of the filter + */ + this.handleTabsMenuFilterInput = function (_, input) { + if (this._tabsMenuFilter == input.value.toLowerCase()) { + return; + } + this._tabsMenuFilter = input.value.toLowerCase(); + this.refreshTabsMenuList(); + }; + + + /** + * Focus on the element in the tabs menu with [tabindex=tabIndex] if given + * or [tabindex=this._tabsMenuFocusedIndex] otherwise + */ + let focusTabsMenuEntry = (tabIndex = null) => { + tabIndex = tabIndex !== null ? tabIndex : this._tabsMenuFocusedIndex; + if (tabIndex === null) { + return; + } + var nextTab = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabIndex}"]`); + if (!nextTab) { + return; + } + this._tabsMenuIgnoreMouseover = true; + this._tabsMenuFocusedIndex = tabIndex; + let hovered = this.tabsMenuList.querySelector(".hover"); + if (hovered) { + hovered.classList.remove("hover"); + } + nextTab.focus(); + // For some reason (likely a mozilla bug), + // a mouseover event fires at the location where the drag event started after the drop. + // To not mark the wrong entry as hovered, ignore mouseover events for a bit after the focus change + setTimeout(() => { + this._tabsMenuIgnoreMouseover = false; + }, 250); + }; + + /** + * Keyboard navigation within the tabs menu + * - Tab/Shift-Tab moves focus from the input field across tab titles and close buttons + * - Enter from the input field focuses the first tab + * - Enter on a toolbarbutton clicks it + * - ArrowUp/ArrowDown on a toolbarbutton moves focus to the next/previous toolbarbutton of the + * same type (e.g. arrowDown from title focuses the next title) + * - ArrowUp from the first tab or ArrowDown from the last tab focuses the filter field + * - ArrowDown from the filter field focuses the first tab + * - ArrowUp from the filter field focuses the last tab + * - Home/PageUp focuses the filter field + * - End/PageDown focues the last tab title + */ + this.handleTabsMenuKeyPress = function (event) { + let tabindex = this._tabsMenuFocusedIndex; + if (event.key == "Tab") { + event.preventDefault(); + let isShift = event.shiftKey; + if (isShift) { + tabindex -= 1; + } + else { + tabindex += 1; + } + focusTabsMenuEntry(tabindex); + } + else if (["Home", "PageUp"].includes(event.key)) { + event.preventDefault(); + focusTabsMenuEntry(0); + } + else if (["End", "PageDown"].includes(event.key)) { + event.preventDefault(); + focusTabsMenuEntry(this.tabsMenuList.childElementCount * 2 - 1); + } + else if (["ArrowUp", "ArrowDown"].includes(event.key)) { + event.preventDefault(); + let isFirstRow = tabindex <= 2 && tabindex > 0; + // Step over 1 index to jump over close button, unless we move + // from the filter field + let step = tabindex == 0 ? 1 : 2; + if (event.key == "ArrowDown") { + tabindex += step; + } + else { + tabindex -= step; + } + if (tabindex <= 0) { + // ArrowUp from the first tab or the first close button focuses the filter field. + // ArrowUp from the filter field focuses the last tab + if (isFirstRow) { + tabindex = 0; + } + else { + tabindex = this.tabsMenuList.childElementCount * 2 - 1; + } + } + // ArrowDown from the bottom focuses the filter field + if (tabindex > this.tabsMenuList.childElementCount * 2) { + tabindex = 0; + } + focusTabsMenuEntry(tabindex); + } + else if (event.key == "Enter") { + event.preventDefault(); + if (event.target.id == "zotero-tabs-menu-filter") { + focusTabsMenuEntry(1); + return; + } + event.target.click(); + } + }; }; diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 3e709c6177..2eeb8d3a41 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -221,7 +221,7 @@ var ZoteroPane = new function() tabsToolbar.addEventListener("keydown", (event) => { // Mapping of target ids and possible key presses to desired focus outcomes let actionsMap = { - 'zotero-tb-opened-tabs': { + 'zotero-tb-tabs-menu': { ArrowRight: () => null, ArrowLeft: () => null, Tab: () => document.getElementById('zotero-tb-sync-error'), @@ -244,7 +244,7 @@ var ZoteroPane = new function() ArrowRight: () => null, ArrowLeft: () => null, Tab: () => document.getElementById('zotero-tb-sync'), - ShiftTab: () => document.getElementById('zotero-tb-opened-tabs'), + ShiftTab: () => document.getElementById('zotero-tb-tabs-menu'), Enter: () => document.getElementById("zotero-tb-sync-error") .dispatchEvent(new MouseEvent("click", { target: event.target })), ' ': () => document.getElementById("zotero-tb-sync-error") diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 0d8d43d10c..19348a8a79 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -757,8 +757,25 @@
- +
+ + + + + + diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index 89b5c53232..e6f3c834e2 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -1,9 +1,13 @@ zotero-toolbar-new-attachment = .tooltiptext = Add File -zotero-toolbar-opened-tabs-menu = +zotero-toolbar-tabs-menu = .tooltiptext = List all tabs zotero-collections-search = .placeholder = Filter Collections +zotero-tabs-menu-filter = + .placeholder = Search Tabs +zotero-tabs-menu-close-button = + .tooltiptext = Close Tab item-menu-viewAttachment = .label = Open { diff --git a/scss/_zotero.scss b/scss/_zotero.scss index 1f04d8d846..2171d26bf6 100644 --- a/scss/_zotero.scss +++ b/scss/_zotero.scss @@ -49,6 +49,7 @@ @import "components/toolbar"; @import "components/toolbarbutton"; @import "components/virtualized-table"; +@import "components/tabsMenu"; // Elements // -------------------------------------------------- diff --git a/scss/components/_clicky.scss b/scss/components/_clicky.scss index 5fc3ef782e..ee95afec53 100644 --- a/scss/components/_clicky.scss +++ b/scss/components/_clicky.scss @@ -36,6 +36,12 @@ border: 0px !important; } +.zotero-clicky-cross { + @include svgicon-menu("x-8", "universal", "16"); + border: 0px !important; + color: var(--fill-secondary); +} + .zotero-clicky-minus[disabled=true], .zotero-clicky-plus[disabled=true] { opacity: .5; } diff --git a/scss/components/_tabsMenu.scss b/scss/components/_tabsMenu.scss new file mode 100644 index 0000000000..d837efbbe4 --- /dev/null +++ b/scss/components/_tabsMenu.scss @@ -0,0 +1,51 @@ +#zotero-tabs-menu-wrapper { + background: var(--material-sidepane); + padding: 7px; + border-radius: 5px; +} + +#zotero-tabs-menu-filter { + margin: 0 0 7px 0; + border-radius: 5px; + border: 1px solid transparent; + padding-inline-start: 5px !important; + padding: 2px; +} + +#zotero-tabs-menu-list { + appearance: none; + margin: 0; + + .zotero-tabs-menu-entry { + border-radius: 6px; + height: 22px; + margin: 0; + &.close { + width: 22px; + } + &.title { + padding: 0; + padding-inline-start: 6px; + color: unset; + &.selected { + font-weight: bold; + } + description { + overflow: hidden; + } + } + + // Using .hover class instead of pseudo element to avoid the + // hover effect from sticking around after drag-and-drop + // https://bugs.webkit.org/show_bug.cgi?id=134555 + &.hover { + background-color: var(--fill-quinary) !important; + } + &:active { + background-color: var(--fill-quarternary) !important; + } + } + #zotero-tabs-menu-dragged { + opacity: 0; + } +} diff --git a/scss/components/_toolbarbutton.scss b/scss/components/_toolbarbutton.scss index 2dc6909e2a..259d3e656f 100644 --- a/scss/components/_toolbarbutton.scss +++ b/scss/components/_toolbarbutton.scss @@ -71,7 +71,7 @@ $toolbarbutton-icons: ( lookup: "magic-wand", attachment-add: "add-file", note-add: "note", - opened-tabs: "chevron", + tabs-menu: "chevron", sync-error: "error", sync: "sync", ); diff --git a/test/tests/zoteroPaneTest.js b/test/tests/zoteroPaneTest.js index 1bdffa83e3..a11bec7ea4 100644 --- a/test/tests/zoteroPaneTest.js +++ b/test/tests/zoteroPaneTest.js @@ -1526,7 +1526,7 @@ describe("ZoteroPane", function() { "zotero-collections-search", "zotero-tb-collection-add", "zotero-tb-sync", - "zotero-tb-opened-tabs" + "zotero-tb-tabs-menu" ]; for (let id of sequence) { @@ -1548,7 +1548,7 @@ describe("ZoteroPane", function() { it("should tab through the toolbar to collection-tree", async function () { win.Zotero_Tabs.moveFocus("current"); let sequence = [ - "zotero-tb-opened-tabs", + "zotero-tb-tabs-menu", "zotero-tb-sync", "zotero-tb-collection-add", "zotero-collections-search",