From abddd40c09c7c48d9c741db9cc322517085d8f67 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 20 Mar 2025 18:55:32 +0900
Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E9=80=9A=E5=B8=B8?=
 =?UTF-8?q?=E3=81=AERouterView=E3=81=ABTransition=E3=82=92=E8=BF=BD?=
 =?UTF-8?q?=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/components/global/MkPageHeader.vue    |  3 +
 .../src/components/global/RouterView.vue      | 70 +++++++++++++++++--
 packages/frontend/src/di.ts                   |  1 +
 packages/frontend/src/utility/random-id.ts    | 15 ++++
 4 files changed, 84 insertions(+), 5 deletions(-)
 create mode 100644 packages/frontend/src/utility/random-id.ts

diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 4321d69253..59bf80cfca 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -69,6 +69,8 @@ const emit = defineEmits<{
 	(ev: 'update:tab', key: string);
 }>();
 
+const viewId = inject(DI.viewId);
+const viewTransitionName = computed(() => `${viewId}---pageHeader`);
 const injectedPageMetadata = inject(DI.pageMetadata);
 const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
 
@@ -140,6 +142,7 @@ onUnmounted(() => {
 	backdrop-filter: var(--MI-blur, blur(15px));
 	border-bottom: solid 0.5px var(--MI_THEME-divider);
 	width: 100%;
+	view-transition-name: v-bind(viewTransitionName);
 }
 
 .upper,
diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue
index fbdb7d261e..45cb1e3bd5 100644
--- a/packages/frontend/src/components/global/RouterView.vue
+++ b/packages/frontend/src/components/global/RouterView.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="_pageContainer" style="height: 100%;">
+<div ref="rootEl" class="_pageContainer" :class="$style.root">
 	<KeepAlive :max="prefer.s.numberOfPageCache">
 		<Suspense :timeout="0">
 			<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
@@ -18,11 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { inject, provide, ref, shallowRef } from 'vue';
+import { inject, nextTick, onMounted, provide, ref, shallowRef, useTemplateRef } from 'vue';
 import type { Router } from '@/router.js';
 import { prefer } from '@/preferences.js';
 import MkLoadingPage from '@/pages/_loading_.vue';
 import { DI } from '@/di.js';
+import { randomId } from '@/utility/random-id.js';
+import { deepEqual } from '@/utility/deep-equal.js';
 
 const props = defineProps<{
 	router?: Router;
@@ -34,18 +36,76 @@ if (router == null) {
 	throw new Error('no router provided');
 }
 
+const viewId = randomId();
+provide(DI.viewId, viewId);
+
 const currentDepth = inject(DI.routerCurrentDepth, 0);
 provide(DI.routerCurrentDepth, currentDepth + 1);
 
+const rootEl = useTemplateRef('rootEl');
+onMounted(() => {
+	rootEl.value.style.viewTransitionName = viewId; // view-transition-nameにcss varが使えないっぽいため直接代入
+});
+
+// view-transition-newなどの<pt-name-selector>にはcss varが使えず、v-bindできないため直接スタイルを生成
+const viewTransitionStylesTag = document.createElement('style');
+viewTransitionStylesTag.textContent = `
+@keyframes ${viewId}-old {
+	to { transform: scale(0.95); opacity: 0; }
+}
+
+@keyframes ${viewId}-new {
+	from { transform: scale(0.95); opacity: 0; }
+}
+
+::view-transition-old(${viewId}) {
+	animation-duration: 0.2s;
+  animation-name: ${viewId}-old;
+}
+
+::view-transition-new(${viewId}) {
+	animation-duration: 0.2s;
+  animation-name: ${viewId}-new;
+}
+`;
+
+window.document.head.appendChild(viewTransitionStylesTag);
+
 const current = router.current!;
 const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage);
 const currentPageProps = ref(current.props);
+let currentRoutePath = current.route.path;
 const key = ref(router.getCurrentFullPath());
 
 router.useListener('change', ({ resolved }) => {
 	if (resolved == null || 'redirect' in resolved.route) return;
-	currentPageComponent.value = resolved.route.component;
-	currentPageProps.value = resolved.props;
-	key.value = router.getCurrentFullPath();
+	if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return;
+
+	function _() {
+		currentPageComponent.value = resolved.route.component;
+		currentPageProps.value = resolved.props;
+		key.value = router.getCurrentFullPath();
+		currentRoutePath = resolved.route.path;
+	}
+
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	if (prefer.s.animation && document.startViewTransition) {
+		document.startViewTransition(() => new Promise((res) => {
+			_();
+			nextTick(() => {
+				res();
+				//setTimeout(res, 100);
+			});
+		}));
+	} else {
+		_();
+	}
 });
 </script>
+
+<style lang="scss" module>
+.root {
+	height: 100%;
+	background-color: var(--MI_THEME-bg);
+}
+</style>
diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts
index e9b2c2b650..4977cdbd62 100644
--- a/packages/frontend/src/di.ts
+++ b/packages/frontend/src/di.ts
@@ -11,4 +11,5 @@ export const DI = {
 	router: Symbol() as InjectionKey<Router>,
 	mock: Symbol() as InjectionKey<boolean>,
 	pageMetadata: Symbol() as InjectionKey<Ref<Record<string, any>>>,
+	viewId: Symbol() as InjectionKey<string>,
 };
diff --git a/packages/frontend/src/utility/random-id.ts b/packages/frontend/src/utility/random-id.ts
new file mode 100644
index 0000000000..4e5943a97f
--- /dev/null
+++ b/packages/frontend/src/utility/random-id.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const CHARS = 'abcdefghijklmnopqrstuvwxyz'; // CSSの<custom-ident>などで使われることもあるのでa-z以外使うな
+
+export function randomId(length = 32, characters = CHARS) {
+	let result = '';
+	const charactersLength = characters.length;
+	for ( let i = 0; i < length; i++ ) {
+		result += characters.charAt(Math.floor(Math.random() * charactersLength));
+	}
+	return result;
+}