diff options
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 56 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSpot.vue | 161 | ||||
| -rw-r--r-- | packages/frontend/src/tips.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/utility/tour.ts | 49 |
4 files changed, 264 insertions, 3 deletions
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 664ff2d469..140b4aa887 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.header"> <div :class="$style.headerLeft"> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> - <button v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu"> + <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu"> <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/> </button> </div> @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else><i class="ti ti-rocket-off"></i></span> </button> <button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button> - <button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> + <button ref="submitButtonEl" v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> <div :class="$style.submitInner"> <template v-if="posted"></template> <template v-else-if="posting"><MkEllipsis/></template> @@ -60,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> </div> + <MkInfo v-if="!store.r.tips.value.postForm" :class="$style.showHowToUse"><button class="_textButton" @click="showTour">{{ i18n.ts._postForm.showHowToUse }}</button></MkInfo> <MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt"> <I18n :src="i18n.ts.scheduleToPostOnX" tag="span"> <template #x> @@ -89,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> </div> - <footer :class="$style.footer"> + <footer ref="footerEl" :class="$style.footer"> <div :class="$style.footerLeft"> <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button> <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button> @@ -153,6 +154,8 @@ import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; import { useUploader } from '@/composables/use-uploader.js'; +import { startTour } from '@/utility/tour.js'; +import { closeTip } from '@/tips.js'; const $i = ensureSignin(); @@ -186,6 +189,9 @@ const cwInputEl = useTemplateRef('cwInputEl'); const hashtagsInputEl = useTemplateRef('hashtagsInputEl'); const visibilityButton = useTemplateRef('visibilityButton'); const otherSettingsButton = useTemplateRef('otherSettingsButton'); +const accountMenuEl = useTemplateRef('accountMenuEl'); +const footerEl = useTemplateRef('footerEl'); +const submitButtonEl = useTemplateRef('submitButtonEl'); const posting = ref(false); const posted = ref(false); @@ -1285,6 +1291,45 @@ function cancelSchedule() { scheduledAt.value = null; } +function showTour() { + if (textareaEl.value == null || + footerEl.value == null || + accountMenuEl.value == null || + visibilityButton.value == null || + otherSettingsButton.value == null || + submitButtonEl.value == null) { + return; + } + + startTour([{ + element: textareaEl.value, + title: i18n.ts._postForm._howToUse.content_title, + description: i18n.ts._postForm._howToUse.content_description, + }, { + element: footerEl.value, + title: i18n.ts._postForm._howToUse.toolbar_title, + description: i18n.ts._postForm._howToUse.toolbar_description, + }, { + element: accountMenuEl.value, + title: i18n.ts._postForm._howToUse.account_title, + description: i18n.ts._postForm._howToUse.account_description, + }, { + element: visibilityButton.value, + title: i18n.ts._postForm._howToUse.visibility_title, + description: i18n.ts._postForm._howToUse.visibility_description, + }, { + element: otherSettingsButton.value, + title: i18n.ts._postForm._howToUse.menu_title, + description: i18n.ts._postForm._howToUse.menu_description, + }, { + element: submitButtonEl.value, + title: i18n.ts._postForm._howToUse.submit_title, + description: i18n.ts._postForm._howToUse.submit_description, + }]).then(() => { + closeTip('postForm'); + }); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1414,6 +1459,7 @@ defineExpose({ } .avatar { + display: block; width: 28px; height: 28px; margin: auto; @@ -1575,6 +1621,10 @@ html[data-color-scheme=light] .preview { margin: 0 20px 16px 20px; } +.showHowToUse { + margin: 0 20px 16px 20px; +} + .cw, .hashtags, .text { diff --git a/packages/frontend/src/components/MkSpot.vue b/packages/frontend/src/components/MkSpot.vue new file mode 100644 index 0000000000..07699644aa --- /dev/null +++ b/packages/frontend/src/components/MkSpot.vue @@ -0,0 +1,161 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="$style.root" :style="{ zIndex }"> + <div :class="[$style.bg]"></div> + <div ref="spotEl" :class="$style.spot"></div> + <div ref="bodyEl" :class="$style.body" class="_panel _shadow"> + <div class="_gaps_s"> + <div><b>{{ title }}</b></div> + <div>{{ description }}</div> + <div class="_buttons"> + <MkButton v-if="hasPrev" small @click="prev"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton> + <MkButton v-if="hasNext" small primary @click="next">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton> + <MkButton v-else small primary @click="next">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; +import { calcPopupPosition } from '@/utility/popup-position.js'; +import * as os from '@/os.js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; + +const props = withDefaults(defineProps<{ + title: string; + description: string; + anchorElement?: HTMLElement; + x?: number; + y?: number; + direction?: 'top' | 'bottom' | 'right' | 'left'; + hasPrev: boolean; + hasNext: boolean; +}>(), { + direction: 'top', +}); + +const emit = defineEmits<{ + (prev: 'prev'): void; + (next: 'next'): void; +}>(); + +function prev() { + emit('prev'); +} + +function next() { + emit('next'); +} + +const rootEl = useTemplateRef('rootEl'); +const bodyEl = useTemplateRef('bodyEl'); +const spotEl = useTemplateRef('spotEl'); +const zIndex = os.claimZIndex('high'); +const spotX = ref(0); +const spotY = ref(0); +const spotWidth = ref(0); +const spotHeight = ref(0); + +function setPosition() { + if (spotEl.value == null) return; + if (bodyEl.value == null) return; + if (props.anchorElement == null) return; + + const rect = props.anchorElement.getBoundingClientRect(); + spotX.value = rect.left; + spotY.value = rect.top; + spotWidth.value = rect.width; + spotHeight.value = rect.height; + + const data = calcPopupPosition(bodyEl.value, { + anchorElement: props.anchorElement, + direction: props.direction, + align: 'center', + innerMargin: 16, + x: props.x, + y: props.y, + }); + + bodyEl.value.style.transformOrigin = data.transformOrigin; + bodyEl.value.style.left = data.left + 'px'; + bodyEl.value.style.top = data.top + 'px'; +} + +let loopHandler; + +onMounted(() => { + nextTick(() => { + setPosition(); + + const loop = () => { + setPosition(); + loopHandler = window.requestAnimationFrame(loop); + }; + + loop(); + }); +}); + +onUnmounted(() => { + window.cancelAnimationFrame(loopHandler); +}); +</script> + +<style lang="scss" module> +.root { + position: absolute; +} + +.bg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.spot { + --x: v-bind("spotX + 'px'"); + --y: v-bind("spotY + 'px'"); + --width: v-bind("spotWidth + 'px'"); + --height: v-bind("spotHeight + 'px'"); + --padding: 8px; + position: absolute; + left: calc(var(--x) - var(--padding)); + top: calc(var(--y) - var(--padding)); + width: calc(var(--width) + var(--padding) * 2); + height: calc(var(--height) + var(--padding) * 2); + box-sizing: border-box; + border: 1px solid transparent; + border-radius: 8px; + box-shadow: 0 0 0 9999px #000a; + transition: left 0.2s ease-out, top 0.2s ease-out, width 0.2s ease-out, height 0.2s ease-out; + animation: blink 1s infinite; +} + +.body { + position: absolute; + padding: 16px 20px; + box-sizing: border-box; + width: max-content; + max-width: min(500px, 100vw); +} + +@keyframes blink { + 0%, 100% { + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); + } + 50% { + background: transparent; + border: 1px solid transparent; + } +} +</style> diff --git a/packages/frontend/src/tips.ts b/packages/frontend/src/tips.ts index 7218f4c19a..8a58e2aa63 100644 --- a/packages/frontend/src/tips.ts +++ b/packages/frontend/src/tips.ts @@ -11,6 +11,7 @@ export const TIPS = [ 'postFormUploader', 'clips', 'userLists', + 'postForm', 'tl.home', 'tl.local', 'tl.social', diff --git a/packages/frontend/src/utility/tour.ts b/packages/frontend/src/utility/tour.ts new file mode 100644 index 0000000000..8ab3474ddc --- /dev/null +++ b/packages/frontend/src/utility/tour.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, ref, shallowRef, watch } from 'vue'; +import * as os from '@/os.js'; + +type TourStep = { + title: string; + description: string; + element: HTMLElement; +}; + +export function startTour(steps: TourStep[]) { + return new Promise<void>(async (resolve) => { + const currentStepIndex = ref(0); + const titleRef = ref(steps[0].title); + const descriptionRef = ref(steps[0].description); + const anchorElementRef = shallowRef<HTMLElement>(steps[0].element); + + watch(currentStepIndex, (newIndex) => { + const step = steps[newIndex]; + titleRef.value = step.title; + descriptionRef.value = step.description; + anchorElementRef.value = step.element; + }); + + const { dispose } = os.popup(await import('@/components/MkSpot.vue').then(x => x.default), { + title: titleRef, + description: descriptionRef, + anchorElement: anchorElementRef, + hasPrev: computed(() => currentStepIndex.value > 0), + hasNext: computed(() => currentStepIndex.value < steps.length - 1), + }, { + next: () => { + if (currentStepIndex.value >= steps.length - 1) { + dispose(); + resolve(); + return; + } + currentStepIndex.value++; + }, + prev: () => { + currentStepIndex.value--; + }, + }); + }); +} |