diff --git a/frontends/web/images/icons/icon_upside_down_triangle.svg b/frontends/web/images/icons/icon_upside_down_triangle.svg new file mode 100644 index 0000000..e7b4c85 --- /dev/null +++ b/frontends/web/images/icons/icon_upside_down_triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontends/web/index.html b/frontends/web/index.html index 82b5267..96ea7d8 100644 --- a/frontends/web/index.html +++ b/frontends/web/index.html @@ -169,7 +169,24 @@

Graffiti

 
- + + + + + + + +
+
Rename
diff --git a/frontends/web/scripts/TabsController.js b/frontends/web/scripts/TabsController.js index 17bf9c9..3e9dd8b 100644 --- a/frontends/web/scripts/TabsController.js +++ b/frontends/web/scripts/TabsController.js @@ -1,9 +1,10 @@ const STORAGE_VERSION = 2; class TabsController { - constructor(tabsView, contentView, contextMenu) { + constructor(tabsView, tabsSearchView, contentView, contextMenu) { this.tabs = []; this.tabsView = tabsView; + this.tabsSearchView = tabsSearchView; this.contentView = contentView; this.contextMenu = contextMenu; this.contextMenuOpenedForTab = null; @@ -83,8 +84,13 @@ class TabsController { #addDividers(tabId) { let index = 0; for (const { tabElement } of this.tabs) { - const dividerElement = this.#createDivider(index, tabId); - this.tabsView.insertBefore(dividerElement, tabElement); + // Only add divider before visible tabs + // This creates a better UX when searching tabs + // but dragging from LTR or RTL is inconsistent + if (tabElement.style.display !== "none") { + const dividerElement = this.#createDivider(index, tabId); + this.tabsView.insertBefore(dividerElement, tabElement); + } index++; } this.tabsView.appendChild(this.#createDivider(index, tabId)); @@ -306,8 +312,61 @@ class TabsController { } }); }; + + + this.tabsSearchView.addEventListener("input", (event) => { + // Hide context menu + realThis.contextMenuOpenedForTab = null; + realThis.contextMenu.classList.remove("visible"); + + setTimeout(() => { + realThis.onSearchTabs(event); + }); + }); + + // TODO: maybe fetch that in a nicer way for class params + const tabsScrollBtn = document.getElementsByClassName("tab-expand-button")[0]; + if (!tabsScrollBtn || !this.tabsView) return; + + this.updateTabArrowVisibility = () => { + // If the content is wider than the visible area, show the button. + const overflowing = this.tabsView.scrollWidth > this.tabsView.clientWidth + 1; + const inMultiRow = this.tabsView.classList.contains("multirow"); + tabsScrollBtn.style.display = overflowing | inMultiRow ? "" : "none"; + }; + + // Initial update + setTimeout(this.updateTabArrowVisibility, 0); + + // Update on window resize + window.addEventListener("resize", this.updateTabArrowVisibility); + + // Observe changes to the tab list (tabs added/removed, style changes from search) + try { + const mo = new MutationObserver(() => this.updateTabArrowVisibility()); + mo.observe(tabListWrapper, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + } catch (err) { + // MutationObserver shouldn't fail in modern browsers; fail silently. + } + } + + toggleTabBarMode() { + const tabListWrapper = this.tabsView; + tabListWrapper.classList.toggle("multirow"); + const iconElement = document.getElementsByClassName("tab-expand-button")[0].querySelector("img"); + iconElement.setAttribute( + "expand-icon-state", + tabListWrapper.classList.contains("multirow") ? "up" : "down" + ); + this.updateTabArrowVisibility(); } + removeCurrentTab() { this.#removeTab(this.selectedTab); } @@ -362,6 +421,36 @@ class TabsController { } } + onSearchTabs(event) { + // Read the current query from the provided search input element. + const query = (event.target && event.target.value) + ? event.target.value.toLowerCase() + : ""; + const iconElement = this.tabsSearchView.querySelector(".tab-search-button img") + + var has_matches = false; + this.tabs.forEach(({ name, tabElement }) => { + const matches = !query || name.toLowerCase().includes(query); + if (matches) { + has_matches = true; + } + tabElement.style.display = matches ? "" : "none"; + }); + + // Update the search icon color based on whether there are matches. + if (!has_matches) { + iconElement.setAttribute('data-icon-state', "gray"); + } else { + if (query) { + iconElement.setAttribute('data-icon-state', "green"); + } else { + iconElement.setAttribute('data-icon-state', "white"); + } + } + // Search results may have changed tab list size; update arrow visibility. + this.updateTabArrowVisibility(); + } + #addEmptyTab() { const emptyTab = this.addTab("untitled"); this.selectTab(emptyTab); diff --git a/frontends/web/scripts/commandPalette.js b/frontends/web/scripts/commandPalette.js index 23b11cf..5422e5d 100644 --- a/frontends/web/scripts/commandPalette.js +++ b/frontends/web/scripts/commandPalette.js @@ -445,6 +445,16 @@ class CommandPalette { handler: () => { event_copyNodeText(); } + }, + { + id: "ExpandTabBar", + title: "Expand tab bar to show all tabs or collapse it", + hotkey: "Ctrl+t", + icon: iconFor("upside_down_triangle"), + section: "Tabs", + handler: () => { + event_toggleTabBarMode(); + } } ]; } diff --git a/frontends/web/scripts/main.js b/frontends/web/scripts/main.js index 81b87cb..6c871e1 100644 --- a/frontends/web/scripts/main.js +++ b/frontends/web/scripts/main.js @@ -634,6 +634,10 @@ function event_showChangelog() { }); } +function event_toggleTabBarMode() { + tabsController.toggleTabBarMode(); +} + function event_showManageTokenDialog(isFromAuthEvent = false) { const currentToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY); const isWindows = navigator.userAgent.includes("Windows NT"); @@ -701,7 +705,8 @@ function main() { // Initiate tabs const tabsController = new TabsController( - document.getElementsByClassName("tabs")[0], + document.getElementsByClassName("tab-list-wrapper")[0], + document.getElementsByClassName("tab-search")[0], document.getElementsByClassName("view")[0], document.getElementById("context-menu") ); @@ -755,7 +760,7 @@ function initiateConnectionUrl() { function initiateHotkeys() { hotkeys( - "esc,ctrl+z,ctrl+shift+z,ctrl+y,ctrl+s,ctrl+alt+s,ctrl+o,ctrl+i,ctrl+alt+shift+i,ctrl+q,ctrl+f,ctrl+shift+f,ctrl+k,ctrl+e,ctrl+shift+q,ctrl+shift+p,delete,ctrl+delete,ctrl+c,home,ctrl+home,shift+`,/,shift+/,ctrl+shift+/,ctrl+a,1,2,3,4,5,6,7,8,9", + "esc,ctrl+z,ctrl+shift+z,ctrl+y,ctrl+s,ctrl+alt+s,ctrl+o,ctrl+i,ctrl+alt+shift+i,ctrl+q,ctrl+f,ctrl+shift+f,ctrl+k,ctrl+e,ctrl+shift+q,ctrl+shift+p,ctrl+t,delete,ctrl+delete,ctrl+c,home,ctrl+home,shift+`,/,shift+/,ctrl+shift+/,ctrl+a,1,2,3,4,5,6,7,8,9", function (event, handler) { if (!window.commandPalette.isOpen()) { switch (handler.key) { @@ -793,6 +798,9 @@ function initiateHotkeys() { case "ctrl+shift+q": event_addTextNode(); return false; + case "ctrl+t": + event_toggleTabBarMode(); + return false; case "delete": event_delete(); return false; diff --git a/frontends/web/styles/style.css b/frontends/web/styles/style.css index 652a521..b54e17b 100644 --- a/frontends/web/styles/style.css +++ b/frontends/web/styles/style.css @@ -105,24 +105,157 @@ body { background: #343434; } -#addTab { - float: right; +.add-tab-button { + /* don't float when using flex layout for .tabs; keep as fixed-size control */ + float: none; + order: 2; + flex: 0 0 auto; + align-self: flex-start; + height: 52px; + width: 40px; + display: inline-flex; + align-items: center; + justify-content: center; } /* Style the tab */ .tabs { - overflow: hidden; + display: flex; + float: none; + order: 2; + flex: 0 0 auto; background-color: var(--primary-color); } +/* The container that holds the tab buttons. It will stay on a single row + and allow horizontal scrolling when there are too many tabs. */ +.tab-list-wrapper { + order: 1; + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + flex: 1 1 auto; +} + +/* When `.multirow` is applied the tab list is allowed to wrap into + multiple rows instead of remaining on a single horizontally-scrollable + row. */ +.tab-list-wrapper.multirow { + flex-wrap: wrap; + overflow-x: visible; +} + +/* Make sure individual buttons inside the wrapper don't float/wrap */ +.tab-list-wrapper button { + float: none; + display: inline-flex; +} + +.tab-list-wrapper.multirow button { + flex: 0 0 auto; +} + +/* Collapsible search box inside the tabs area. It shows only the icon + when collapsed and expands to a fixed width when focused +*/ +.tab-search { + order: 0; + display: inline-flex; + align-items: center; + background: transparent; + border-radius: 6px; + overflow: hidden; + width: 36px; + transition: width 200ms ease; + flex: 0 0 auto; + align-self: flex-start; +} +.tab-search:focus-within { + width: 200px; +} +.tab-search:hover { + width: 200px; + background-color: var(--primary-color-hover); +} + +.tab-search-button img { + filter: none; +} + +.tab-search-button img[data-icon-state="white"] { + filter: brightness(0) saturate(100%) invert(100%) sepia(11%) saturate(0%) hue-rotate(14deg) brightness(103%) contrast(100%) +} +.tab-search-button img[data-icon-state="gray"] { + filter: brightness(0) saturate(100%) invert(67%) sepia(82%) saturate(0%) hue-rotate(133deg) brightness(95%) contrast(93%) +} +.tab-search-button img[data-icon-state="green"] { + filter: brightness(0) saturate(100%) invert(92%) sepia(15%) saturate(461%) hue-rotate(46deg) brightness(102%) contrast(102%); +} + +.tab-search-button { + width: 36px; + background: transparent; + border: none; + color: var(--text-color); + font-size: 16px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Arrow button shown when tab list is scrollable */ +.tab-expand-button { + float: none; + order: 2; + flex: 0 0 auto; + align-self: flex-start; + width: 40px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.tab-expand-button img { + display: block; + transform: none; + filter: brightness(0) saturate(100%) invert(100%) sepia(11%) saturate(0%) hue-rotate(14deg) brightness(103%) contrast(100%) +} +.tab-expand-button img[expand-icon-state="down"] { + transform: none; +} +.tab-expand-button img[expand-icon-state="up"] { + transform: scaleY(-1);; +} + +.tab-search-input { + background: transparent; + border: none; + outline: none; + color: var(--text-color); + padding: 8px 10px; + font-size: 14px; + width: 100%; + min-width: 0; +} +.tab-search-input::placeholder { + color: rgba(255,255,255,0.7); +} + .tabs .divider { background-color: inherit; - float: left; + /* Use flex sizing instead of float so the divider keeps its intended + dimensions inside the .tabs flex container. */ + float: none; border: white solid 2px; outline: none; cursor: grabbing; height: 48px; width: 20px; + min-width: 20px; + box-sizing: content-box; + flex: 0 0 20px; /* don't grow or shrink; keep fixed width */ + align-self: stretch; /* allow it to span the container height if needed */ + margin: 0 6px; } .tabs .divider.dragover {