diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-05-07 02:46:42 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-07 02:46:42 +0000 |
| commit | 9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b (patch) | |
| tree | c41c3ee20b995c3a74a75d4005ab980d217a3727 /packages/frontend/src/components | |
| parent | Merge pull request #15842 from misskey-dev/develop (diff) | |
| parent | Release: 2025.5.0 (diff) | |
| download | misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.gz misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.bz2 misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.zip | |
Merge pull request #15933 from misskey-dev/develop
Release: 2025.5.0
Diffstat (limited to 'packages/frontend/src/components')
22 files changed, 559 insertions, 256 deletions
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 6e5b29654b..81c92bfb5c 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick"> +<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick"> <div ref="rootEl" :class="$style.root"> <div :class="$style.header"> <span :class="$style.icon"> @@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.title">{{ announcement.title }}</span> </div> <div :class="$style.text"><Mfm :text="announcement.text"/></div> - <MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton> + <div ref="bottomEl"></div> + <div :class="$style.footer"> + <MkButton + primary + full + :disabled="!hasReachedBottom" + @click="ok" + >{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton> + </div> </div> </MkModal> </template> <script lang="ts" setup> -import { onMounted, useTemplateRef } from 'vue'; +import { onMounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -32,12 +40,12 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; import { updateCurrentAccountPartial } from '@/accounts.js'; -const props = withDefaults(defineProps<{ +const props = defineProps<{ announcement: Misskey.entities.Announcement; -}>(), { -}); +}>(); const rootEl = useTemplateRef('rootEl'); +const bottomEl = useTemplateRef('bottomEl'); const modal = useTemplateRef('modal'); async function ok() { @@ -72,7 +80,34 @@ function onBgClick() { }); } +const hasReachedBottom = ref(false); + onMounted(() => { + if (bottomEl.value && rootEl.value) { + const bottomElRect = bottomEl.value.getBoundingClientRect(); + const rootElRect = rootEl.value.getBoundingClientRect(); + if ( + bottomElRect.top >= rootElRect.top && + bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分) + ) { + hasReachedBottom.value = true; + return; + } + + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + hasReachedBottom.value = true; + observer.disconnect(); + } + } + }, { + root: rootEl.value, + rootMargin: '0px 0px -75px 0px', + }); + + observer.observe(bottomEl.value); + } }); </script> @@ -80,9 +115,12 @@ onMounted(() => { .root { margin: auto; position: relative; - padding: 32px; + padding: 32px 32px 0; min-width: 320px; max-width: 480px; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; box-sizing: border-box; background: var(--MI_THEME-panel); border-radius: var(--MI-radius); @@ -103,4 +141,14 @@ onMounted(() => { .text { margin: 1em 0; } + +.footer { + position: sticky; + bottom: 0; + left: -32px; + backdrop-filter: var(--MI-blur, blur(15px)); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); + margin: 0 -32px; + padding: 24px 32px; +} </style> diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index fdb7d2a1c4..d0b50f04f2 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.notFound }}</div> - </div> - </template> + <template #empty><MkResult type="empty"/></template> <template #default="{ items }"> <MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/> @@ -23,7 +18,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue index c508ea8451..b33ed428c7 100644 --- a/packages/frontend/src/components/MkChatHistories.vue +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkA> </div> -<div v-if="!initializing && history.length == 0" class="_fullinfo"> - <div>{{ i18n.ts._chat.noHistory }}</div> -</div> +<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/> <MkLoading v-if="initializing"/> </template> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 81d508c161..3f7519a43f 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="!input && !select" - :class="[$style.icon, { - [$style.type_success]: type === 'success', - [$style.type_error]: type === 'error', - [$style.type_warning]: type === 'warning', - [$style.type_info]: type === 'info', - }]" + :class="[$style.icon]" > - <i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i> - <i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i> - <i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i> - <i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i> - <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> + <MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/> + <MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/> + <MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/> + <MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/> + <MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> @@ -202,22 +197,6 @@ function onInputKeydown(evt: KeyboardEvent) { margin: 0 auto; } -.type_info { - color: #55c4dd; -} - -.type_success { - color: var(--MI_THEME-success); -} - -.type_error { - color: var(--MI_THEME-error); -} - -.type_warning { - color: var(--MI_THEME-warn); -} - .title { margin: 0 0 8px 0; font-weight: bold; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 1236b843f2..e86861c874 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -31,6 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" + @enter="enter" + @afterEnter="afterEnter" + @leave="leave" + @afterLeave="afterLeave" > <KeepAlive> <div v-show="opened"> @@ -86,6 +90,42 @@ const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); +//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す) +function enter(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + const elementHeight = el.getBoundingClientRect().height; + el.style.height = '0'; + el.offsetHeight; // reflow + el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`; +} + +function afterEnter(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + el.style.height = ''; +} + +function leave(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + const elementHeight = el.getBoundingClientRect().height; + el.style.height = `${elementHeight}px`; + el.offsetHeight; // reflow + el.style.height = '0'; +} + +function afterLeave(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + el.style.height = ''; +} +//#endregion + function toggle() { if (!opened.value) { openedAtLeastOnce.value = true; @@ -108,17 +148,27 @@ onMounted(() => { .transition_toggle_enterActive, .transition_toggle_leaveActive { overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない - transition: opacity 0.3s, height 0.3s !important; + transition: opacity 0.3s, height 0.3s; } + +@supports (interpolate-size: allow-keywords) { + .transition_toggle_enterFrom, + .transition_toggle_leaveTo { + height: 0; + } + + .root { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + } +} + .transition_toggle_enterFrom, .transition_toggle_leaveTo { opacity: 0; - height: 0; } .root { display: block; - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 } .header { diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 0884cdc016..6ac4441cac 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> </template> </div> - <div v-else class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else type="empty"/> </div> </MkModalWindow> </template> @@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue'; import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ title: string; diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index eba8a73aec..380fb7b2d8 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import type { CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; +import type { CSSProperties } from 'vue'; import { instance as localInstance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; @@ -61,19 +61,9 @@ $height: 2ex; border-radius: 4px 0 0 4px; overflow: clip; color: #fff; - text-shadow: /* .866 ≈ sin(60deg) */ - 1px 0 1px #000, - .866px .5px 1px #000, - .5px .866px 1px #000, - 0 1px 1px #000, - -.5px .866px 1px #000, - -.866px .5px 1px #000, - -1px 0 1px #000, - -.866px -.5px 1px #000, - -.5px -.866px 1px #000, - 0 -1px 1px #000, - .5px -.866px 1px #000, - .866px -.5px 1px #000; + + // text-shadowは重いから使うな + mask-image: linear-gradient(90deg, rgb(0,0,0), rgb(0,0,0) calc(100% - 16px), diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 9d862a4eac..509099e0b9 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> @@ -34,7 +29,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 9672efca0a..21104b41df 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> - <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> <MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/> <img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> <div @@ -176,7 +175,6 @@ import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { ensureSignin } from '@/i.js'; -import { infoImageUrl } from '@/instance.js'; const $i = ensureSignin(); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index b8fada1020..3c88b8af0d 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh :refresher="() => reload()"> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> <MkPagination ref="pagingComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotifications }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template> <template #default="{ items: notifications }"> <component @@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> @@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue'; import MkNote from '@/components/MkNote.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { prefer } from '@/preferences.js'; @@ -103,18 +97,38 @@ defineExpose({ </script> <style lang="scss" module> -.transition_x_move, -.transition_x_enterActive, +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.item, + .item { + /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + .transition_x_leaveActive { - transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); } -.transition_x_enterFrom, -.transition_x_leaveTo { + +.transition_x_enterFrom { opacity: 0; - transform: translateY(-50%); + transform: translateY(max(-64px, -100%)); } -.transition_x_leaveActive { - position: absolute; + +@supports (interpolate-size: allow-keywords) { + .transition_x_enterFrom { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; } .notifications { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 9adc3d98da..54da5a889d 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -16,12 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkError v-else-if="error" @retry="init()"/> <div v-else-if="empty" key="_empty_"> - <slot name="empty"> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - </slot> + <slot name="empty"><MkResult type="empty"/></slot> </div> <div v-else ref="rootEl" class="_gaps"> @@ -88,7 +83,6 @@ function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): M </script> <script lang="ts" setup> -import { infoImageUrl } from '@/instance.js'; import MkButton from '@/components/MkButton.vue'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 22ae563d13..b0638db785 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="rootEl"> - <div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> +<div ref="rootEl" :class="isPulling ? $style.isPulling : null"> + <!-- 小数が含まれるとレンダリングが高頻度になりすぎパフォーマンスが悪化するためround --> + <div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${Math.round(pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR)))}px;`"> <div :class="$style.frameContent"> <MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/> - <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i> + <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i> <div :class="$style.text"> - <template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template> + <template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template> <template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template> <template v-else>{{ i18n.ts.pullDownToRefresh }}</template> </div> @@ -29,24 +30,21 @@ import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; -const FIRE_THRESHOLD = 230; +const FIRE_THRESHOLD = 200; const RELEASE_TRANSITION_DURATION = 200; const PULL_BRAKE_BASE = 1.5; const PULL_BRAKE_FACTOR = 170; -const isPullStart = ref(false); -const isPullEnd = ref(false); +const isPulling = ref(false); +const isPulledEnough = ref(false); const isRefreshing = ref(false); const pullDistance = ref(0); -let supportPointerDesktop = false; let startScreenY: number | null = null; const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; -let disabled = false; - const props = withDefaults(defineProps<{ refresher: () => Promise<void>; }>(), { @@ -57,19 +55,72 @@ const emit = defineEmits<{ (ev: 'refresh'): void; }>(); -function getScreenY(event) { - if (supportPointerDesktop) { +function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number { + if (event.touches && event.touches[0] && event.touches[0].screenY != null) { + return event.touches[0].screenY; + } else { return event.screenY; } - return event.touches[0].screenY; } -function moveStart(event) { - if (!isPullStart.value && !isRefreshing.value && !disabled) { - isPullStart.value = true; - startScreenY = getScreenY(event); - pullDistance.value = 0; +// When at the top of the page, disable vertical overscroll so passive touch listeners can take over. +function lockDownScroll() { + if (scrollEl == null) return; + scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom'; + scrollEl.style.overscrollBehavior = 'none'; +} + +function unlockDownScroll() { + if (scrollEl == null) return; + scrollEl.style.touchAction = 'auto'; + scrollEl.style.overscrollBehavior = 'contain'; +} + +function moveStartByMouse(event: MouseEvent) { + if (event.button !== 1) return; + if (isRefreshing.value) return; + + const scrollPos = scrollEl!.scrollTop; + if (scrollPos !== 0) { + unlockDownScroll(); + return; + } + + lockDownScroll(); + + event.preventDefault(); // 中クリックによるスクロール、テキスト選択などを防ぐ + + isPulling.value = true; + startScreenY = getScreenY(event); + pullDistance.value = 0; + + window.addEventListener('mousemove', moving, { passive: true }); + window.addEventListener('mouseup', () => { + window.removeEventListener('mousemove', moving); + onPullRelease(); + }, { passive: true, once: true }); +} + +function moveStartByTouch(event: TouchEvent) { + if (isRefreshing.value) return; + + const scrollPos = scrollEl!.scrollTop; + if (scrollPos !== 0) { + unlockDownScroll(); + return; } + + lockDownScroll(); + + isPulling.value = true; + startScreenY = getScreenY(event); + pullDistance.value = 0; + + window.addEventListener('touchmove', moving, { passive: true }); + window.addEventListener('touchend', () => { + window.removeEventListener('touchmove', moving); + onPullRelease(); + }, { passive: true, once: true }); } function moveBySystem(to: number): Promise<void> { @@ -108,31 +159,36 @@ async function closeContent() { } } -function moveEnd() { - if (isPullStart.value && !isRefreshing.value) { - startScreenY = null; - if (isPullEnd.value) { - isPullEnd.value = false; - isRefreshing.value = true; - fixOverContent().then(() => { - emit('refresh'); - props.refresher().then(() => { - refreshFinished(); - }); +function onPullRelease() { + startScreenY = null; + if (isPulledEnough.value) { + isPulledEnough.value = false; + isRefreshing.value = true; + fixOverContent().then(() => { + emit('refresh'); + props.refresher().then(() => { + refreshFinished(); }); - } else { - closeContent().then(() => isPullStart.value = false); - } + }); + } else { + closeContent().then(() => isPulling.value = false); } } -function moving(event: TouchEvent | PointerEvent) { - if (!isPullStart.value || isRefreshing.value || disabled) return; +function toggleScrollLockOnTouchEnd() { + const scrollPos = scrollEl!.scrollTop; + if (scrollPos === 0) { + lockDownScroll(); + } else { + unlockDownScroll(); + } +} - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) { +function moving(event: MouseEvent | TouchEvent) { + if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; - isPullEnd.value = false; - moveEnd(); + isPulledEnough.value = false; + onPullRelease(); return; } @@ -144,15 +200,7 @@ function moving(event: TouchEvent | PointerEvent) { const moveHeight = moveScreenY - startScreenY!; pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); - if (pullDistance.value > 0) { - if (event.cancelable) event.preventDefault(); - } - - if (pullDistance.value > SCROLL_STOP) { - event.stopPropagation(); - } - - isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; + isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; } /** @@ -162,65 +210,33 @@ function moving(event: TouchEvent | PointerEvent) { */ function refreshFinished() { closeContent().then(() => { - isPullStart.value = false; + isPulling.value = false; isRefreshing.value = false; }); } -function setDisabled(value) { - disabled = value; -} - -function onScrollContainerScroll() { - const scrollPos = scrollEl!.scrollTop; - - // When at the top of the page, disable vertical overscroll so passive touch listeners can take over. - if (scrollPos === 0) { - scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom'; - registerEventListenersForReadyToPull(); - } else { - scrollEl!.style.touchAction = 'auto'; - unregisterEventListenersForReadyToPull(); - } -} - -function registerEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.addEventListener('touchstart', moveStart, { passive: true }); - rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない -} - -function unregisterEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.removeEventListener('touchstart', moveStart); - rootEl.value.removeEventListener('touchmove', moving); -} - onMounted(() => { if (rootEl.value == null) return; - scrollEl = getScrollContainer(rootEl.value); - if (scrollEl == null) return; - - scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true }); - - rootEl.value.addEventListener('touchend', moveEnd, { passive: true }); - - registerEventListenersForReadyToPull(); + lockDownScroll(); + rootEl.value.addEventListener('mousedown', moveStartByMouse, { passive: false }); // preventDefaultするため + rootEl.value.addEventListener('touchstart', moveStartByTouch, { passive: true }); + rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true }); }); onUnmounted(() => { - if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll); - - unregisterEventListenersForReadyToPull(); -}); - -defineExpose({ - setDisabled, + unlockDownScroll(); + if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse); + if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch); + if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd); }); </script> <style lang="scss" module> +.isPulling { + will-change: contents; +} + .frame { position: relative; overflow: clip; @@ -242,7 +258,6 @@ defineExpose({ display: flex; flex-direction: column; align-items: center; - font-size: 14px; > .icon, > .loader { margin: 6px 0; @@ -258,6 +273,7 @@ defineExpose({ > .text { margin: 5px 0; + font-size: 90%; } } </style> diff --git a/packages/frontend/src/components/MkSwiper.vue b/packages/frontend/src/components/MkSwiper.vue index 1d0ffaea11..b66bfb0e9d 100644 --- a/packages/frontend/src/components/MkSwiper.vue +++ b/packages/frontend/src/components/MkSwiper.vue @@ -53,12 +53,12 @@ const MIN_SWIPE_DISTANCE = 20; // スワイプ時の動作を発火する最小の距離 const SWIPE_DISTANCE_THRESHOLD = 70; -// スワイプを中断するY方向の移動距離 -const SWIPE_ABORT_Y_THRESHOLD = 75; - // スワイプできる最大の距離 const MAX_SWIPE_DISTANCE = 120; +// スワイプ方向を判定する角度の許容範囲(度数) +const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50; + // ▲ しきい値 ▲ // let startScreenX: number | null = null; @@ -69,6 +69,7 @@ const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === t const pullDistance = ref(0); const isSwipingForClass = ref(false); let swipeAborted = false; +let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null; function touchStart(event: TouchEvent) { if (!prefer.r.enableHorizontalSwipe.value) return; @@ -79,6 +80,7 @@ function touchStart(event: TouchEvent) { startScreenX = event.touches[0].screenX; startScreenY = event.touches[0].screenY; + swipeDirectionLocked = null; // スワイプ方向をリセット } function touchMove(event: TouchEvent) { @@ -95,15 +97,24 @@ function touchMove(event: TouchEvent) { let distanceX = event.touches[0].screenX - startScreenX; let distanceY = event.touches[0].screenY - startScreenY; - if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) { - swipeAborted = true; + // スワイプ方向をロック + if (!swipeDirectionLocked) { + const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI)); + if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) { + swipeDirectionLocked = 'vertical'; + } else { + swipeDirectionLocked = 'horizontal'; + } + } + // 縦方向のスワイプの場合は中断 + if (swipeDirectionLocked === 'vertical') { + swipeAborted = true; pullDistance.value = 0; isSwiping.value = false; window.setTimeout(() => { isSwipingForClass.value = false; }, 400); - return; } @@ -164,6 +175,8 @@ function touchEnd(event: TouchEvent) { window.setTimeout(() => { isSwipingForClass.value = false; }, 400); + + swipeDirectionLocked = null; // スワイプ方向をリセット } /** 横スワイプに関与する可能性のある要素を調べる */ @@ -190,7 +203,7 @@ watch(tabModel, (newTab, oldTab) => { const newIndex = props.tabs.findIndex(tab => tab.key === newTab); const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab); - if (oldIndex >= 0 && newIndex && oldIndex < newIndex) { + if (oldIndex >= 0 && newIndex >= 0 && oldIndex < newIndex) { transitionName.value = 'swipeAnimationLeft'; } else { transitionName.value = 'swipeAnimationRight'; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8ca690f2ce..6a265aa836 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()"> - <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> + <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)"> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <component @@ -21,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" :leaveToClass="$style.transition_x_leaveTo" - :moveClass=" $style.transition_x_move" + :moveClass="$style.transition_x_move" tag="div" > <template v-for="(note, i) in notes" :key="note.id"> @@ -36,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> @@ -53,7 +48,6 @@ import { prefer } from '@/preferences.js'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -93,7 +87,6 @@ type TimelineQueryType = { roleId?: string }; -const prComponent = useTemplateRef('prComponent'); const pagingComponent = useTemplateRef('pagingComponent'); let tlNotesCount = 0; @@ -306,18 +299,38 @@ defineExpose({ </script> <style lang="scss" module> -.transition_x_move, -.transition_x_enterActive, +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.note, + .note { + /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + .transition_x_leaveActive { - transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); } -.transition_x_enterFrom, -.transition_x_leaveTo { + +.transition_x_enterFrom { opacity: 0; - transform: translateY(-50%); + transform: translateY(max(-64px, -100%)); } -.transition_x_leaveActive { - position: absolute; + +@supports (interpolate-size: allow-keywords) { + .transition_x_leaveTo { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; } .reverse { diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 0d1ffd715f..90087cb000 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div :class="$style.root"> @@ -25,7 +20,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 3bd2a2ffae..2a423bfa55 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> - <div v-if="user != null"> + <MkError v-if="error" @retry="fetchUser()"/> + <div v-else-if="user != null"> <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"> <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> </div> @@ -85,6 +86,7 @@ const zIndex = os.claimZIndex('middle'); const user = ref<Misskey.entities.UserDetailed | null>(null); const top = ref(0); const left = ref(0); +const error = ref(false); function showMenu(ev: MouseEvent) { if (user.value == null) return; @@ -92,19 +94,27 @@ function showMenu(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } -onMounted(() => { +async function fetchUser() { if (typeof props.q === 'object') { user.value = props.q; + error.value = false; } else { - const query = props.q.startsWith('@') ? + const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ? Misskey.acct.parse(props.q.substring(1)) : { userId: props.q }; misskeyApi('users/show', query).then(res => { if (!props.showing) return; user.value = res; + error.value = false; + }, () => { + error.value = true; }); } +} + +onMounted(() => { + fetchUser(); const rect = props.source.getBoundingClientRect(); const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX; diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 95ed255189..6a5c4c18bf 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -4,20 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> - <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> - <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> - <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> - </div> -</Transition> +<MkResult type="error"> + <MkButton :class="$style.button" rounded @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> +</MkResult> </template> <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { prefer } from '@/preferences.js'; -import { serverErrorImageUrl } from '@/instance.js'; const emit = defineEmits<{ (ev: 'retry'): void; @@ -25,25 +19,7 @@ const emit = defineEmits<{ </script> <style lang="scss" module> -.root { - padding: 32px; - text-align: center; - align-items: center; -} - -.text { - margin: 0 0 8px 0; -} - .button { margin: 0 auto; } - -.img { - vertical-align: bottom; - width: 128px; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; -} </style> diff --git a/packages/frontend/src/components/global/MkResult.stories.impl.ts b/packages/frontend/src/components/global/MkResult.stories.impl.ts new file mode 100644 index 0000000000..05f8c9069b --- /dev/null +++ b/packages/frontend/src/components/global/MkResult.stories.impl.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkResult from './MkResult.vue'; +import type { StoryObj } from '@storybook/vue3'; +export const Default = { + render(args) { + return { + components: { + MkResult, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkResult v-bind="props" />', + }; + }, + args: { + type: 'empty', + text: 'Lorem Ipsum', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkResult>; +export const emptyWithNoText = { + ...Default, + args: { + ...Default.args, + text: undefined, + }, +} satisfies StoryObj<typeof MkResult>; +export const notFound = { + ...Default, + args: { + ...Default.args, + type: 'notFound', + }, +} satisfies StoryObj<typeof MkResult>; +export const errorType = { + ...Default, + args: { + ...Default.args, + type: 'error', + }, +} satisfies StoryObj<typeof MkResult>; diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue new file mode 100644 index 0000000000..fdfc7091e8 --- /dev/null +++ b/packages/frontend/src/components/global/MkResult.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> + <div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps"> + <img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/> + <img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'notFound'" type="question" :class="$style.icon"/> + <img v-if="type === 'error' && instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'error'" type="error" :class="$style.icon"/> + + <div style="opacity: 0.7;">{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : type === 'error' ? i18n.ts.somethingHappened : null) }}</div> + <slot></slot> + </div> +</Transition> +</template> + +<script lang="ts" setup> +import {} from 'vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + type: 'empty' | 'notFound' | 'error'; + text?: string; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; + text-align: center; + padding: 32px; +} + +.img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} + +.icon { + width: 65px; + height: 65px; + margin: 0 auto; +} +</style> diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue new file mode 100644 index 0000000000..3285d5a940 --- /dev/null +++ b/packages/frontend/src/components/global/MkSystemIcon.vue @@ -0,0 +1,109 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160"> + <path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.anim]"/> + <path d="M80,52L80,52" :class="[$style.line, $style.fade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160"> + <path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.anim]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160"> + <path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.anim]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160"> + <path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.anim]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> + <path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:390;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160"> + <path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.anim]"/> + <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.anim]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +</template> + +<script lang="ts" setup> +import {} from 'vue'; + +const props = defineProps<{ + type: 'info' | 'question' | 'success' | 'warn' | 'error'; +}>(); +</script> + +<style lang="scss" module> +.icon { + stroke-linecap: round; + stroke-linejoin: round; + + &.info { + color: var(--MI_THEME-accent); + } + + &.question { + color: var(--MI_THEME-fg); + } + + &.success { + color: var(--MI_THEME-success); + } + + &.warn { + color: var(--MI_THEME-warn); + } + + &.error { + color: var(--MI_THEME-error); + } +} + +.line { + fill: none; + stroke: currentColor; + stroke-width: 8px; +} + +.fill { + fill: currentColor; +} + +.anim { + stroke-dasharray: var(--l); + stroke-dashoffset: var(--l); + animation: line-animation var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation-delay: var(--delay, 0s); +} + +.fade { + opacity: 0; + animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation-delay: var(--delay, 0s); +} + +@keyframes line-animation { + 0% { + stroke-dashoffset: var(--l); + opacity: 0; + } + 100% { + stroke-dashoffset: 0; + opacity: 1; + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +</style> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index 58c222038a..33a34e0b67 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> <div :class="$style.body"> - <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> + <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> <slot></slot> </MkSwiper> <slot v-else></slot> @@ -25,6 +25,7 @@ import type { PageHeaderProps } from './MkPageHeader.vue'; import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js'; import MkSwiper from '@/components/MkSwiper.vue'; import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index ec6ea7c569..9981772ae8 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -24,6 +24,8 @@ import MkAd from './global/MkAd.vue'; import MkPageHeader from './global/MkPageHeader.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; +import MkResult from './global/MkResult.vue'; +import MkSystemIcon from './global/MkSystemIcon.vue'; import PageWithHeader from './global/PageWithHeader.vue'; import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; @@ -61,6 +63,8 @@ export const components = { MkPageHeader: MkPageHeader, MkStickyContainer: MkStickyContainer, MkLazy: MkLazy, + MkResult: MkResult, + MkSystemIcon: MkSystemIcon, PageWithHeader: PageWithHeader, PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, @@ -92,6 +96,8 @@ declare module '@vue/runtime-core' { MkPageHeader: typeof MkPageHeader; MkStickyContainer: typeof MkStickyContainer; MkLazy: typeof MkLazy; + MkResult: typeof MkResult; + MkSystemIcon: typeof MkSystemIcon; PageWithHeader: typeof PageWithHeader; PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; |