From 4b7b51d5ccdcdad5134edc0232c98e9e8ce2caf5 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Fri, 12 Nov 2021 23:53:10 +0900 Subject: [PATCH] refactor(client): use composition api for tooltip logic --- .../client/src/components/note-detailed.vue | 26 ++-- packages/client/src/components/note.vue | 26 ++-- .../client/src/components/notification.vue | 38 +---- .../components/reactions-viewer.reaction.vue | 146 ++++++++---------- .../client/src/components/renote-button.vue | 126 ++++++--------- packages/client/src/scripts/use-tooltip.ts | 44 ++++++ 6 files changed, 187 insertions(+), 219 deletions(-) create mode 100644 packages/client/src/scripts/use-tooltip.ts diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 09c05d7769..3b5b12a60a 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -94,7 +94,7 @@ <template v-else><i class="fas fa-reply"></i></template> <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <XRenoteButton :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/> + <XRenoteButton class="button" :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/> <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> <i class="fas fa-plus"></i> </button> @@ -132,16 +132,16 @@ import XMediaList from './media-list.vue'; import XCwButton from './cw-button.vue'; import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; // TODO: note.vueとほぼ同じなので共通化したい export default defineComponent({ @@ -154,8 +154,8 @@ export default defineComponent({ XCwButton, XPoll, XRenoteButton, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), }, inject: { diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index 19486c4dff..2ab769db43 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -78,7 +78,7 @@ <template v-else><i class="fas fa-reply"></i></template> <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <XRenoteButton :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/> + <XRenoteButton class="button" :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/> <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> <i class="fas fa-plus"></i> </button> @@ -115,16 +115,16 @@ import XMediaList from './media-list.vue'; import XCwButton from './cw-button.vue'; import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; export default defineComponent({ components: { @@ -136,8 +136,8 @@ export default defineComponent({ XCwButton, XPoll, XRenoteButton, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), }, inject: { diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index 1f61bee6f8..40670daa9c 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -78,6 +78,7 @@ import notePage from '@/filters/note'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; export default defineComponent({ components: { @@ -153,47 +154,14 @@ export default defineComponent({ os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); }; - let isReactionHovering = false; - let reactionTooltipTimeoutId; - - const onReactionMouseover = () => { - if (isReactionHovering) return; - isReactionHovering = true; - reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300); - }; - - const onReactionMouseleave = () => { - if (!isReactionHovering) return; - isReactionHovering = false; - clearTimeout(reactionTooltipTimeoutId); - closeReactionTooltip(); - }; - - let changeReactionTooltipShowingState: (() => void) | null; - - const openReactionTooltip = () => { - closeReactionTooltip(); - if (!isReactionHovering) return; - - const showing = ref(true); + const { onMouseover: onReactionMouseover, onMouseleave: onReactionMouseleave } = useTooltip((showing) => { os.popup(XReactionTooltip, { showing, reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, emojis: props.notification.note.emojis, source: reactionRef.value.$el, }, {}, 'closed'); - - changeReactionTooltipShowingState = () => { - showing.value = false; - }; - }; - - const closeReactionTooltip = () => { - if (changeReactionTooltipShowingState != null) { - changeReactionTooltipShowingState(); - changeReactionTooltipShowingState = null; - } - }; + }); return { getNoteSummary: (note: misskey.entities.Note) => getNoteSummary(note), diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue index 47a3bb9720..a7769868b9 100644 --- a/packages/client/src/components/reactions-viewer.reaction.vue +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -2,13 +2,13 @@ <button class="hkzvhatu _button" :class="{ reacted: note.myReaction == reaction, canToggle }" - @click="toggleReaction(reaction)" + @click="toggleReaction()" v-if="count > 0" @touchstart.passive="onMouseover" @mouseover="onMouseover" @mouseleave="onMouseleave" @touchend="onMouseleave" - ref="reaction" + ref="buttonRef" v-particle="canToggle" > <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/> @@ -17,15 +17,18 @@ </template> <script lang="ts"> -import { defineComponent, ref } from 'vue'; +import { computed, defineComponent, onMounted, ref, watch } from 'vue'; import XDetails from '@/components/reactions-viewer.details.vue'; import XReactionIcon from '@/components/reaction-icon.vue'; import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; +import { $i } from '@/account'; export default defineComponent({ components: { XReactionIcon }, + props: { reaction: { type: String, @@ -44,101 +47,78 @@ export default defineComponent({ required: true, }, }, - data() { - return { - close: null, - detailsTimeoutId: null, - isHovering: false - }; - }, - computed: { - canToggle(): boolean { - return !this.reaction.match(/@\w/) && this.$i; - }, - }, - watch: { - count(newCount, oldCount) { - if (oldCount < newCount) this.anime(); - if (this.close != null) this.openDetails(); - }, - }, - mounted() { - if (!this.isInitial) this.anime(); - }, - methods: { - toggleReaction() { - if (!this.canToggle) return; - const oldReaction = this.note.myReaction; + setup(props) { + const buttonRef = ref<HTMLElement>(); + + const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); + + const toggleReaction = () => { + if (!canToggle.value) return; + + const oldReaction = props.note.myReaction; if (oldReaction) { os.api('notes/reactions/delete', { - noteId: this.note.id + noteId: props.note.id }).then(() => { - if (oldReaction !== this.reaction) { + if (oldReaction !== props.reaction) { os.api('notes/reactions/create', { - noteId: this.note.id, - reaction: this.reaction + noteId: props.note.id, + reaction: props.reaction }); } }); } else { os.api('notes/reactions/create', { - noteId: this.note.id, - reaction: this.reaction + noteId: props.note.id, + reaction: props.reaction }); } - }, - onMouseover() { - if (this.isHovering) return; - this.isHovering = true; - this.detailsTimeoutId = setTimeout(this.openDetails, 300); - }, - onMouseleave() { - if (!this.isHovering) return; - this.isHovering = false; - clearTimeout(this.detailsTimeoutId); - this.closeDetails(); - }, - openDetails() { - os.api('notes/reactions', { - noteId: this.note.id, - type: this.reaction, - limit: 11 - }).then((reactions: any[]) => { - const users = reactions - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - .map(x => x.user); + }; - this.closeDetails(); - if (!this.isHovering) return; - - const showing = ref(true); - os.popup(XDetails, { - showing, - reaction: this.reaction, - emojis: this.note.emojis, - users, - count: this.count, - source: this.$refs.reaction - }, {}, 'closed'); - - this.close = () => { - showing.value = false; - }; - }); - }, - closeDetails() { - if (this.close != null) { - this.close(); - this.close = null; - } - }, - anime() { + const anime = () => { if (document.hidden) return; - // TODO - }, - } + // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション + }; + + watch(() => props.count, (newCount, oldCount) => { + if (oldCount < newCount) anime(); + }); + + onMounted(() => { + if (!props.isInitial) anime(); + }); + + const { onMouseover, onMouseleave } = useTooltip(async (showing) => { + const reactions = await os.api('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 11 + }); + + const users = reactions + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map(x => x.user); + + os.popup(XDetails, { + showing, + reaction: props.reaction, + emojis: props.note.emojis, + users, + count: props.count, + source: buttonRef.value + }, {}, 'closed'); + }); + + return { + buttonRef, + canToggle, + toggleReaction, + onMouseover, + onMouseleave, + }; + }, }); </script> diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue index 16ae2a2fa4..5ddc1602ed 100644 --- a/packages/client/src/components/renote-button.vue +++ b/packages/client/src/components/renote-button.vue @@ -1,13 +1,13 @@ <template> <button - class="button _button canRenote" + class="eddddedb _button canRenote" @click="renote()" v-if="canRenote" @touchstart.passive="onMouseover" @mouseover="onMouseover" @mouseleave="onMouseleave" @touchend="onMouseleave" - ref="renoteButton" + ref="buttonRef" > <i class="fas fa-retweet"></i> <p class="count" v-if="count > 0">{{ count }}</p> @@ -21,10 +21,13 @@ </template> <script lang="ts"> -import { defineComponent, ref } from 'vue'; -import XDetails from '@client/components/renote.details.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import * as os from '@client/os'; +import { computed, defineComponent, ref } from 'vue'; +import XDetails from '@/components/renote.details.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { useTooltip } from '@/scripts/use-tooltip'; +import { i18n } from '@/i18n'; export default defineComponent({ props: { @@ -37,95 +40,68 @@ export default defineComponent({ required: true, }, }, - data() { - return { - close: null, - detailsTimeoutId: null, - isHovering: false - }; - }, - computed: { - canRenote(): boolean { - return ['public', 'home'].includes(this.note.visibility) || this.note.userId === this.$i.id; - }, - }, - watch: { - count(newCount, oldCount) { - if (oldCount < newCount) this.anime(); - if (this.close != null) this.openDetails(); - }, - }, - methods: { - renote(viaKeyboard = false) { + + setup(props) { + const buttonRef = ref<HTMLElement>(); + + const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); + + const { onMouseover, onMouseleave } = useTooltip(async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: props.note.id, + limit: 11 + }); + + const users = renotes + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map(x => x.user); + + if (users.length < 1) return; + + os.popup(XDetails, { + showing, + users, + count: props.count, + source: buttonRef.value + }, {}, 'closed'); + }); + + const renote = (viaKeyboard = false) => { pleaseLogin(); os.popupMenu([{ - text: this.$ts.renote, + text: i18n.locale.renote, icon: 'fas fa-retweet', action: () => { os.api('notes/create', { - renoteId: this.note.id + renoteId: props.note.id }); } }, { - text: this.$ts.quote, + text: i18n.locale.quote, icon: 'fas fa-quote-right', action: () => { os.post({ - renote: this.note, + renote: props.note, }); } - }], this.$refs.renoteButton, { + }], buttonRef.value, { viaKeyboard }); - }, - onMouseover() { - if (this.isHovering) return; - this.isHovering = true; - this.detailsTimeoutId = setTimeout(this.openDetails, 300); - }, - onMouseleave() { - if (!this.isHovering) return; - this.isHovering = false; - clearTimeout(this.detailsTimeoutId); - this.closeDetails(); - }, - openDetails() { - os.api('notes/renotes', { - noteId: this.note.id, - limit: 11 - }).then((renotes: any[]) => { - const users = renotes - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - .map(x => x.user); + }; - this.closeDetails(); - if (!this.isHovering || users.length < 1) return; - - const showing = ref(true); - os.popup(XDetails, { - showing, - users, - count: this.count, - source: this.$refs.renoteButton - }, {}, 'closed'); - - this.close = () => { - showing.value = false; - }; - }); - }, - closeDetails() { - if (this.close != null) { - this.close(); - this.close = null; - } - }, - } + return { + buttonRef, + canRenote, + renote, + onMouseover, + onMouseleave, + }; + }, }); </script> <style lang="scss" scoped> -.button { +.eddddedb { display: inline-block; height: 32px; margin: 2px; diff --git a/packages/client/src/scripts/use-tooltip.ts b/packages/client/src/scripts/use-tooltip.ts new file mode 100644 index 0000000000..2c0c36400d --- /dev/null +++ b/packages/client/src/scripts/use-tooltip.ts @@ -0,0 +1,44 @@ +import { Ref, ref } from 'vue'; + +export function useTooltip(onShow: (showing: Ref<boolean>) => void) { + let isHovering = false; + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + const open = () => { + close(); + if (!isHovering) return; + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, 300); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + return { + onMouseover, + onMouseleave, + }; +}