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:
Kissa Ruokanen 2026-06-03 09:07:17 +09:00 committed by GitHub
commit 23bb992121
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 45 additions and 19 deletions

View file

@ -7,6 +7,8 @@
- Feat: ノート検索で投稿日時の期間を条件に加えられるように(#16035)
### Client
- Enhance: ユーザーページのファイルタブでスクロール位置が保持されるように
- Enhance: ドライブページでスクロール位置が保持されるように
- Fix: URLプレビューのプレイヤーをウィンドウで開いたとき、プレイヤーが読み込まれるまでの間 `Invalid URL` と表示される問題を修正
- Fix: 一部の実績が正しく表示されない問題を修正
- Fix: アクセストークン発行時のダイアログのタイトルが「確認コード」となっているのを修正

View file

@ -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)"

View file

@ -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"

View file

@ -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);
});

View file

@ -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);