diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 868beb7765..5c7e7d6411 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -1,5 +1,5 @@ <template> -<Transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"> +<Transition :name="transitionName" :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"> <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick"> @@ -74,20 +74,27 @@ const type = $computed(() => { return props.preferType!; } }); +let transitionName = $ref(defaultStore.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''); +let transitionDuration = $ref(defaultStore.state.animation ? 200 : 0); let contentClicking = false; -const close = () => { +function close(opts: { useSendAnimation?: boolean } = {}) { + if (opts.useSendAnimation) { + transitionName = 'send'; + transitionDuration = 400; + } + // eslint-disable-next-line vue/no-mutating-props if (props.src) props.src.style.pointerEvents = 'auto'; showing = false; emit('close'); -}; +} -const onBgClick = () => { +function onBgClick() { if (contentClicking) return; emit('click'); -}; +} if (type === 'drawer') { maxHeight = window.innerHeight / 1.5; @@ -254,6 +261,30 @@ defineExpose({ </script> <style lang="scss" scoped> +.send-enter-active, .send-leave-active { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transform-style: preserve-3d; + transform: perspective(50cm) translateZ(0px) translateY(0px) rotateX(0deg); + transition: opacity 0.4s cubic-bezier(.5,-0.5,.75,1), transform 0.4s cubic-bezier(.5,-0.5,.75,1) !important; + } +} +.send-enter-from, .send-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform-style: preserve-3d; + transform: perspective(50cm) translateZ(-300px) translateY(-200px) rotateX(40deg); + } +} + .modal-enter-active, .modal-leave-active { > .bg { transition: opacity 0.2s !important; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 875c6753b7..b8784620c0 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -22,7 +22,14 @@ <span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span> </button> <button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> - <button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button> + <button v-click-anime class="submit _button" :class="{ posting }" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> + <div class="inner _buttonGradate"> + <template v-if="posted"></template> + <template v-else-if="posting"><MkEllipsis/></template> + <template v-else>{{ submitText }}</template> + <i :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i> + </div> + </button> </div> </header> <div class="form" :class="{ fixed }"> @@ -41,7 +48,7 @@ </div> <MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> - <textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <XPostFormAttaches v-model="files" class="attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> @@ -90,6 +97,7 @@ import { instance } from '@/instance'; import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { uploadFile } from '@/scripts/upload'; import { deepClone } from '@/scripts/clone'; +import Ripple from '@/components/MkRipple.vue'; const modal = inject('modal'); @@ -108,6 +116,7 @@ const props = withDefaults(defineProps<{ instant?: boolean; fixed?: boolean; autofocus?: boolean; + freezeAfterPosted?: boolean; }>(), { initialVisibleUsers: () => [], autofocus: true, @@ -125,6 +134,7 @@ const hashtagsInputEl = $ref<HTMLInputElement | null>(null); const visibilityButton = $ref<HTMLElement | null>(null); let posting = $ref(false); +let posted = $ref(false); let text = $ref(props.initialText ?? ''); let files = $ref(props.initialFiles ?? []); let poll = $ref<{ @@ -206,7 +216,7 @@ const maxTextLength = $computed((): number => { }); const canPost = $computed((): boolean => { - return !posting && + return !posting && !posted && (1 <= textLength || 1 <= files.length || !!poll || !!props.renote) && (textLength <= maxTextLength) && (!poll || poll.choices.length >= 2); @@ -559,7 +569,15 @@ function deleteDraft() { localStorage.setItem('drafts', JSON.stringify(draftData)); } -async function post() { +async function post(ev?: MouseEvent) { + if (ev) { + const el = ev.currentTarget ?? ev.target; + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(Ripple, { x, y }, {}, 'end'); + } + let postData = { text: text === '' ? undefined : text, fileIds: files.length > 0 ? files.map(f => f.id) : undefined, @@ -594,7 +612,11 @@ async function post() { posting = true; os.api('notes/create', postData, token).then(() => { - clear(); + if (props.freezeAfterPosted) { + posted = true; + } else { + clear(); + } nextTick(() => { deleteDraft(); emit('posted'); @@ -713,6 +735,10 @@ onMounted(() => { nextTick(() => watchForDraft()); }); }); + +defineExpose({ + clear, +}); </script> <style lang="scss" scoped> @@ -793,19 +819,28 @@ onMounted(() => { > .submit { margin: 16px 16px 16px 0; - padding: 0 12px; - line-height: 34px; - font-weight: bold; vertical-align: bottom; - border-radius: 4px; - font-size: 0.9em; &:disabled { opacity: 0.7; } - > i { - margin-left: 6px; + &.posting { + cursor: wait; + } + + > .inner { + padding: 0 12px; + line-height: 34px; + font-weight: bold; + border-radius: 4px; + font-size: 0.9em; + min-width: 90px; + box-sizing: border-box; + + > i { + margin-left: 6px; + } } } } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 6dabb1db14..71c07ed658 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -1,19 +1,46 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog:top'" @click="$refs.modal.close()" @closed="$emit('closed')"> - <MkPostForm v-bind="$attrs" @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()"/> +<MkModal ref="modal" :prefer-type="'dialog:top'" @click="modal.close()" @closed="onModalClosed()"> + <MkPostForm ref="form" v-bind="props" autofocus freeze-after-posted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; -export default defineComponent({ - components: { - MkModal, - MkPostForm, - }, - emits: ['closed'], -}); +const props = defineProps<{ + reply?: misskey.entities.Note; + renote?: misskey.entities.Note; + channel?: any; // TODO + mention?: misskey.entities.User; + specified?: misskey.entities.User; + initialText?: string; + initialVisibility?: typeof misskey.noteVisibilities; + initialFiles?: misskey.entities.DriveFile[]; + initialLocalOnly?: boolean; + initialVisibleUsers?: misskey.entities.User[]; + initialNote?: misskey.entities.Note; + instant?: boolean; + fixed?: boolean; + autofocus?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +let modal = $ref<InstanceType<typeof MkModal>>(); +let form = $ref<InstanceType<typeof MkPostForm>>(); + +function onPosted() { + modal.close({ + useSendAnimation: true, + }); +} + +function onModalClosed() { + emit('closed'); +} </script> diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts index e2f514b7ca..83ec08543f 100644 --- a/packages/frontend/src/directives/click-anime.ts +++ b/packages/frontend/src/directives/click-anime.ts @@ -2,30 +2,32 @@ import { Directive } from 'vue'; import { defaultStore } from '@/store'; export default { - mounted(el, binding, vn) { - /* + mounted(el: HTMLElement, binding, vn) { if (!defaultStore.state.animation) return; - el.classList.add('_anime_bounce_standBy'); + const target = el.children[0]; + + if (target == null) return; + + target.classList.add('_anime_bounce_standBy'); el.addEventListener('mousedown', () => { - el.classList.add('_anime_bounce_standBy'); - el.classList.add('_anime_bounce_ready'); + target.classList.add('_anime_bounce_standBy'); + target.classList.add('_anime_bounce_ready'); - el.addEventListener('mouseleave', () => { - el.classList.remove('_anime_bounce_ready'); + target.addEventListener('mouseleave', () => { + target.classList.remove('_anime_bounce_ready'); }); }); el.addEventListener('click', () => { - el.classList.add('_anime_bounce'); + target.classList.add('_anime_bounce'); }); el.addEventListener('animationend', () => { - el.classList.remove('_anime_bounce_ready'); - el.classList.remove('_anime_bounce'); - el.classList.add('_anime_bounce_standBy'); + target.classList.remove('_anime_bounce_ready'); + target.classList.remove('_anime_bounce'); + target.classList.add('_anime_bounce_standBy'); }); - */ }, } as Directive;