diff --git a/src/_locales/dict.setup-page.ts b/src/_locales/dict.setup-page.ts index 78cec0b7..19e90365 100644 --- a/src/_locales/dict.setup-page.ts +++ b/src/_locales/dict.setup-page.ts @@ -3537,6 +3537,54 @@ Przykłady: "*", "ctrl+$", "ctrl+alt+g"`, zh_TW: '無', ja: 'なし', }, + 'settings.sticky_ancestor_tabs': { + en: 'Sticky ancestor tabs', + de: 'Fixierte übergeordnete Tabs', + fr: 'Onglets ancêtres fixes', + hu: 'Rögzített felmenő lapok', + pl: 'Przypięte karty nadrzędne', + ru: 'Закреплённые вкладки-предки', + zh_CN: '固定祖先标签页', + zh_TW: '固定祖先分頁', + ja: '先祖タブを固定表示', + }, + 'settings.sticky_ancestor_tabs_limit': { + en: 'Sticky ancestor tabs depth limit', + de: 'Maximale Tiefe der fixierten übergeordneten Tabs', + fr: 'Limite de profondeur des onglets ancêtres fixes', + hu: 'A rögzített felmenő lapok mélységkorlátja', + pl: 'Limit głębokości przypiętych kart nadrzędnych', + ru: 'Максимальная глубина закреплённых вкладок-предков', + zh_CN: '固定祖先标签页深度上限', + zh_TW: '固定祖先分頁深度上限', + ja: '固定する先祖タブの深さ制限', + }, + 'settings.sticky_ancestor_tabs_limit_1': { + en: '1', + }, + 'settings.sticky_ancestor_tabs_limit_2': { + en: '2', + }, + 'settings.sticky_ancestor_tabs_limit_3': { + en: '3', + }, + 'settings.sticky_ancestor_tabs_limit_4': { + en: '4', + }, + 'settings.sticky_ancestor_tabs_limit_5': { + en: '5', + }, + 'settings.sticky_ancestor_tabs_limit_none': { + en: 'none', + de: 'Unbegrenzt', + fr: 'aucun', + hu: 'nincs', + pl: 'brak', + ru: 'выкл', + zh_CN: '无', + zh_TW: '無', + ja: 'なし', + }, 'settings.auto_fold_tabs': { en: 'Auto fold tabs branch on expanding another branch', de: 'Zweig beim Öffnen eines anderen Zweiges automatisch einklappen', diff --git a/src/defaults/panels.ts b/src/defaults/panels.ts index c2677c45..5d7dd39e 100644 --- a/src/defaults/panels.ts +++ b/src/defaults/panels.ts @@ -114,6 +114,7 @@ export const TABS_PANEL_STATE: T.TabsPanel = { visibleTabIds: [], pinnedTabIds: [], + stickyTabIds: [], updated: false, selNewTab: false, scrollRetainerHeight: 0, diff --git a/src/defaults/settings.ts b/src/defaults/settings.ts index 65a0e730..b58969c5 100644 --- a/src/defaults/settings.ts +++ b/src/defaults/settings.ts @@ -136,6 +136,8 @@ export const DEFAULT_SETTINGS: SettingsState = { // Tabs tree tabsTree: true, tabsTreeLimit: 'none', + stickyAncestorTabs: false, + stickyAncestorTabsLimit: 3, autoFoldTabs: false, autoFoldTabsExcept: 'none', autoExpandTabs: false, @@ -335,6 +337,7 @@ export const SETTINGS_OPTIONS = { tabsUpdateMark: ['all', 'pin', 'norm', 'none'], pinnedTabsPosition: ['panel', 'top', 'left', 'right'], tabsTreeLimit: [1, 2, 3, 4, 5, 'none'], + stickyAncestorTabsLimit: [1, 2, 3, 4, 5, 'none'], previewTabsMode: ['i', 'p'], previewTabsPageModeFallback: ['i', 'n'], previewTabsSide: ['right', 'left'], diff --git a/src/page.setup/components/settings.tabs.vue b/src/page.setup/components/settings.tabs.vue index 768cb4eb..d1380ec4 100644 --- a/src/page.setup/components/settings.tabs.vue +++ b/src/page.setup/components/settings.tabs.vue @@ -327,6 +327,23 @@ section(ref="el") :inactive="!Settings.state.tabsTree" :opts="Settings.getOpts('tabsTreeLimit')" @update:value="Settings.saveDebounced(150)") + ToggleField( + label="settings.sticky_ancestor_tabs" + dbg="stickyAncestorTabs" + v-model:value="Settings.state.stickyAncestorTabs" + :default="DEFAULT_SETTINGS.stickyAncestorTabs" + :inactive="!Settings.state.tabsTree" + @update:value="Settings.saveDebounced(150)") + .sub-fields + SelectField( + label="settings.sticky_ancestor_tabs_limit" + optLabel="settings.sticky_ancestor_tabs_limit_" + dbg="stickyAncestorTabsLimit" + v-model:value="Settings.state.stickyAncestorTabsLimit" + :default="DEFAULT_SETTINGS.stickyAncestorTabsLimit" + :inactive="!Settings.state.tabsTree || !Settings.state.stickyAncestorTabs" + :opts="Settings.getOpts('stickyAncestorTabsLimit')" + @update:value="Settings.saveDebounced(150)") ToggleField( label="settings.auto_fold_tabs" :inactive="!Settings.state.tabsTree" diff --git a/src/services/tabs.fg.handlers.ts b/src/services/tabs.fg.handlers.ts index 79cade5f..e8d45ef6 100644 --- a/src/services/tabs.fg.handlers.ts +++ b/src/services/tabs.fg.handlers.ts @@ -1733,6 +1733,16 @@ function onTabActivated(info: browser.tabs.ActiveInfo): void { const panel = Sidebar.panelsById[tab.panelId] if (!Utils.isTabsPanel(panel)) return + // Update the sticky tab hierarchy (ancestors of the active tab) for this panel, + // and clear it on the panel the active tab just left. + if (Settings.state.stickyAncestorTabs) { + Tabs.calcStickyTabs(panel) + if (prevActive && prevActive.panelId !== panel.id) { + const prevPanel = Sidebar.panelsById[prevActive.panelId] + if (Utils.isTabsPanel(prevPanel)) Tabs.calcStickyTabs(prevPanel) + } + } + // Update succession Tabs.updateSuccessionDebounced(0) diff --git a/src/services/tabs.fg.scroll.ts b/src/services/tabs.fg.scroll.ts index 070c02fd..0ffabf6a 100644 --- a/src/services/tabs.fg.scroll.ts +++ b/src/services/tabs.fg.scroll.ts @@ -2,6 +2,8 @@ import { TabsPanel } from 'src/types' import * as Utils from 'src/utils' import * as Logs from 'src/services/logs' import * as Sidebar from 'src/services/sidebar.fg' +import * as Settings from 'src/services/settings' +import * as Tabs from 'src/services/tabs.fg' import { PRE_SCROLL } from 'src/defaults' export let blockedScrollPosition = false @@ -94,3 +96,69 @@ export function resetScrollRetainer(panel: TabsPanel) { panel.reactive.scrollRetainerHeight = 0 blockedScrollPosition = false } + +/** + * Recalculate the sticky tab hierarchy: the ancestor chain of the active tab, but only + * the ancestors whose row has scrolled above the top of the viewport (so an ancestor + * stops being sticky once it's visible in the list again), capped by the configured + * depth limit. Pinned only on the panel that contains the active tab. Writes the result + * to `panel.reactive.stickyTabIds` (only when it actually changes). + */ +export function calcStickyTabs(panel: TabsPanel): void { + const reactive = panel.reactive + + const reset = () => { + if (reactive.stickyTabIds.length) reactive.stickyTabIds = [] + } + + if (!Settings.state.tabsTree || !Settings.state.stickyAncestorTabs || !panel.scrollEl) { + return reset() + } + + const activeTab = Tabs.byId[Tabs.activeId] + if (!activeTab || activeTab.pinned || activeTab.panelId !== panel.id) return reset() + + const tabFullHeight = Sidebar.tabHeight + Sidebar.tabMargin + if (tabFullHeight <= 0) return reset() + + // Build the ancestor chain (root-most first) of the active tab via parentId. + const ancestors: ID[] = [] + let parent = Tabs.byId[activeTab.parentId] + let guard = 0 + while (parent && guard++ < 256) { + ancestors.unshift(parent.id) + parent = Tabs.byId[parent.parentId] + } + + // Keep only ancestors that are hidden: either scrolled above the viewport top, or + // covered by the sticky rows already pinned above them. Each sticky row we add pushes + // the cutoff down by one row height (it hides the tab beneath it), so the bottom of + // the accumulated sticky stack is the real cutoff. Ancestors are ordered root -> active + // with increasing row position, so stop at the first one still fully visible below the + // stack. + const visibleIds = reactive.visibleTabIds + const scrollTop = panel.scrollEl.scrollTop + const sticky: ID[] = [] + let stackBottom = 0 // viewport-Y of the bottom of the accumulated sticky stack + for (const id of ancestors) { + const idx = visibleIds.indexOf(id) + if (idx === -1) continue + const rowViewportTop = idx * tabFullHeight - scrollTop + if (rowViewportTop < stackBottom) { + sticky.push(id) + stackBottom += tabFullHeight + } else break + } + + // Apply the depth cap (keep the deepest N ancestors, closest to the active tab). + const limit = Settings.state.stickyAncestorTabsLimit + let result = sticky + if (typeof limit === 'number' && sticky.length > limit) { + result = sticky.slice(sticky.length - limit) + } + + // Skip the reactive write when nothing changed. + const cur = reactive.stickyTabIds + if (cur.length === result.length && cur.every((id, i) => id === result[i])) return + reactive.stickyTabIds = result +} diff --git a/src/services/tabs.fg.ts b/src/services/tabs.fg.ts index d3384ec8..aa255849 100644 --- a/src/services/tabs.fg.ts +++ b/src/services/tabs.fg.ts @@ -2900,8 +2900,14 @@ export function renderTitle(tab: T.Tab, forcedTitle?: string) { } export function renderFavicon(tab: T.Tab) { - const imgEl = tab.favImgEl - const svgUseEl = tab.favSvgUseEl + renderFaviconInto(tab, tab.favImgEl, tab.favSvgUseEl) +} + +export function renderFaviconInto( + tab: T.Tab, + imgEl?: HTMLImageElement, + svgUseEl?: SVGElement +): void { if (tab.favIconUrl && imgEl) { // Set img imgEl.src = tab.favIconUrl diff --git a/src/sidebar/components/panel.tabs.vue b/src/sidebar/components/panel.tabs.vue index 57a96c0c..cebb4d93 100644 --- a/src/sidebar/components/panel.tabs.vue +++ b/src/sidebar/components/panel.tabs.vue @@ -9,6 +9,7 @@ @drop="onDrop") PinnedTabsBar(v-if="panel.reactive.pinnedTabIds.length" :panel="panel") ScrollBox(ref="scrollBox" :preScroll="D.PRE_SCROLL") + StickyTabs(:panel="panel") DragAndDropPointer(:panelId="panel.id" :subPanel="false") AnimatedTabList(:panel="panel") TabComponent(v-for="id in panel.reactive.visibleTabIds" :key="id" :tabId="id") @@ -30,7 +31,7 @@ diff --git a/src/sidebar/components/tab.vue b/src/sidebar/components/tab.vue index df616e5c..e0fd6ea4 100644 --- a/src/sidebar/components/tab.vue +++ b/src/sidebar/components/tab.vue @@ -1,6 +1,7 @@