Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/_locales/dict.setup-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/defaults/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const TABS_PANEL_STATE: T.TabsPanel = {

visibleTabIds: [],
pinnedTabIds: [],
stickyTabIds: [],
updated: false,
selNewTab: false,
scrollRetainerHeight: 0,
Expand Down
3 changes: 3 additions & 0 deletions src/defaults/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down
17 changes: 17 additions & 0 deletions src/page.setup/components/settings.tabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/services/tabs.fg.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions src/services/tabs.fg.scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
10 changes: 8 additions & 2 deletions src/services/tabs.fg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions src/sidebar/components/panel.tabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -30,7 +31,7 @@
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { translate } from 'src/dict'
import type { ScrollBoxComponent, TabsPanel } from 'src/types'
import * as E from 'src/enums'
Expand All @@ -50,6 +51,7 @@ import PanelPlaceholder from './panel-placeholder.vue'
import NewTabBar from './bar.new-tab.vue'
import DragAndDropPointer from './dnd-pointer.vue'
import AnimatedTabList from './animated-tab-list.vue'
import StickyTabs from './sticky-tabs.vue'

const props = defineProps<{ panel: TabsPanel }>()
const scrollBox = ref<ScrollBoxComponent | null>(null)
Expand All @@ -59,14 +61,44 @@ const bottomBarSpaceNeeded =
Settings.state.subPanelHistory
let scrollBoxEl: HTMLElement | null = null

let stickyRafId = 0
function scheduleStickyUpdate(): void {
if (stickyRafId) return
stickyRafId = requestAnimationFrame(() => {
stickyRafId = 0
Tabs.calcStickyTabs(props.panel)
})
}

onMounted(() => {
if (scrollBox.value) {
Sidebar.setPanelScrollBox(props.panel.id, scrollBox.value)
scrollBoxEl = scrollBox.value.getScrollBox()
if (scrollBoxEl) Sidebar.setPanelEls(props.panel.id, { scrollBox: scrollBoxEl })
if (scrollBoxEl) {
Sidebar.setPanelEls(props.panel.id, { scrollBox: scrollBoxEl })
scrollBoxEl.addEventListener('scroll', scheduleStickyUpdate, { passive: true })
}
}
scheduleStickyUpdate()
})

onBeforeUnmount(() => {
if (scrollBoxEl) scrollBoxEl.removeEventListener('scroll', scheduleStickyUpdate)
if (stickyRafId) cancelAnimationFrame(stickyRafId)
})

// The sticky hierarchy is the active tab's ancestors that have scrolled above the
// viewport top, so it must update on scroll (listener above). Active-tab switches are
// recomputed from the activation handler (activeId isn't reactive); this watch covers
// structure changes that can re-parent the active tab or change ancestor row positions
// (move/indent/outdent, add/remove, fold/expand) and feature toggling. The deep watch
// catches in-place reorders of the visible list, not just length changes.
watch(
[() => props.panel.reactive.visibleTabIds, () => Settings.state.stickyAncestorTabs],
() => scheduleStickyUpdate(),
{ deep: true }
)

function onDrop(): void {
DnD.reactive.dstType = E.DropType.Tabs
}
Expand Down
26 changes: 26 additions & 0 deletions src/sidebar/components/sticky-tabs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template lang="pug">
.StickyTabs(v-if="show")
TabComponent(
v-for="id in panel.reactive.stickyTabIds"
:key="id"
:tabId="id"
:sticky="true")
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import type { TabsPanel } from 'src/types'
import * as Settings from 'src/services/settings'
import * as Search from 'src/services/search.fg'
import TabComponent from './tab.vue'

const props = defineProps<{ panel: TabsPanel }>()

const show = computed<boolean>(
() =>
Settings.state.tabsTree &&
Settings.state.stickyAncestorTabs &&
!Search.reactive.active &&
props.panel.reactive.stickyTabIds.length > 0
)
</script>
19 changes: 17 additions & 2 deletions src/sidebar/components/tab.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template lang="pug">
.Tab(
:id="'tab' + tab.id"
:id="sticky ? undefined : 'tab' + tab.id"
:class="{ '-sticky': sticky }"
:data-pin="!!iconOnly"
:data-active="tab.reactive.active"
:data-loading="tab.reactive.status === TabStatus.Loading"
Expand Down Expand Up @@ -95,7 +96,7 @@ import * as Utils from 'src/utils'
import * as Logs from 'src/services/logs'
import * as Preview from 'src/services/tabs.fg.preview'

const props = defineProps<{ tabId: ID }>()
const props = defineProps<{ tabId: ID; sticky?: boolean }>()
const tab = Tabs.byId[props.tabId] as Tab
const iconOnly =
tab.pinned &&
Expand Down Expand Up @@ -124,6 +125,14 @@ const tabColor = computed<string>(() => {
})

onMounted(() => {
// Sticky clones are a second view of the same tab. They must not overwrite the
// shared element refs on the tab object (those drive the real row's favicon/title/
// flash updates), so render the favicon into the clone's own elements instead.
if (props.sticky) {
Tabs.renderFaviconInto(tab, favImgEl.value ?? undefined, favSvgUseEl.value ?? undefined)
return
}

if (titleEl.value) tab.titleEl = titleEl.value
if (favImgEl.value) tab.favImgEl = favImgEl.value
if (favSvgUseEl.value) tab.favSvgUseEl = favSvgUseEl.value
Expand Down Expand Up @@ -734,6 +743,12 @@ function onExpandMouseUp(e: MouseEvent): void {
}

function onError(): void {
// For a sticky clone, fall back to the placeholder locally without mutating the
// shared tab (which would affect the real row).
if (props.sticky) {
Tabs.renderFaviconInto(tab, undefined, favSvgUseEl.value ?? undefined)
return
}
tab.favIconUrl = undefined
Tabs.renderFavicon(tab)
}
Expand Down
1 change: 1 addition & 0 deletions src/styles/sidebar/sidebar.styl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vendors = official
@import '../popup.new-tab-shortcuts'

@import './tab'
@import './sticky-tabs'
@import './bookmark-card'
@import './bar.pinned-tabs'
@import './bar.navigation'
Expand Down
26 changes: 26 additions & 0 deletions src/styles/sidebar/sticky-tabs.styl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@import '../utils'

// Zero-height sticky anchor pinned to the top of the scroll viewport.
// Its rows overflow downward as an overlay, so they take no flow space
// (no layout shift) and align automatically with the scrollable content.
.StickyTabs
position: sticky
top: 0
left: 0
width: 100%
height: 0
z-index: 1000

.StickyTabs > .Tab.-sticky
z-index: 1000
cursor: pointer
background-color: var(--frame-bg)
transition: none

// Don't draw tree-connector lines between stacked sticky rows.
.StickyTabs > .Tab.-sticky .body:after
display: none

// Separator shadow under the lowest sticky row, against the scrolling list.
.StickyTabs > .Tab.-sticky:last-child
box-shadow: 0 6px 8px -6px #00000060
Loading
Loading