diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 3b8bd6b4a..ee166a578 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -7,6 +7,7 @@ import { toRefs, useTemplateRef, watch, + nextTick, } from 'vue' import { useRouter } from 'vue-router' @@ -65,22 +66,11 @@ export default { PostStatusForm, RichContent, }, - emits: ['heightChange', 'suspendableStateChange'], + emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'], setup(props, { emit }) { const router = useRouter() const scroller = useScrollPosition() - - 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}"]`) - return await scroller.scrollIntoView(target, { block: 'nearest' }) - } + const { statusId } = toRefs(props) // # Main Configuration / global state const { mergedConfig } = storeToRefs(useMergedConfigStore()) @@ -92,17 +82,27 @@ export default { const expanded = ref(false) const { isPage } = toRefs(props) const isExpanded = computed(() => !!(expanded.value || isPage.value)) - const toggleExpanded = () => { - expanded.value = !expanded.value + const toggleExpanded = async () => { + const newVal = !expanded.value + if (newVal) { + virtualScrollingEnabled.value = newVal + await nextTick() + expanded.value = newVal + } else { + expanded.value = newVal + await nextTick() + virtualScrollingEnabled.value = newVal + } } provide('isExpanded', isExpanded) provide('isPage', isPage) provide('expandable', true) + watch(expanded, (val) => val ? emit('expanded') : emit('collapsed'), { flush: 'post' }) // # Main things - const { statusId } = toRefs(props) const { focusedId, + conversationId, setFocused, currentStatus, mainStatus, @@ -115,12 +115,13 @@ export default { const conversationLite = computed(() => conversation.value.map(({ id }) => ({ id })), ) + const mainStatusId = computed(() => mainStatus.value.id) watch( expanded, - async (value) => { + (value) => { if (value) { - await fetchConversation() + fetchConversation() } }, { flush: 'post' }, @@ -154,8 +155,15 @@ export default { () => !isExpanded.value && unsuspendableIds.value.size === 0, ) const rootElement = useTemplateRef('root') + const rootElementMargin = ref(null) const updateVirtualHeight = (e) => { const [entry] = e + + const rootCss = window.getComputedStyle(rootElement.value) + const rootMarginString = rootCss.getPropertyValue('margin-top') + const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10) + rootElementMargin.value = rootMargin + emit('heightChange', { id: statusId.value, height: entry.contentRect.height, @@ -168,15 +176,27 @@ export default { emit('suspendableStateChange', { suspend: value, id: statusId.value }), ) onUnmounted(() => resizeObserver.value.disconnect()) + // Internal virtual scrolling + const virtualScrollingEnabled = ref(isExpanded.value) // Placeholder heights. - const { fontSize } = useInterfaceSizes() + const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes() const mutedStatusHeight = computed(() => fontSize.value * 1.5) const normalStatusHeight = computed(() => fontSize.value * 10) const getPlaceholderHeight = (id) => conversation.value.find((item) => item.id === id)?.muted ? mutedStatusHeight : normalStatusHeight + const offset = computed(() => { + // The fontsize after navbar is the little gap between navbar and content + if (isPage.value) { + return navbarSize.value + fontSize.value + panelHeaderSize.value + } else if (expanded.value) { + return navbarSize.value + fontSize.value + panelHeaderSize.value * 2 + rootElementMargin.value + } else { + return navbarSize.value + fontSize.value + } + }) const anchorIds = computed( () => new Set([mainStatus.value?.id, currentStatus.value?.id]), @@ -186,17 +206,20 @@ export default { const isLinearView = computed(() => displayStyle.value !== 'tree') const linearElement = useTemplateRef('linear') const linearScrollCompensation = computed( - () => isExpanded.value && isLinearView.value, + () => virtualScrollingEnabled.value && isLinearView.value, ) const { heightChart: heightChartLinear, changeSuspendState: changeSuspendStateLinear, updateVirtualHeight: updateVirtualHeightLinear, reset: resetLinearScrollVirtualization, + scrollTo: linearScrollTo, } = useVirtualScrolling({ - context: statusId, + name: 'Linear', + enabled: linearScrollCompensation, list: conversationLite, body: linearElement, + offset, scrollPositionInstance: scroller, scrollCompensation: linearScrollCompensation, anchorIds, @@ -236,17 +259,20 @@ export default { ) const ancestorsElement = useTemplateRef('ancestors') const treeScrollCompensation = computed( - () => isExpanded.value && isTreeView.value, + () => virtualScrollingEnabled.value && isTreeView.value, ) const { heightChart: heightChartAncestors, changeSuspendState: changeSuspendStateAncestors, updateVirtualHeight: updateVirtualHeightAncestors, reset: resetTreeScrollVirtualization, + scrollTo: treeScrollTo, } = useVirtualScrolling({ - context: statusId, + name: 'Ancestors', + enabled: treeScrollCompensation, list: currentAncestorsLite, body: ancestorsElement, + offset, scrollPositionInstance: scroller, scrollCompensation: treeScrollCompensation, collapseMode: 'height', @@ -271,11 +297,12 @@ export default { } } - watch(statusId, (neu, old) => { + watch(conversationId, (neu, old) => { resetLinearScrollVirtualization() resetTreeScrollVirtualization() }) + const treeViewIsSimple = computed( () => !mergedConfig.value.conversationTreeAdvanced, ) @@ -287,8 +314,19 @@ export default { ) // # Scrolling - const diveIntoStatus = (id) => tryScrollTo(id) - const diveToTopLevel = () => tryScrollTo(currentAncestors.value[0].id) + const scrollTo = (ids) => { + if (isTreeView.value) { + return treeScrollTo(ids) + } else { + return linearScrollTo(ids) + } + } + const diveIntoStatus = (id) => scrollTo(new Set([id])) + const diveToTopLevel = () => scrollTo(new Set([currentAncestors.value[0].id])) + watch(focusedId, async (neu) => { + if (!isPage.value) return + if (neu) scrollTo(new Set([neu])) + }) return { // # Misc diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 7344b8bb7..50f20a5b9 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -6,6 +6,7 @@ >

diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 38376dc81..77a160672 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -8,6 +8,7 @@ import { toRefs, useTemplateRef, watch, + nextTick, } from 'vue' import { useI18n } from 'vue-i18n' @@ -69,7 +70,7 @@ const Timeline = { }) // Virtual scrolling - const { fontSize } = useInterfaceSizes() + const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes() // Placeholder heights. const mutedStatusHeight = computed(() => fontSize.value * 1.5) @@ -80,12 +81,27 @@ const Timeline = { : normalStatusHeight const body = useTemplateRef('timeline') - const { heightChart, changeSuspendState, updateVirtualHeight } = + const offset = computed(() => { + if (embedded.value) { + // The fontsize after navbar is the little gap between navbar and content + return navbarSize.value + fontsize.value + } else { + return 0 + } + }) + const { + heightChart, + changeSuspendState, + updateVirtualHeight, + } = useVirtualScrolling({ + name: 'Timeline', + enabled: ref(true), list: filteredVisibleStatuses, body, + offset, scrollPositionInstance: useScrollPosition(), - scrollCompensation: false, + scrollCompensation: ref(false), getPlaceholderHeight, }) diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js index f78075488..b4c76e026 100644 --- a/src/composables/useConversation.js +++ b/src/composables/useConversation.js @@ -17,6 +17,7 @@ import { WSConnectionStatus } from 'src/api/websocket.js' export function useConversation(statusId, expanded) { const loadError = ref(null) const { status: currentStatus, mainStatus } = useMainStatus(statusId) + const mainStatusId = computed(() => mainStatus.value?.id) // # Config const { mergedConfig } = storeToRefs(useMergedConfigStore()) @@ -28,16 +29,23 @@ export function useConversation(statusId, expanded) { ) // # Focus - const focusedId = ref(null) - const { mainStatus: focusedStatus } = useMainStatus(focusedId) + const focused = ref(null) + const { mainStatus: focusedStatus } = useMainStatus(focused) const setFocused = (id) => { - focusedId.value = id + focused.value = id } + watch(mainStatusId, (val) => setFocused(val)) + const focusedId = computed(() => expanded.value ? focusedStatus.value?.id : null) provide('focusedId', focusedId) - watch(mainStatus, (newStatus, oldStatus) => { - if (newStatus) setFocused(newStatus.id) - if (newStatus.id !== oldStatus.id) { + watch(statusId, (neu, old) => { + console.log('focused', neu) + if (neu) setFocused(neu) + console.log('focusedid', focusedId.value) + }, { immediate: true }) + + watch(statusId, (neu, old) => { + if (neu !== old) { fetchConversation() } }) @@ -45,7 +53,7 @@ export function useConversation(statusId, expanded) { watch( expanded, (value) => { - setFocused(value ? statusId.value : null) + setFocused(value ? mainStatusId.value : null) }, { immediate: true }, ) @@ -83,7 +91,7 @@ export function useConversation(statusId, expanded) { } } const conversationId = computed( - () => mainStatus.value.statusnet_conversation_id, + () => mainStatus.value?.statusnet_conversation_id, ) const conversation = computed(() => { if (!currentStatus.value) { @@ -159,6 +167,7 @@ export function useConversation(statusId, expanded) { return { focusedId, + conversationId, setFocused, currentStatus, mainStatus, diff --git a/src/composables/useInterfaceSizes.js b/src/composables/useInterfaceSizes.js index 7404d6cd5..baf567913 100644 --- a/src/composables/useInterfaceSizes.js +++ b/src/composables/useInterfaceSizes.js @@ -21,7 +21,6 @@ export function useInterfaceSizes() { const navbarSize = computed(() => { const string = - fontSize.value * window.getComputedStyle(document.body).getPropertyValue('--navbarSize') return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem' @@ -29,7 +28,6 @@ export function useInterfaceSizes() { const panelHeaderSize = computed(() => { const string = - fontSize.value * window .getComputedStyle(document.body) .getPropertyValue('--panelHeaderSize') diff --git a/src/composables/useMainStatus.js b/src/composables/useMainStatus.js index be9e58225..d778d3df7 100644 --- a/src/composables/useMainStatus.js +++ b/src/composables/useMainStatus.js @@ -1,14 +1,16 @@ import { computed, toValue } from 'vue' +import { storeToRefs } from 'pinia' import { useStatusesStore } from 'src/stores/statuses.js' export function useMainStatus(statusId) { - const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) + const statusesStore = storeToRefs(useStatusesStore()) + const getStatusObject = (id) => statusesStore.allStatuses.value.get(id) - const status = computed(() => getStatusObject(toValue(statusId))) + const status = computed(() => getStatusObject(statusId.value)) const mainStatus = computed(() => { - if (!status.value) return + if (!status.value) return null const retweetedStatusId = status.value.retweeted_status?.id if (retweetedStatusId) { return getStatusObject(retweetedStatusId) diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js index d32f6a261..e0613d9fb 100644 --- a/src/composables/useScrollPosition.js +++ b/src/composables/useScrollPosition.js @@ -20,9 +20,9 @@ export function useScrollPosition() { window.removeEventListener('scroll', update) }) - const scrollBy = async (x1, y1, options) => { + const scrollBy = async (...args) => { inProgress.value = true - await window.scrollBy(x1, y1, options) + await window.scrollBy(...args) inProgress.value = false } @@ -40,8 +40,9 @@ export function useScrollPosition() { if (aboveTop || belowBottom || biggerThanScreen) { await element.scrollIntoView(options) } + } else { + await element.scrollIntoViewIfNeeded(options) } - await element.scrollIntoViewIfNeeded(options) inProgress.value = false } diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js index af80b274e..16b03efa9 100644 --- a/src/composables/useVirtualScrolling.js +++ b/src/composables/useVirtualScrolling.js @@ -4,17 +4,21 @@ import { computed, nextTick, ref, toValue, watch } from 'vue' import { useWindowSize } from 'src/composables/useWindowSize.js' export function useVirtualScrolling({ + // For debugging + name = 'Generic', + // Master toggle + enabled, // List of items list, // Container of items, used for measuring scroll position body, + // vertical offset to account for fixed and sticky headers + offset, // useScrollPosition composable, used to prevent dupicating instances scrollPositionInstance, // buffer zone, the amount of placeholder heights to include buffer, // whether to use scroll compensation when elements above anchor change - // set to 'positive' to only compensate for positive increase (useful when - // combined with infinite scroll) scrollCompensation, // How to handle collapse/expansion (going from 0 elements to full and back) // - false - don't do scroll compensation at all @@ -56,7 +60,7 @@ export function useVirtualScrolling({ })() const suspendable = !unsuspendibleIds.value.has(id) const real = heights.value.has(id) - return { id, height, suspendable, real } + return { id, height, suspendable, real, visible: true } }) // Walk over the list to set top offsets @@ -79,11 +83,12 @@ export function useVirtualScrolling({ const bottomScrollBoundary = ref(0) const updateBoundaries = () => { + if (!toValue(enabled)) return if (!body.value) return // Not mounted yet const { top } = body.value.getBoundingClientRect() - const distanceItemTopToWindowTop = 0 - top + const distanceItemTopToWindowTop = 0 - top + offset.value const distanceItemTopToWindowBottom = windowHeight.value - top // Technically, bottom scroll boundary should be distance @@ -115,6 +120,14 @@ export function useVirtualScrolling({ updateBoundaries() } + watch(enabled, (val) => { + if (val) { + resumeWatchers() + } else { + pauseWatchers() + } + }) + // # Visiblity // Add buffer zone to boundary, equal to approx 3 items heights const bufferZone = computed( @@ -145,64 +158,10 @@ export function useVirtualScrolling({ visible: checkVisible(heightChartItem), })), ) - - // ## Scroll compensation - watch( - heightChart, - async (newVal, oldVal) => { - if (!toValue(scrollCompensation)) return - if (newVal.length === 0 && oldVal.length === 0) return - - const diff = (() => { - const expansion = oldVal.length === 0 && newVal.length !== 0 - const collapse = oldVal.length !== 0 && newVal.length === 0 - - if (expansion) { - if (toValue(collapseMode) === 'height') { - const newBottomElement = last(newVal) - - return newBottomElement.top + newBottomElement.height - } else if (toValue(collapseMode) === 'item') { - const element = newVal.find(({ id }) => toValue(anchorIds).has(id)) - - return element.top - } else { - return 0 - } - } else if (collapse) { - const oldBottomElement = last(oldVal) - - return 0 - oldBottomElement.top - oldBottomElement.height - } else { - const oldVisible = oldVal.filter((item) => checkVisible(item)) - const oldItem = first(oldVisible) - if (!oldItem) return 0 // probably out of bounds in timeline - const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id) - if (!oldItemUpdated) return 0 // context change? - return ( - oldItemUpdated.top - - oldItem.top - - (oldItem.height - oldItemUpdated.height) - ) - } - })() - - if (diff !== 0) { - // Scroll by amount offset changed to keep it in view - topScrollBoundary.value += diff - bottomScrollBoundary.value += diff - await scrollBy(0, diff) - await nextTick() - } - - resumeWatchers() - }, - { flush: 'post' }, - ) - - const heightChartGrouped = computed(() => + const heightChartGrouped = computed(() => { + const chart = enabled.value ? heightChartVisibility : heightChart // Group invisible items into spacers - heightChartVisibility.value.reduce((acc, heightChartItem) => { + return chart.value.reduce((acc, heightChartItem) => { const { suspendable, visible, height, top, bottom, id } = heightChartItem // Bottom value isn't really used otherwise for debugging const present = visible || !suspendable @@ -238,14 +197,112 @@ export function useVirtualScrolling({ return [...acc, spacer] } } - }, []), + }, []) + }) + + // ## Scroll compensation + watch( + heightChart, + async (newVal, oldVal) => { + if (!toValue(scrollCompensation)) return + if (newVal.length === 0 && oldVal.length === 0) return + const expansion = oldVal.length === 0 && newVal.length !== 0 + const collapse = oldVal.length !== 0 && newVal.length === 0 + + const diff = (() => { + if (expansion) { + if (toValue(collapseMode) === 'height') { + const newBottomElement = last(newVal) + + return newBottomElement.top + newBottomElement.height + } else if (toValue(collapseMode) === 'item') { + const element = newVal.find(({ id }) => toValue(anchorIds).has(id)) + + return element.top + } else { + return 0 + } + } else if (collapse) { + const oldBottomElement = last(oldVal) + + return 0 - oldBottomElement.top - oldBottomElement.height + } else { + const contextChange = (() => { + const oldIds = new Set(oldVal.map(({ id }) => id)) + const newIds = new Set(newVal.map(({ id }) => id)) + + if (oldVal.length <= newVal.length) { + return [...oldIds].some((id) => !newIds.has(id)) + } else { + return [...newIds].some((id) => !oldIds.has(id)) + } + })() + if (contextChange) return 0 + + const expansion = (() => { + if (newVal.length < oldVal.length) return 0 + const oldVisible = oldVal.filter((item) => checkVisible(item) && item.real) + const oldItem = first(oldVisible) + if (!oldItem) return 0 // probably out of bounds in timeline + const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id) + + return ( + oldItemUpdated.top - + oldItem.top - + (oldItem.height - oldItemUpdated.height) + ) + })() + + const collapsing = (() => { + if (newVal.length >= oldVal.length) return 0 + const newVisible = newVal + const newItem = first(newVisible) + if (!newItem) return 0 // probably out of bounds in timeline + const newItemBefore = oldVal.find(({ id }) => id === newItem.id) + + return ( + newItem.top - + newItemBefore.top - + (newItemBefore.height - newItem.height) + ) + })() + + return expansion + collapsing + } + })() + + if (diff !== 0) { + // Scroll by amount offset changed to keep it in view + topScrollBoundary.value += diff + bottomScrollBoundary.value += diff + await scrollBy(0, diff) + await nextTick() + } + + resumeWatchers() + }, + { flush: 'post' }, ) + // Misc const reset = async () => { unsuspendibleIds.value = new Set() heights.value = new Map() } + const scrollTo = (anchors) => { + pauseWatchers() + + const element = heightChart.value.find(({ id }) => anchors.has(id)) + const elementMiddle = element.top + element.height / 2 + const desiredTopBoundary = Math.min(element.top, elementMiddle - (windowHeight.value - offset.value) / 2) + + console.log(element, desiredTopBoundary, topScrollBoundary.value) + scrollBy(0, desiredTopBoundary - topScrollBoundary.value) + + resumeWatchers() + } + return { heightChart: heightChartGrouped, changeSuspendState, @@ -254,5 +311,6 @@ export function useVirtualScrolling({ resumeWatchers, updateBoundaries, reset, + scrollTo, } }