scroll to element for linear view

This commit is contained in:
Henry Jameson 2026-09-09 18:22:18 +03:00
commit 7753a94a2f
6 changed files with 77 additions and 49 deletions

View file

@ -80,6 +80,19 @@ export default {
) )
} }
const tryScrollTo = async (id) => {
if (!id) {
return
}
if (isPage.value) {
router.push({ name: 'conversation', params: { statusId: id } })
}
setFocused(id)
const target = document.querySelector(`.Status[data-status-id=${id}]`)
await nextTick()
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const { statusId } = toRefs(props) const { statusId } = toRefs(props)
const router = useRouter() const router = useRouter()
@ -105,6 +118,7 @@ export default {
} }
provide('isExpanded', isExpanded) provide('isExpanded', isExpanded)
provide('isPage', isPage) provide('isPage', isPage)
provide('expandable', true)
// # Focus // # Focus
const focusedId = ref(statusId.value) const focusedId = ref(statusId.value)
@ -133,12 +147,13 @@ export default {
} = useConversation(focusedId, isExpanded) } = useConversation(focusedId, isExpanded)
watch(expanded, (value) => { watch(expanded, (value) => {
tryScrollTo(currentStatus.value.id)
if (value) { if (value) {
fetchConversation() fetchConversation()
} else { } else {
resetDisplayState() resetDisplayState()
} }
}) }, { flush: 'post' })
const resetDisplayState = () => { const resetDisplayState = () => {
setFocused(statusId.value) setFocused(statusId.value)
@ -181,7 +196,7 @@ export default {
heightChart: heightChartLinear, heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear, changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear, updateVirtualHeight: updateVirtualHeightLinear,
} = useVirtualScrolling(conversation, linearElement) } = useVirtualScrolling(conversation, linearElement, currentStatus)
// # Tree style stuff // # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree') const isTreeView = computed(() => displayStyle.value === 'tree')
@ -205,9 +220,10 @@ export default {
const currentLevelElement = useTemplateRef('currentLevel') const currentLevelElement = useTemplateRef('currentLevel')
const { const {
heightChart: heightChartCurrentLevel, heightChart: heightChartCurrentLevel,
totalHeight: totalHeightCurrentLevel,
changeSuspendState: changeSuspendStateCurrentLevel, changeSuspendState: changeSuspendStateCurrentLevel,
updateVirtualHeight: updateVirtualHeightCurrentLevel, updateVirtualHeight: updateVirtualHeightCurrentLevel,
} = useVirtualScrolling(currentLevel, currentLevelElement) } = useVirtualScrolling(currentLevel, currentLevelElement, currentStatus)
const treeViewIsSimple = computed( const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced, () => !mergedConfig.value.conversationTreeAdvanced,
@ -223,32 +239,6 @@ export default {
) )
// # Scrolling // # Scrolling
const tryScrollTo = (id) => {
if (!id) {
return
}
if (isPage.value) {
router.push({ name: 'conversation', params: { statusId: id } })
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
nextTick(() => {
setFocused(id)
})
}
const diveIntoStatus = (id) => { const diveIntoStatus = (id) => {
tryScrollTo(id) tryScrollTo(id)
} }
@ -261,6 +251,11 @@ export default {
loadError, loadError,
mobileLayout, mobileLayout,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Focus // # Focus
focused, focused,
setFocused, setFocused,
@ -270,11 +265,6 @@ export default {
currentStatus, currentStatus,
getReplies, getReplies,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Misc UI things // # Misc UI things
getStatusClasses, getStatusClasses,

View file

@ -106,6 +106,7 @@
:focused="focused === element.status.id" :focused="focused === element.status.id"
conversation-rank="ancestor" conversation-rank="ancestor"
:data-status-id="element.id"
@goto="setFocused" @goto="setFocused"
@dive="diveIntoStatus(element.status.id)" @dive="diveIntoStatus(element.status.id)"
@ -165,6 +166,7 @@
:focused="focused === element.id || focused === element.status.retweeted_status?.id" :focused="focused === element.id || focused === element.status.retweeted_status?.id"
:data-status-id="element.id"
@goto="setFocused" @goto="setFocused"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinear" @suspendable-state-change="changeSuspendStateLinear"

View file

@ -121,19 +121,15 @@ const Status = {
], ],
inject: { inject: {
profileUserId: { profileUserId: {
type: String,
default: null, default: null,
}, },
isPage: { isPage: {
type: Boolean,
default: false, default: false,
}, },
isExpanded: { isExpanded: {
type: Boolean,
default: false, default: false,
}, },
expandable: { expandable: {
type: Boolean,
default: false, default: false,
}, },
}, },

View file

@ -199,7 +199,7 @@
/> />
</span> </span>
<button <button
v-if="!isExpanded && !isPreview" v-if="expandable && !isExpanded && !isPreview"
class="button-unstyled" class="button-unstyled"
:title="$t('status.expand')" :title="$t('status.expand')"
@click.prevent="toggleExpanded" @click.prevent="toggleExpanded"

View file

@ -10,6 +10,7 @@
:replies="getReplies(statusId)" :replies="getReplies(statusId)"
:focused="focused === statusId" :focused="focused === statusId"
:data-status-id="statusId"
:conversation-rank="depth === 0 ? 'current' : 'child'" :conversation-rank="depth === 0 ? 'current' : 'child'"
:thread-display-state="threadDisplay.get(statusId)" :thread-display-state="threadDisplay.get(statusId)"

View file

@ -1,5 +1,5 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js' import { useStatusesStore } from 'src/stores/statuses.js'
@ -7,10 +7,11 @@ import { useStatusesStore } from 'src/stores/statuses.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.js' import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useWindowSize } from 'src/composables/useWindowSize.js' import { useWindowSize } from 'src/composables/useWindowSize.js'
export function useVirtualScrolling(conversation, body) { export function useVirtualScrolling(conversation, body, anchorStatus) {
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const { mergedConfig } = storeToRefs(useMergedConfigStore()) const { mergedConfig } = storeToRefs(useMergedConfigStore())
const anchor = computed(() => anchorStatus?.value.id)
const unsuspendibleIds = ref(new Set()) const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspend }) => { const changeSuspendState = ({ id, suspend }) => {
@ -32,7 +33,7 @@ export function useVirtualScrolling(conversation, body) {
fontSize.value = Number.parseInt(string.slice(0, -2), 10) // remove the 'px' fontSize.value = Number.parseInt(string.slice(0, -2), 10) // remove the 'px'
} }
// Update font size if user changed UI scale // Update font size if user changed UI scale
watch(fontSizeSetting, updateFontSize) watch(fontSizeSetting, updateFontSize, { immediate: true })
// Placeholder heights. // Placeholder heights.
const mutedStatusHeight = computed(() => { const mutedStatusHeight = computed(() => {
@ -79,10 +80,22 @@ export function useVirtualScrolling(conversation, body) {
topScrollBoundary.value = distanceItemTopToWindowTop topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom bottomScrollBoundary.value = distanceItemTopToWindowBottom
} }
watch(windowHeight, updateBoundaries) const windowWatcher = watch(windowHeight, updateBoundaries)
watch(scrollY, updateBoundaries) const scrollWatcher = watch(scrollY, updateBoundaries)
watch(totalHeight, updateBoundaries) const heightWatcher = watch(totalHeight, updateBoundaries)
watch(body, updateBoundaries) const bodyWatcher = watch(body, updateBoundaries)
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = () => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
}
const heightChart = computed(() => { const heightChart = computed(() => {
// Map every height and suspendable state // Map every height and suspendable state
@ -108,8 +121,34 @@ export function useVirtualScrolling(conversation, body) {
return sum + item.height return sum + item.height
}, 0) }, 0)
return chart
})
watch(heightChart, async (newVal, oldVal) => {
pauseWatchers()
const getAnchoredEl = (list) => anchor.value
? list.find(({ id }) => id === anchor.value)
: list[list.length - 1]
const oldElement = getAnchoredEl(oldVal)
const newElement = getAnchoredEl(newVal)
const oldOffset = oldElement?.top ?? 0
const newOffset = newElement?.top ?? 0
const diff = newOffset - oldOffset // Positive = down, Negative = up
console.log(diff, oldElement, newOffset)
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await nextTick()
window.scrollBy(0, diff)
updateBoundaries()
resumeWatchers()
})
const heightChartGrouped = computed(() => {
// Determine visibility state // Determine visibility state
chart.forEach((heightChartItem) => { const chart = heightChart.value.map((heightChartItem) => {
const itemBottomBoundary = heightChartItem.top + heightChartItem.height const itemBottomBoundary = heightChartItem.top + heightChartItem.height
const itemTopBoundary = heightChartItem.top const itemTopBoundary = heightChartItem.top
@ -123,7 +162,7 @@ export function useVirtualScrolling(conversation, body) {
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary // This accounts for the case where item's boundaries exceed scroll boundary
heightChartItem.visible = isBelowTopBoundary && isAboveBottomBoundary return { ...heightChartItem, visible: isBelowTopBoundary && isAboveBottomBoundary }
}) })
// Group invisible statuses into spacers // Group invisible statuses into spacers
@ -162,7 +201,7 @@ export function useVirtualScrolling(conversation, body) {
}) })
return { return {
heightChart, heightChart: heightChartGrouped,
changeSuspendState, changeSuspendState,
updateVirtualHeight, updateVirtualHeight,
} }