diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-02-11 14:08:58 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2023-02-11 14:08:58 +0900 |
| commit | 5af8b77d287f006031238293c29d8d5cea1cd4a1 (patch) | |
| tree | 75369017b1091e21c3072d0adff062cdc510b701 /packages/frontend/src/components | |
| parent | Merge branch 'develop' (diff) | |
| parent | 13.6.0 (diff) | |
| download | misskey-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.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkMediaImage.vue | 3 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNote.vue | 96 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNoteHeader.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotification.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 32 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkReactionsViewer.reaction.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkReactionsViewer.vue | 51 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUserPreview.vue | 13 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/MkPageHeader.vue | 275 | ||||
| -rw-r--r-- | packages/frontend/src/components/mfm.ts | 32 |
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});`; |