summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-02-11 14:08:58 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2023-02-11 14:08:58 +0900
commit5af8b77d287f006031238293c29d8d5cea1cd4a1 (patch)
tree75369017b1091e21c3072d0adff062cdc510b701 /packages/frontend/src/components
parentMerge branch 'develop' (diff)
parent13.6.0 (diff)
downloadmisskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.tar.gz
misskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.tar.bz2
misskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.zip
Merge branch 'develop'
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkDialog.vue2
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue3
-rw-r--r--packages/frontend/src/components/MkNote.vue96
-rw-r--r--packages/frontend/src/components/MkNoteHeader.vue4
-rw-r--r--packages/frontend/src/components/MkNotification.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue32
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue2
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue51
-rw-r--r--packages/frontend/src/components/MkUserPreview.vue13
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue275
-rw-r--r--packages/frontend/src/components/mfm.ts32
11 files changed, 394 insertions, 120 deletions
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index da4db63406..9690353432 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -32,7 +32,7 @@
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
- <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
+ <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
</div>
</div>
</MkModal>
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 6ac56f3ce0..b777a1329b 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -8,7 +8,7 @@
</div>
</div>
</div>
-<div v-else :class="$style.visible">
+<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
<a
:class="$style.imageContainer"
:href="image.url"
@@ -78,7 +78,6 @@ watch(() => props.image, () => {
position: relative;
//box-shadow: 0 0 0 1px var(--divider) inset;
background: var(--bg);
- --c: rgb(0 0 0 / 2%);
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
background-size: 16px 16px;
}
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 7edcaf1324..e910fbab01 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -5,14 +5,14 @@
ref="el"
v-hotkey="keymap"
:class="$style.root"
- :tabindex="!isDeleted ? '-1' : null"
+ :tabindex="!isDeleted ? '-1' : undefined"
>
- <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+ <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
<!--<div v-if="appearNote._prId_" class="tip"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
<div v-if="isRenote" :class="$style.renote">
- <MkAvatar v-once :class="$style.renoteAvatar" :user="note.user" link preview/>
+ <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
<I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
<template #user>
@@ -34,8 +34,12 @@
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</div>
</div>
- <article :class="$style.article" @contextmenu.stop="onContextmenu">
- <MkAvatar v-once :class="$style.avatar" :user="appearNote.user" link preview/>
+ <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
+ <MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
+ <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
+ </div>
+ <article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
+ <MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/>
@@ -60,7 +64,7 @@
<div v-if="appearNote.files.length > 0" :class="$style.files">
<MkMediaList :media-list="appearNote.files"/>
</div>
- <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/>
+ <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
@@ -73,7 +77,13 @@
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer :class="$style.footer">
- <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
+ <MkReactionsViewer :note="appearNote" :max-number="16">
+ <template v-slot:more>
+ <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
+ {{ i18n.ts.more }}
+ </button>
+ </template>
+ </MkReactionsViewer>
<button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p>
@@ -116,7 +126,7 @@
</template>
<script lang="ts" setup>
-import { computed, inject, onMounted, onUnmounted, reactive, ref, shallowRef, Ref } from 'vue';
+import { computed, inject, onMounted, onUnmounted, reactive, ref, shallowRef, Ref, defineAsyncComponent } from 'vue';
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
@@ -144,6 +154,8 @@ import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { shownNoteIds } from '@/os';
const props = defineProps<{
note: misskey.entities.Note;
@@ -180,18 +192,23 @@ const reactButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = (appearNote.cw == null && appearNote.text != null && (
(appearNote.text.split('\n').length > 9) ||
- (appearNote.text.length > 500)
+ (appearNote.text.length > 500) ||
+ (appearNote.files.length >= 5) ||
+ (urls && urls.length >= 4)
));
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
-const translation = ref(null);
+const translation = ref<any>(null);
const translating = ref(false);
-const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
+let renoteCollapsed = $ref(isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id)));
+
+shownNoteIds.add(appearNote.id);
const keymap = {
'r': () => reply(true),
@@ -350,6 +367,12 @@ function readPromo() {
});
isDeleted.value = true;
}
+
+function showReactions(): void {
+ os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), {
+ noteId: appearNote.id,
+ }, {}, 'closed');
+}
</script>
<style lang="scss" module>
@@ -433,7 +456,6 @@ function readPromo() {
width: 28px;
height: 28px;
margin: 0 8px 0 0;
- border-radius: 6px;
}
.renoteText {
@@ -461,6 +483,36 @@ function readPromo() {
margin-right: 4px;
}
+.collapsedRenoteTarget {
+ display: flex;
+ align-items: center;
+ line-height: 28px;
+ white-space: pre;
+ padding: 0 32px 18px;
+}
+
+.collapsedRenoteTargetAvatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+}
+
+.collapsedRenoteTargetText {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 90%;
+ opacity: 0.7;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
.article {
display: flex;
padding: 28px 32px 18px;
@@ -614,6 +666,11 @@ function readPromo() {
padding: 8px 16px 0 16px;
}
+ .collapsedRenoteTarget {
+ padding: 0 16px 9px;
+ margin-top: 4px;
+ }
+
.article {
padding: 14px 16px 9px;
}
@@ -652,4 +709,19 @@ function readPromo() {
text-align: center;
opacity: 0.7;
}
+
+.reactionDetailsButton {
+ display: inline-block;
+ height: 32px;
+ margin: 2px;
+ padding: 0 6px;
+ border: dashed 1px var(--divider);
+ border-radius: 4px;
+ background: transparent;
+ opacity: .8;
+
+ &:hover {
+ background: var(--X5);
+ }
+}
</style>
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index 6b43f14665..32998e1a70 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -1,6 +1,6 @@
<template>
<header :class="$style.root">
- <MkA v-once v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
+ <MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
@@ -90,7 +90,7 @@ defineProps<{
vertical-align: -20%;
& + .badgeRole {
- margin-left: .125em;
+ margin-left: 0.2em;
}
}
</style>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 8c7114eac1..0d42e8ffbf 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -1,6 +1,6 @@
<template>
<div ref="elRef" :class="$style.root">
- <div v-once :class="$style.head">
+ <div :class="$style.head">
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
@@ -35,7 +35,7 @@
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
- <div v-once :class="$style.content">
+ <div :class="$style.content">
<MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index c7e7e85b2e..f15906c1c1 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -109,7 +109,7 @@ const props = withDefaults(defineProps<{
mention?: misskey.entities.User;
specified?: misskey.entities.User;
initialText?: string;
- initialVisibility?: typeof misskey.noteVisibilities;
+ initialVisibility?: (typeof misskey.noteVisibilities)[number];
initialFiles?: misskey.entities.DriveFile[];
initialLocalOnly?: boolean;
initialVisibleUsers?: misskey.entities.User[];
@@ -579,6 +579,36 @@ async function post(ev?: MouseEvent) {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
+ const annoying =
+ text.includes('$[x2') ||
+ text.includes('$[x3') ||
+ text.includes('$[x4') ||
+ text.includes('$[scale') ||
+ text.includes('$[position');
+ if (annoying) {
+ const { canceled, result } = await os.actions({
+ type: 'warning',
+ text: i18n.ts.thisPostMayBeAnnoying,
+ actions: [{
+ value: 'home',
+ text: i18n.ts.thisPostMayBeAnnoyingHome,
+ primary: true,
+ }, {
+ value: 'cancel',
+ text: i18n.ts.thisPostMayBeAnnoyingCancel,
+ }, {
+ value: 'ignore',
+ text: i18n.ts.thisPostMayBeAnnoyingIgnore,
+ }],
+ });
+
+ if (canceled) return;
+ if (result === 'cancel') return;
+ if (result === 'home') {
+ visibility = 'home';
+ }
+ }
+
let postData = {
text: text === '' ? undefined : text,
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 83fdf0f988..4abd2562df 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -107,7 +107,7 @@ useTooltip(buttonEl, async (showing) => {
border-radius: 4px;
&.canToggle {
- background: rgba(0, 0, 0, 0.05);
+ background: var(--buttonBg);
&:hover {
background: rgba(0, 0, 0, 0.1);
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 5981471c68..cdd6f528e7 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -7,23 +7,60 @@
:move-class="$store.state.animation ? $style.transition_x_move : ''"
tag="div" :class="$style.root"
>
- <XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
+ <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
+ <slot v-if="hasMoreReactions" name="more" />
</TransitionGroup>
</template>
<script lang="ts" setup>
-import { computed } from 'vue';
import * as misskey from 'misskey-js';
-import { $i } from '@/account';
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
+import { watch } from 'vue';
-const props = defineProps<{
- note: misskey.entities.Note;
-}>();
+const props = withDefaults(defineProps<{
+ note: misskey.entities.Note;
+ maxNumber?: number;
+}>(), {
+ maxNumber: Infinity,
+});
const initialReactions = new Set(Object.keys(props.note.reactions));
-const isMe = computed(() => $i && $i.id === props.note.userId);
+let reactions = $ref<[string, number][]>([]);
+let hasMoreReactions = $ref(false);
+
+if (props.note.myReaction && !Object.keys(reactions).includes(props.note.myReaction)) {
+ reactions[props.note.myReaction] = props.note.reactions[props.note.myReaction];
+}
+
+watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
+ let newReactions: [string, number][] = [];
+ hasMoreReactions = Object.keys(newSource).length > maxNumber;
+
+ for (let i = 0; i < reactions.length; i++) {
+ const reaction = reactions[i][0];
+ if (reaction in newSource && newSource[reaction] !== 0) {
+ reactions[i][1] = newSource[reaction];
+ newReactions.push(reactions[i]);
+ }
+ }
+
+ const newReactionsNames = newReactions.map(([x]) => x);
+ newReactions = [
+ ...newReactions,
+ ...Object.entries(newSource)
+ .sort(([, a], [, b]) => b - a)
+ .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)),
+ ]
+
+ newReactions = newReactions.slice(0, props.maxNumber);
+
+ if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) {
+ newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]);
+ }
+
+ reactions = newReactions;
+}, { immediate: true, deep: true });
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue
index f68fdd64d9..eacc66de4f 100644
--- a/packages/frontend/src/components/MkUserPreview.vue
+++ b/packages/frontend/src/components/MkUserPreview.vue
@@ -24,6 +24,7 @@
<p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
</div>
</div>
+ <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
</div>
<div v-else>
@@ -40,6 +41,7 @@ import * as misskey from 'misskey-js';
import MkFollowButton from '@/components/MkFollowButton.vue';
import { userPage } from '@/filters/user';
import * as os from '@/os';
+import { getUserMenu } from '@/scripts/get-user-menu';
const props = defineProps<{
showing: boolean;
@@ -58,6 +60,10 @@ let user = $ref<misskey.entities.UserDetailed | null>(null);
let top = $ref(0);
let left = $ref(0);
+function showMenu(ev: MouseEvent) {
+ os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
+}
+
onMounted(() => {
if (typeof props.q === 'object') {
user = props.q;
@@ -174,6 +180,13 @@ onMounted(() => {
}
}
+ > .menu {
+ position: absolute;
+ top: 8px;
+ right: 42px;
+ padding: 8px;
+ }
+
> .koudoku-button {
position: absolute;
top: 8px;
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index ab66502e06..23a39b9ac9 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -1,37 +1,62 @@
<template>
-<div v-if="show" ref="el" :class="[$style.root, { [$style.slim]: narrow, [$style.thin]: thin_ }]" :style="{ background: bg }" @click="onClick">
- <div v-if="narrow" :class="$style.buttonsLeft">
- <MkAvatar v-if="props.displayMyAvatar && $i" :class="$style.avatar" :user="$i"/>
- </div>
- <template v-if="metadata">
- <div v-if="!hideTitle" :class="$style.titleContainer" @click="showTabsPopup">
- <MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
- <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
+<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
+ <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
+ <div v-if="narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
+ <MkAvatar :class="$style.avatar" :user="$i" />
+ </div>
+ <div v-else-if="narrow && !hideTitle" :class="$style.buttonsLeft" />
+
+ <template v-if="metadata">
+ <div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
+ <MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
+ <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
- <div :class="$style.title">
- <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true"/>
- <div v-else-if="metadata.title">{{ metadata.title }}</div>
- <div v-if="!narrow && metadata.subtitle" :class="$style.subtitle">
- {{ metadata.subtitle }}
+ <div :class="$style.title">
+ <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true"/>
+ <div v-else-if="metadata.title">{{ metadata.title }}</div>
+ <div v-if="metadata.subtitle" :class="$style.subtitle">
+ {{ metadata.subtitle }}
+ </div>
</div>
- <div v-if="narrow && hasTabs" :class="[$style.subtitle, $style.activeTab]">
- {{ tabs.find(tab => tab.key === props.tab)?.title }}
- <i class="ti ti-chevron-down" :class="$style.chevron"></i>
+ </div>
+ <div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel">
+ <div :class="$style.tabsInner">
+ <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
+ <div :class="$style.tabInner">
+ <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
+ <div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div>
+ <Transition
+ v-else
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ mode="in-out"
+ >
+ <div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div>
+ </Transition>
+ </div>
+ </button>
</div>
+ <div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
+ </template>
+ <div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
+ <template v-for="action in actions">
+ <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+ </template>
</div>
- <div v-if="!narrow || hideTitle" :class="$style.tabs">
- <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
- <i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i>
- <span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span>
- </button>
+ </div>
+ <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
+ <div :class="$style.tabs" @wheel="onTabWheel">
+ <div :class="$style.tabsInner">
+ <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+ <i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i>
+ <span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span>
+ </button>
+ </div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
- </template>
- <div :class="$style.buttonsRight">
- <template v-for="action in actions">
- <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
- </template>
</div>
</div>
</template>
@@ -39,11 +64,10 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
import tinycolor from 'tinycolor2';
-import { popupMenu } from '@/os';
import { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata';
-import { $i } from '@/account';
+import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
type Tab = {
key: string;
@@ -77,9 +101,9 @@ const metadata = injectPageMetadata();
const hideTitle = inject('shouldOmitHeaderTitle', false);
const thin_ = props.thin || inject('shouldHeaderThin', false);
-const el = $shallowRef<HTMLElement | undefined>(undefined);
+let el = $shallowRef<HTMLElement | undefined>(undefined);
const tabRefs: Record<string, HTMLElement | null> = {};
-const tabHighlightEl = $shallowRef<HTMLElement | null>(null);
+let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
const bg = ref<string | undefined>(undefined);
let narrow = $ref(false);
const hasTabs = $computed(() => props.tabs.length > 0);
@@ -88,32 +112,22 @@ const show = $computed(() => {
return !hideTitle || hasTabs || hasActions;
});
-const showTabsPopup = (ev: MouseEvent) => {
- if (!hasTabs) return;
- if (!narrow) return;
- ev.preventDefault();
- ev.stopPropagation();
- const menu = props.tabs.map(tab => ({
- text: tab.title,
- icon: tab.icon,
- active: tab.key != null && tab.key === props.tab,
- action: (ev) => {
- onTabClick(tab, ev);
- },
- }));
- popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement);
-};
-
const preventDrag = (ev: TouchEvent) => {
ev.stopPropagation();
};
-const onClick = () => {
+const top = () => {
if (el) {
scrollToTop(el as HTMLElement, { behavior: 'smooth' });
}
};
+function openAccountMenu(ev: MouseEvent) {
+ openAccountMenu_({
+ withExtraOperation: true,
+ }, ev);
+}
+
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// ユーザビリティの観点からmousedown時にはonClickは呼ばない
if (tab.key) {
@@ -121,14 +135,17 @@ function onTabMousedown(tab: Tab, ev: MouseEvent): void {
}
}
-function onTabClick(tab: Tab, ev: MouseEvent): void {
- if (tab.onClick) {
+function onTabClick(t: Tab, ev: MouseEvent): void {
+ if (t.key === props.tab) {
+ top();
+ } else if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
- tab.onClick(ev);
+ t.onClick(ev);
}
- if (tab.key) {
- emit('update:tab', tab.key);
+
+ if (t.key) {
+ emit('update:tab', t.key);
}
}
@@ -139,56 +156,124 @@ const calcBg = () => {
bg.value = tinyBg.toRgbString();
};
-let ro: ResizeObserver | null;
+let ro1: ResizeObserver | null;
+let ro2: ResizeObserver | null;
+
+function renderTab() {
+ const tabEl = props.tab ? tabRefs[props.tab] : undefined;
+ if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabHighlightEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px';
+ }
+}
+
+function onTabWheel(ev: WheelEvent) {
+ if (ev.deltaY !== 0 && ev.deltaX === 0) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ (ev.currentTarget as HTMLElement).scrollBy({
+ left: ev.deltaY,
+ behavior: 'smooth',
+ });
+ }
+ return false;
+}
+
+function enter(el: HTMLElement) {
+ const elementWidth = el.getBoundingClientRect().width;
+ el.style.width = '0';
+ el.offsetWidth; // reflow
+ el.style.width = elementWidth + 'px';
+ setTimeout(renderTab, 70);
+}
+function afterEnter(el: HTMLElement) {
+ el.style.width = '';
+ nextTick(renderTab);
+}
+function leave(el: HTMLElement) {
+ const elementWidth = el.getBoundingClientRect().width;
+ el.style.width = elementWidth + 'px';
+ el.offsetWidth; // reflow
+ el.style.width = '0';
+}
+function afterLeave(el: HTMLElement) {
+ el.style.width = '';
+}
onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
- watch(() => [props.tab, props.tabs], () => {
- nextTick(() => {
- const tabEl = props.tab ? tabRefs[props.tab] : undefined;
- if (tabEl && tabHighlightEl && tabEl.parentElement) {
- // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
- // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
- const parentRect = tabEl.parentElement.getBoundingClientRect();
- const rect = tabEl.getBoundingClientRect();
- tabHighlightEl.style.width = rect.width + 'px';
- tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
- }
- });
+ watch([() => props.tab, () => props.tabs], () => {
+ nextTick(() => renderTab());
}, {
immediate: true,
});
if (el && el.parentElement) {
narrow = el.parentElement.offsetWidth < 500;
- ro = new ResizeObserver((entries, observer) => {
- if (el.parentElement && document.body.contains(el as HTMLElement)) {
+ ro1 = new ResizeObserver((entries, observer) => {
+ if (el && el.parentElement && document.body.contains(el as HTMLElement)) {
narrow = el.parentElement.offsetWidth < 500;
}
});
- ro.observe(el.parentElement as HTMLElement);
+ ro1.observe(el.parentElement as HTMLElement);
+ }
+
+ if (el) {
+ ro2 = new ResizeObserver((entries, observer) => {
+ if (document.body.contains(el as HTMLElement)) {
+ nextTick(() => renderTab());
+ }
+ });
+ ro2.observe(el);
}
});
onUnmounted(() => {
globalEvents.off('themeChanged', calcBg);
- if (ro) ro.disconnect();
+ if (ro1) ro1.disconnect();
+ if (ro2) ro2.disconnect();
});
</script>
<style lang="scss" module>
.root {
- --height: 50px;
- display: flex;
- width: 100%;
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-bottom: solid 0.5px var(--divider);
- contain: strict;
+ width: 100%;
+}
+
+.upper,
+.lower {
+ width: 100%;
+ background: transparent;
+}
+
+.upper {
+ --height: 50px;
+ display: flex;
height: var(--height);
+ .tabs:first-child {
+ margin-left: auto;
+ }
+ .tabs:not(:first-child) {
+ padding-left: 16px;
+ mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%);
+ }
+ .tabs:last-child {
+ margin-right: auto;
+ }
+ .tabs:not(:last-child) {
+ margin-right: 0;
+ }
+
&.thin {
--height: 42px;
@@ -205,6 +290,7 @@ onUnmounted(() => {
> .titleContainer {
flex: 1;
margin: 0 auto;
+ max-width: 100%;
> *:first-child {
margin-left: auto;
@@ -217,6 +303,11 @@ onUnmounted(() => {
}
}
+.lower {
+ --height: 40px;
+ height: var(--height);
+}
+
.buttons {
--margin: 8px;
display: flex;
@@ -247,15 +338,14 @@ onUnmounted(() => {
height: $size;
vertical-align: bottom;
margin: 0 8px;
- pointer-events: none;
}
.button {
display: flex;
align-items: center;
justify-content: center;
- height: calc(var(--height) - (var(--margin) * 2));
- width: calc(var(--height) - (var(--margin) * 2));
+ height: var(--height);
+ width: calc(var(--height) - (var(--margin)));
box-sizing: border-box;
position: relative;
border-radius: 5px;
@@ -278,7 +368,7 @@ onUnmounted(() => {
.titleContainer {
display: flex;
align-items: center;
- max-width: 400px;
+ max-width: min(30vw, 400px);
overflow: auto;
white-space: nowrap;
text-align: left;
@@ -330,10 +420,24 @@ onUnmounted(() => {
}
.tabs {
+ display: block;
position: relative;
- margin-left: 16px;
+ margin: 0;
+ height: var(--height);
font-size: 0.8em;
- overflow: auto;
+ text-align: center;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.tabsInner {
+ display: inline-block;
+ height: var(--height);
white-space: nowrap;
}
@@ -344,6 +448,7 @@ onUnmounted(() => {
height: 100%;
font-weight: normal;
opacity: 0.7;
+ transition: opacity 0.2s ease;
&:hover {
opacity: 1;
@@ -354,8 +459,18 @@ onUnmounted(() => {
}
}
+.tabInner {
+ display: flex;
+ align-items: center;
+}
+
.tabIcon + .tabTitle {
margin-left: 8px;
+}
+
+.tabTitle {
+ overflow: hidden;
+ transition: width 0.15s ease-in-out;
}
.tabHighlight {
@@ -364,7 +479,7 @@ onUnmounted(() => {
height: 3px;
background: var(--accent);
border-radius: 999px;
- transition: all 0.2s ease;
+ transition: width 0.15s ease, left 0.15s ease;
pointer-events: none;
}
</style>
diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts
index 683c9014a1..816a42a5fb 100644
--- a/packages/frontend/src/components/mfm.ts
+++ b/packages/frontend/src/components/mfm.ts
@@ -12,6 +12,7 @@ import MkSparkle from '@/components/MkSparkle.vue';
import MkA from '@/components/global/MkA.vue';
import { host } from '@/config';
import { MFM_TAGS } from '@/scripts/mfm-tags';
+import { defaultStore } from '@/store';
const QUOTE_STYLE = `
display: block;
@@ -64,6 +65,8 @@ export default defineComponent({
return t.match(/^[0-9.]+s$/) ? t : null;
};
+ const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
+
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => {
switch (token.type) {
case 'text': {
@@ -102,22 +105,22 @@ export default defineComponent({
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
- style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : '');
+ style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : '');
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) ?? '1s';
- style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
+ style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
- style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
+ style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
- style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
+ style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : '';
break;
}
case 'spin': {
@@ -130,17 +133,17 @@ export default defineComponent({
token.props.args.y ? 'mfm-spinY' :
'mfm-spin';
const speed = validTime(token.props.args.speed) ?? '1.5s';
- style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
+ style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
- style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : '';
+ style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
- style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
+ style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
break;
}
case 'flip': {
@@ -153,17 +156,17 @@ export default defineComponent({
}
case 'x2': {
return h('span', {
- class: 'mfm-x2',
+ class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
}, genEl(token.children));
}
case 'x3': {
return h('span', {
- class: 'mfm-x3',
+ class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
}, genEl(token.children));
}
case 'x4': {
return h('span', {
- class: 'mfm-x4',
+ class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
}, genEl(token.children));
}
case 'font': {
@@ -185,11 +188,11 @@ export default defineComponent({
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) ?? '1s';
- style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
+ style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
break;
}
case 'sparkle': {
- if (!this.$store.state.animatedMfm) {
+ if (!useAnim) {
return genEl(token.children);
}
return h(MkSparkle, {}, genEl(token.children));
@@ -200,12 +203,17 @@ export default defineComponent({
break;
}
case 'position': {
+ if (!defaultStore.state.advancedMfm) break;
const x = parseFloat(token.props.args.x ?? '0');
const y = parseFloat(token.props.args.y ?? '0');
style = `transform: translateX(${x}em) translateY(${y}em);`;
break;
}
case 'scale': {
+ if (!defaultStore.state.advancedMfm) {
+ style = '';
+ break;
+ }
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
style = `transform: scale(${x}, ${y});`;