summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--locales/index.d.ts54
-rw-r--r--locales/ja-JP.yml14
-rw-r--r--packages/frontend/src/components/MkPostForm.vue56
-rw-r--r--packages/frontend/src/components/MkSpot.vue161
-rw-r--r--packages/frontend/src/tips.ts1
-rw-r--r--packages/frontend/src/utility/tour.ts49
7 files changed, 333 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d32ff1ec42..1ea6e2efd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
- Enhance: プロフィールへのリンクをユーザーポップアップのアバターに追加
- Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加
- Enhance: プッシュ通知を行うための権限確認をより確実に行うように
+- Enhance: 投稿フォームのチュートリアルを追加
- Fix: 紙吹雪エフェクトがアニメーション設定を考慮せず常に表示される問題を修正
- Fix: ナビゲーションバーのリアルタイムモード切替ボタンの状態をよりわかりやすく表示するように
- Fix: ページのタイトルが長いとき、はみ出る問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index e4ebfedd3d..0d0c1cfc53 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10030,6 +10030,60 @@ export interface Locale extends ILocale {
* チャンネルに投稿...
*/
"channelPlaceholder": string;
+ /**
+ * フォームの説明を表示
+ */
+ "showHowToUse": string;
+ "_howToUse": {
+ /**
+ * 本文
+ */
+ "content_title": string;
+ /**
+ * 投稿する内容を入力します。
+ */
+ "content_description": string;
+ /**
+ * ツールバー
+ */
+ "toolbar_title": string;
+ /**
+ * ファイルやアンケートの添付、注釈やハッシュタグの設定、絵文字やメンションの挿入などが行えます。
+ */
+ "toolbar_description": string;
+ /**
+ * アカウントメニュー
+ */
+ "account_title": string;
+ /**
+ * 投稿するアカウントを切り替えたり、アカウントに保存した下書き・予約投稿を一覧できます。
+ */
+ "account_description": string;
+ /**
+ * 公開範囲
+ */
+ "visibility_title": string;
+ /**
+ * ノートを公開する範囲の設定が行えます。
+ */
+ "visibility_description": string;
+ /**
+ * メニュー
+ */
+ "menu_title": string;
+ /**
+ * 下書きへの保存、投稿の予約、リアクションの設定など、その他のアクションが行えます。
+ */
+ "menu_description": string;
+ /**
+ * 投稿ボタン
+ */
+ "submit_title": string;
+ /**
+ * ノートを投稿します。Ctrl + Enter / Cmd + Enter でも投稿できます。
+ */
+ "submit_description": string;
+ };
"_placeholders": {
/**
* いまどうしてる?
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d0485af208..e40c083cff 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2641,6 +2641,20 @@ _postForm:
replyPlaceholder: "このノートに返信..."
quotePlaceholder: "このノートを引用..."
channelPlaceholder: "チャンネルに投稿..."
+ showHowToUse: "フォームの説明を表示"
+ _howToUse:
+ content_title: "本文"
+ content_description: "投稿する内容を入力します。"
+ toolbar_title: "ツールバー"
+ toolbar_description: "ファイルやアンケートの添付、注釈やハッシュタグの設定、絵文字やメンションの挿入などが行えます。"
+ account_title: "アカウントメニュー"
+ account_description: "投稿するアカウントを切り替えたり、アカウントに保存した下書き・予約投稿を一覧できます。"
+ visibility_title: "公開範囲"
+ visibility_description: "ノートを公開する範囲の設定が行えます。"
+ menu_title: "メニュー"
+ menu_description: "下書きへの保存、投稿の予約、リアクションの設定など、その他のアクションが行えます。"
+ submit_title: "投稿ボタン"
+ submit_description: "ノートを投稿します。Ctrl + Enter / Cmd + Enter でも投稿できます。"
_placeholders:
a: "いまどうしてる?"
b: "何かありましたか?"
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--;
+ },
+ });
+ });
+}