diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2023-01-13 18:25:40 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-01-13 18:25:40 +0900 |
| commit | d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b (patch) | |
| tree | 3479f9cde6139af5cebf94cf6de005e16c404e21 /packages/frontend/src/components | |
| parent | Update CHANGELOG.md (diff) | |
| download | misskey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.tar.gz misskey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.tar.bz2 misskey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.zip | |
refactor: pagination/date-separated-list系処理を良い感じに? (#8209)
* pages/messaging/messaging-room.vue
* wip
* wip
* wip???
* wip?
* :v:
* messaaging-room.form.vue rewrite to compositon api
* refactor
* 関心事でないのでとりあえず置いておく
* :art:
* :art:
* i18n.ts
* fix scroll container find function
* fix
* FIX
* :v:
* Fix scroll bottom detect
* wip
* aaaaaaaaaaa
* rename
* fix
* fix?
* :v:
* :v:
* clean up
* clena up
* refactor
* scroll event once or not
* fix
* fix once
* add safe-area-inset-bottom to spacer
* fix
* :v:
* :art:
* fix
* fix
* wip
* :v:
* clean up
* fix lint
* Update packages/client/src/components/global/sticky-container.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
* Update packages/client/src/components/ui/pagination.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
* Update packages/client/src/pages/messaging/messaging-room.form.vue
Co-authored-by: Johann150 <johann.galle@protonmail.com>
* clean up: single line comment
* https://github.com/misskey-dev/misskey/pull/8209#discussion_r867386077
* fix
* asobi → tolerance
* pick form
* pick message
* pick room
* fix lint
* fix scroll?
* fix scroll.ts
* fix directives/sticky-container
* update global/sticky-container.vue
* fix, :art:
* revert merge
* :v:
* fix lint errors
* :art:
* Update packages/client/src/types/date-separated-list.ts
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* https://github.com/misskey-dev/misskey/pull/8209#discussion_r917225080
* use '
* Update packages/client/src/scripts/scroll.ts
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* use Number.EPSILON
Co-authored-by: acid-chicken <root@acid-chicken.com>
* revert
* fix
* fix
* Use % instead of vh
* :art:
* :art:
* :art:
* wip
* wip
* css modules
Co-authored-by: Johann150 <johann.galle@protonmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkDateSeparatedList.vue | 188 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotes.vue | 11 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPagination.vue | 286 |
3 files changed, 306 insertions, 179 deletions
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 5d8e14c3c8..cb88444d34 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,13 +1,14 @@ <script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue'; import MkAd from '@/components/global/MkAd.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; +import { MisskeyEntity } from '@/types/date-separated-list'; export default defineComponent({ props: { items: { - type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + type: Array as PropType<MisskeyEntity[]>, required: true, }, direction: { @@ -33,6 +34,7 @@ export default defineComponent({ }, setup(props, { slots, expose }) { + const $style = useCssModule(); function getDateText(time: string) { const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; @@ -57,21 +59,25 @@ export default defineComponent({ new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() ) { const separator = h('div', { - class: 'separator', + class: $style['separator'], key: item.id + ':separator', }, h('p', { - class: 'date', + class: $style['date'], }, [ - h('span', [ + h('span', { + class: $style['date-1'], + }, [ h('i', { - class: 'ti ti-chevron-up icon', + class: `ti ti-chevron-up ${$style['date-1-icon']}`, }), getDateText(item.createdAt), ]), - h('span', [ + h('span', { + class: $style['date-2'], + }, [ getDateText(props.items[i + 1].createdAt), h('i', { - class: 'ti ti-chevron-down icon', + class: `ti ti-chevron-down ${$style['date-2-icon']}`, }), ]), ])); @@ -89,100 +95,138 @@ export default defineComponent({ } }); + function onBeforeLeave(el: HTMLElement) { + el.style.top = `${el.offsetTop}px`; + el.style.left = `${el.offsetLeft}px`; + } + function onLeaveCanceled(el: HTMLElement) { + el.style.top = ''; + el.style.left = ''; + } + return () => h( defaultStore.state.animation ? TransitionGroup : 'div', - defaultStore.state.animation ? { - class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), - name: 'list', - tag: 'div', - 'data-direction': props.direction, - 'data-reversed': props.reversed ? 'true' : 'false', - } : { - class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), + { + class: { + [$style['date-separated-list']]: true, + [$style['date-separated-list-nogap']]: props.noGap, + [$style['reversed']]: props.reversed, + [$style['direction-down']]: props.direction === 'down', + [$style['direction-up']]: props.direction === 'up', + }, + ...(defaultStore.state.animation ? { + name: 'list', + tag: 'div', + onBeforeLeave, + onLeaveCanceled, + } : {}), }, { default: renderChildren }); }, }); </script> -<style lang="scss"> -.sqadhkmv { +<style lang="scss" module> +.date-separated-list { container-type: inline-size; - > *:empty { - display: none; + &:global { + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); } - > *:not(:last-child) { - margin-bottom: var(--margin); + &.deny-move-transition > .list-move { + transition: none !important; } - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + > .list-leave-active, + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); } - > .list-enter-active { + > .list-leave-from, + > .list-leave-to, + > .list-leave-active { transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + position: absolute !important; } - &[data-direction="up"] { - > .list-enter-from { - opacity: 0; - transform: translateY(64px); - } + > *:empty { + display: none; } - &[data-direction="down"] { - > .list-enter-from { - opacity: 0; - transform: translateY(-64px); + > *:not(:last-child) { + margin-bottom: var(--margin); + } + } +} + +.date-separated-list-nogap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); } } +} - > .separator { - text-align: center; +.direction-up { + &:global { + > .list-enter-from, + > .list-leave-to { + opacity: 0; + transform: translateY(64px); + } + } +} +.direction-down { + &:global { + > .list-enter-from, + > .list-leave-to { + opacity: 0; + transform: translateY(-64px); + } + } +} - > .date { - display: inline-block; - position: relative; - margin: 0; - padding: 0 16px; - line-height: 32px; - text-align: center; - font-size: 12px; - color: var(--dateLabelFg); +.reversed { + display: flex; + flex-direction: column-reverse; +} - > span { - &:first-child { - margin-right: 8px; +.separator { + text-align: center; +} - > .icon { - margin-right: 8px; - } - } +.date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); +} - &:last-child { - margin-left: 8px; +.date-1 { + margin-right: 8px; +} - > .icon { - margin-left: 8px; - } - } - } - } - } +.date-1-icon { + margin-right: 8px; +} - &.noGap { - > * { - margin: 0 !important; - border: none; - border-radius: 0; - box-shadow: none; +.date-2 { + margin-left: 8px; +} - &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); - } - } - } +.date-2-icon { + margin-left: 8px; } </style> + diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 73659fd501..f9952e4245 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -9,7 +9,16 @@ <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap }]"> - <MkDateSeparatedList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" :class="$style.notes"> + <MkDateSeparatedList + ref="notes" + v-slot="{ item: note }" + :items="notes" + :direction="pagination.reversed ? 'up' : 'down'" + :reversed="pagination.reversed" + :no-gap="noGap" + :ad="true" + :class="$style.notes" + > <XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> </MkDateSeparatedList> </div> diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index b92e6d2360..a78e7e064a 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -15,14 +15,14 @@ <div v-else ref="rootEl"> <div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _margin"> - <MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> - <slot :items="items"></slot> + <slot :items="items" :fetching="fetching || moreFetching"></slot> <div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _margin"> - <MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> @@ -31,15 +31,18 @@ </Transition> </template> -<script lang="ts" setup> -import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, shallowRef, watch } from 'vue'; +<script lang="ts"> +import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'; import * as misskey from 'misskey-js'; import * as os from '@/os'; -import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll'; import MkButton from '@/components/MkButton.vue'; +import { defaultStore } from '@/store'; +import { MisskeyEntity } from '@/types/date-separated-list'; import { i18n } from '@/i18n'; const SECOND_FETCH_LIMIT = 30; +const TOLERANCE = 16; export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { endpoint: E; @@ -58,8 +61,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> reversed?: boolean; offsetMode?: boolean; -}; + pageEl?: HTMLElement; +}; +</script> +<script lang="ts" setup> const props = withDefaults(defineProps<{ pagination: Paging; disableAutoLoad?: boolean; @@ -72,21 +78,73 @@ const emit = defineEmits<{ (ev: 'queue', count: number): void; }>(); -type Item = { id: string; [another: string]: unknown; }; +let rootEl = $shallowRef<HTMLElement>(); + +// 遡り中かどうか +let backed = $ref(false); + +let scrollRemove = $ref<(() => void) | null>(null); -const rootEl = shallowRef<HTMLElement>(); -const items = ref<Item[]>([]); -const queue = ref<Item[]>([]); +const items = ref<MisskeyEntity[]>([]); +const queue = ref<MisskeyEntity[]>([]); const offset = ref(0); const fetching = ref(true); const moreFetching = ref(false); const more = ref(false); -const backed = ref(false); // 遡り中か否か const isBackTop = ref(false); const empty = computed(() => items.value.length === 0); const error = ref(false); +const { + enableInfiniteScroll +} = defaultStore.reactiveState; -const init = async (): Promise<void> => { +const contentEl = $computed(() => props.pagination.pageEl || rootEl); +const scrollableElement = $computed(() => getScrollContainer(contentEl)); + +// 先頭が表示されているかどうかを検出 +// https://qiita.com/mkataigi/items/0154aefd2223ce23398e +let scrollObserver = $ref<IntersectionObserver>(); + +watch([() => props.pagination.reversed, $$(scrollableElement)], () => { + if (scrollObserver) scrollObserver.disconnect(); + + scrollObserver = new IntersectionObserver(entries => { + backed = entries[0].isIntersecting; + }, { + root: scrollableElement, + rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', + threshold: 0.01, + }); +}, { immediate: true }); + +watch($$(rootEl), () => { + scrollObserver.disconnect(); + nextTick(() => { + if (rootEl) scrollObserver.observe(rootEl); + }); +}); + +watch([$$(backed), $$(contentEl)], () => { + if (!backed) { + if (!contentEl) return; + + scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE); + } else { + if (scrollRemove) scrollRemove(); + scrollRemove = null; + } +}); + +if (props.pagination.params && isRef(props.pagination.params)) { + watch(props.pagination.params, init, { deep: true }); +} + +watch(queue, (a, b) => { + if (a.length === 0 && b.length === 0) return; + emit('queue', queue.value.length); +}, { deep: true }); + +async function init(): Promise<void> { queue.value = []; fetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; @@ -96,18 +154,15 @@ const init = async (): Promise<void> => { }).then(res => { for (let i = 0; i < res.length; i++) { const item = res[i]; - if (props.pagination.reversed) { - if (i === res.length - 2) item._shouldInsertAd_ = true; - } else { - if (i === 3) item._shouldInsertAd_ = true; - } + if (i === 3) item._shouldInsertAd_ = true; } if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { res.pop(); - items.value = props.pagination.reversed ? [...res].reverse() : res; + if (props.pagination.reversed) moreFetching.value = true; + items.value = res; more.value = true; } else { - items.value = props.pagination.reversed ? [...res].reverse() : res; + items.value = res; more.value = false; } offset.value = res.length; @@ -117,17 +172,16 @@ const init = async (): Promise<void> => { error.value = true; fetching.value = false; }); -}; +} -const reload = (): void => { +const reload = (): Promise<void> => { items.value = []; - init(); + return init(); }; const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; moreFetching.value = true; - backed.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { ...params, @@ -142,22 +196,52 @@ const fetchMore = async (): Promise<void> => { }).then(res => { for (let i = 0; i < res.length; i++) { const item = res[i]; - if (props.pagination.reversed) { - if (i === res.length - 9) item._shouldInsertAd_ = true; - } else { - if (i === 10) item._shouldInsertAd_ = true; - } + if (i === 10) item._shouldInsertAd_ = true; } + + const reverseConcat = _res => { + const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); + const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; + + items.value = items.value.concat(_res); + + return nextTick(() => { + if (scrollableElement) { + scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' }); + } else { + window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); + } + + return nextTick(); + }); + }; + if (res.length > SECOND_FETCH_LIMIT) { res.pop(); - items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); - more.value = true; + + if (props.pagination.reversed) { + reverseConcat(res).then(() => { + more.value = true; + moreFetching.value = false; + }); + } else { + items.value = items.value.concat(res); + more.value = true; + moreFetching.value = false; + } } else { - items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); - more.value = false; + if (props.pagination.reversed) { + reverseConcat(res).then(() => { + more.value = false; + moreFetching.value = false; + }); + } else { + items.value = items.value.concat(res); + more.value = false; + moreFetching.value = false; + } } offset.value += res.length; - moreFetching.value = false; }, err => { moreFetching.value = false; }); @@ -180,10 +264,10 @@ const fetchMoreAhead = async (): Promise<void> => { }).then(res => { if (res.length > SECOND_FETCH_LIMIT) { res.pop(); - items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + items.value = items.value.concat(res); more.value = true; } else { - items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + items.value = items.value.concat(res); more.value = false; } offset.value += res.length; @@ -193,106 +277,96 @@ const fetchMoreAhead = async (): Promise<void> => { }); }; -const prepend = (item: Item): void => { - if (props.pagination.reversed) { - if (rootEl.value) { - const container = getScrollContainer(rootEl.value); - if (container == null) { - // TODO? - } else { - const pos = getScrollPosition(rootEl.value); - const viewHeight = container.clientHeight; - const height = container.scrollHeight; - const isBottom = (pos + viewHeight > height - 32); - if (isBottom) { - // オーバーフローしたら古いアイテムは捨てる - if (items.value.length >= props.displayLimit) { - // このやり方だとVue 3.2以降アニメーションが動かなくなる - //items.value = items.value.slice(-props.displayLimit); - while (items.value.length >= props.displayLimit) { - items.value.shift(); - } - more.value = true; - } - } - } - } - items.value.push(item); - // TODO - } else { - // 初回表示時はunshiftだけでOK - if (!rootEl.value) { - items.value.unshift(item); - return; - } +const prepend = (item: MisskeyEntity): void => { + // 初回表示時はunshiftだけでOK + if (!rootEl) { + items.value.unshift(item); + return; + } - const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); + const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); - if (isTop) { - // Prepend the item - items.value.unshift(item); + if (isTop) unshiftItems([item]); + else prependQueue(item); +}; - // オーバーフローしたら古いアイテムは捨てる - if (items.value.length >= props.displayLimit) { - // このやり方だとVue 3.2以降アニメーションが動かなくなる - //this.items = items.value.slice(0, props.displayLimit); - while (items.value.length >= props.displayLimit) { - items.value.pop(); - } - more.value = true; - } - } else { - queue.value.push(item); - onScrollTop(rootEl.value, () => { - for (const item of queue.value) { - prepend(item); - } - queue.value = []; - }); - } +function unshiftItems(newItems: MisskeyEntity[]) { + const length = newItems.length + items.value.length; + items.value = [ ...newItems, ...items.value ].slice(0, props.displayLimit); + + if (length >= props.displayLimit) more.value = true; +} + +function executeQueue() { + if (queue.value.length === 0) return; + unshiftItems(queue.value); + queue.value = []; +} + +function prependQueue(newItem: MisskeyEntity) { + queue.value.unshift(newItem); + if (queue.value.length >= props.displayLimit) { + queue.value.pop(); } -}; +} -const append = (item: Item): void => { +const appendItem = (item: MisskeyEntity): void => { items.value.push(item); }; -const removeItem = (finder: (item: Item) => boolean) => { +const removeItem = (finder: (item: MisskeyEntity) => boolean) => { const i = items.value.findIndex(finder); items.value.splice(i, 1); }; -const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => { +const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { const i = items.value.findIndex(item => item.id === id); items.value[i] = replacer(items.value[i]); }; -if (props.pagination.params && isRef(props.pagination.params)) { - watch(props.pagination.params, init, { deep: true }); -} - -watch(queue, (a, b) => { - if (a.length === 0 && b.length === 0) return; - emit('queue', queue.value.length); -}, { deep: true }); - -init(); +const inited = init(); onActivated(() => { isBackTop.value = false; }); onDeactivated(() => { - isBackTop.value = window.scrollY === 0; + isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl?.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; +}); + +function toBottom() { + scrollToBottom(contentEl); +} + +onMounted(() => { + inited.then(() => { + if (props.pagination.reversed) { + nextTick(() => { + setTimeout(toBottom, 800); + + // scrollToBottomでmoreFetchingボタンが画面外まで出るまで + // more = trueを遅らせる + setTimeout(() => { + moreFetching.value = false; + }, 2000); + }); + } + }); +}); + +onBeforeUnmount(() => { + scrollObserver.disconnect(); }); defineExpose({ items, queue, backed, + more, + inited, reload, prepend, - append, + append: appendItem, removeItem, updateItem, }); |