forked from mirrors/misskey
enhance(frontend): scroll position restoration in the drive page and "Files" tab on the user page (#17497)
* fix: ユーザーページのFilesタブでスクロール位置が正しく復元されない問題を修正 * fix: ドライブページでスクロール位置が保持されない問題を修正 * fix: 変更履歴の文言を調整 * fix: 同じファイルを複数ノートに添付した場合にスクロール位置が先頭要素へ戻る問題を修正 * fix: viewPosition の計算誤りと KeepAlive によるスクロール位置消失を修正 * fix: スクロール位置復元が下端要素の見切れを引き起こす問題を修正 * fix: スクロール位置復元がコンテナ高さ変化時にオーバーシュートする問題を修正 * Update CHANGELOG.md Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * fix: 最下部スクロール時にスクロール位置のキャプチャが失敗する問題を修正 min-height による空白領域にビューポート中央が入るケースで anchorId が更新 されず、復元が古い位置に飛んでしまう問題を修正した。 アンカー選択条件を「中央を跨ぐ要素のみ」から「上端が中央以下の最も下の要素」 に変更することで、空白領域スクロール時も最後のアイテムを正しくキャプチャできる。 * Update CHANGELOG.md * fix: MkNoteMediaGrid のスクロールアンカーをノートIDとファイルIDの複合キーに変更 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: anchorIndex による重複アンカー対応を削除 * refactor: unused になった onUnmounted の import を削除 * refactor: querySelectorAll/querySelector にジェネリクスを渡し型キャストを削除 --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eed6c3654f
commit
23bb992121
5 changed files with 45 additions and 19 deletions
|
|
@ -7,6 +7,8 @@
|
|||
- Feat: ノート検索で投稿日時の期間を条件に加えられるように(#16035)
|
||||
|
||||
### Client
|
||||
- Enhance: ユーザーページのファイルタブでスクロール位置が保持されるように
|
||||
- Enhance: ドライブページでスクロール位置が保持されるように
|
||||
- Fix: URLプレビューのプレイヤーをウィンドウで開いたとき、プレイヤーが読み込まれるまでの間 `Invalid URL` と表示される問題を修正
|
||||
- Fix: 一部の実績が正しく表示されない問題を修正
|
||||
- Fix: アクセストークン発行時のダイアログのタイトルが「確認コード」となっているのを修正
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-for="(f, i) in foldersPaginator.items.value"
|
||||
:key="f.id"
|
||||
v-anim="i"
|
||||
:data-scroll-anchor="f.id"
|
||||
:folder="f"
|
||||
:selectMode="select === 'folder'"
|
||||
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
||||
|
|
@ -101,6 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<XFile
|
||||
v-for="file in item.items" :key="file.id"
|
||||
:data-scroll-anchor="file.id"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
|
|
@ -123,6 +125,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<XFile
|
||||
v-for="file in filesPaginator.items.value" :key="file.id"
|
||||
:data-scroll-anchor="file.id"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div
|
||||
v-if="isHiding(file)"
|
||||
:class="[$style.filePreview, { [$style.square]: square }]"
|
||||
:data-scroll-anchor="`${note.id}:${file.id}`"
|
||||
@click="reveal(file)"
|
||||
>
|
||||
<MkDriveFileThumbnail
|
||||
|
|
@ -26,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)">
|
||||
<MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :data-scroll-anchor="`${note.id}:${file.id}`" :to="notePage(note)">
|
||||
<MkDriveFileThumbnail
|
||||
:file="file"
|
||||
fit="cover"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue';
|
||||
import { nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする
|
||||
|
|
@ -13,12 +13,15 @@ import type { Ref } from 'vue';
|
|||
|
||||
export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void {
|
||||
let anchorId: string | null = null;
|
||||
// キャプチャ時のアンカー要素上端のコンテナ上端からの距離
|
||||
let anchorContainerLocalY = 0;
|
||||
let savedScrollTop = 0;
|
||||
let ready = true;
|
||||
|
||||
watch(scrollContainerRef, (el) => {
|
||||
if (!el) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const captureAnchor = () => {
|
||||
if (!el) return;
|
||||
if (!ready) return;
|
||||
|
||||
|
|
@ -29,16 +32,17 @@ export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | nu
|
|||
}
|
||||
|
||||
const scrollContainerRect = el.getBoundingClientRect();
|
||||
const viewPosition = scrollContainerRect.height / 2;
|
||||
const viewPosition = scrollContainerRect.top + scrollContainerRect.height / 2;
|
||||
|
||||
const anchorEls = el.querySelectorAll('[data-scroll-anchor]');
|
||||
const anchorEls = el.querySelectorAll<HTMLElement>('[data-scroll-anchor]');
|
||||
for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い
|
||||
const anchorEl = anchorEls[i] as HTMLElement;
|
||||
const anchorRect = anchorEl.getBoundingClientRect();
|
||||
const anchorTop = anchorRect.top;
|
||||
const anchorBottom = anchorRect.bottom;
|
||||
if (anchorTop <= viewPosition && anchorBottom >= viewPosition) {
|
||||
const anchorEl = anchorEls[i];
|
||||
const anchorTop = anchorEl.getBoundingClientRect().top;
|
||||
// 上端が viewPosition 以下の最初の要素(=中央を跨ぐか、中央より上にある中で最も近いもの)を選択する
|
||||
// 最下部スクロール時に min-height による空白に viewPosition が入った場合も最後のアイテムをキャプチャできる
|
||||
if (anchorTop <= viewPosition) {
|
||||
anchorId = anchorEl.getAttribute('data-scroll-anchor');
|
||||
anchorContainerLocalY = anchorTop - scrollContainerRect.top;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +51,10 @@ export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | nu
|
|||
// ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい
|
||||
// https://github.com/vuejs/vue/issues/9454
|
||||
// https://github.com/vuejs/rfcs/pull/284
|
||||
el.addEventListener('scroll', throttle(1000, onScroll), { passive: true });
|
||||
el.addEventListener('scroll', throttle(1000, captureAnchor), { passive: true });
|
||||
// スクロール後すぐにクリックするとthrottleによりanchorIdが古いまま残るため、
|
||||
// pointerdownで遷移直前のアンカーを同期的に取得する
|
||||
el.addEventListener('pointerdown', captureAnchor, { passive: true });
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
|
@ -56,16 +63,18 @@ export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | nu
|
|||
if (!anchorId) return;
|
||||
const scrollContainer = scrollContainerRef.value;
|
||||
if (!scrollContainer) return;
|
||||
const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`);
|
||||
const scrollAnchorEl = scrollContainer.querySelector<HTMLElement>(`[data-scroll-anchor="${CSS.escape(anchorId)}"]`);
|
||||
if (!scrollAnchorEl) return;
|
||||
scrollAnchorEl.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
const anchorRect = scrollAnchorEl.getBoundingClientRect();
|
||||
// anchorContentY: コンテンツ先頭からのアンカー要素上端の距離(scrollTopに依存しない)
|
||||
const anchorContentY = scrollContainer.scrollTop + anchorRect.top - scrollContainer.getBoundingClientRect().top;
|
||||
// キャプチャ時と同じ scrollTop になるよう直接セット(コンテナ高さ変化に依存しない)
|
||||
scrollContainer.scrollTop = anchorContentY - anchorContainerLocalY;
|
||||
};
|
||||
|
||||
onDeactivated(() => {
|
||||
const el = scrollContainerRef.value;
|
||||
if (el) savedScrollTop = el.scrollTop;
|
||||
ready = false;
|
||||
});
|
||||
|
||||
|
|
@ -76,6 +85,13 @@ export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | nu
|
|||
window.setTimeout(() => {
|
||||
restore();
|
||||
|
||||
// anchor方式が失敗した場合(anchorIdがnullまたは要素が見つからない場合)の
|
||||
// フォールバック
|
||||
const el = scrollContainerRef.value;
|
||||
if (el && el.scrollTop === 0 && savedScrollTop > 0) {
|
||||
el.scrollTop = savedScrollTop;
|
||||
}
|
||||
|
||||
ready = true;
|
||||
}, 100);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div ref="scrollContainer" class="_pageScrollable">
|
||||
<MkDrive @cd="x => folder = x"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { useScrollPositionKeeper } from '@/composables/use-scroll-position-keeper.js';
|
||||
|
||||
const scrollContainer = useTemplateRef('scrollContainer');
|
||||
useScrollPositionKeeper(scrollContainer);
|
||||
|
||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue