From b0301dd2fbd48ada9c48be398e9b41865e6fef1f Mon Sep 17 00:00:00 2001
From: YAVIIGI <118232419+YAVIIGI@users.noreply.github.com>
Date: Wed, 27 Dec 2023 20:57:43 +0900
Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=8A=95=E7=A8=BF=E3=82=A6?=
 =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=89=E3=82=A6=E3=81=ABMFM=E8=A6=81?=
 =?UTF-8?q?=E7=B4=A0=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B=E3=83=9C?=
 =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=81=AE=E8=BF=BD=E5=8A=A0=20(#12788)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* functionPicker の追加

* Update CHANGELOG.md

* fix lint errors

* Add addMfmFunction

* add enableQuickAddMfmFunction setting

* Update CHANGELOG.md

issue 番号を追加

* Update index.d.ts

* change 'functionPicker' to 'mfmFunctionPicker'

* Change indent from 4 space to 1 tab

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 CHANGELOG.md                                  |  3 +-
 locales/index.d.ts                            |  2 +
 locales/ja-JP.yml                             |  2 +
 .../frontend/src/components/MkPostForm.vue    | 12 ++++
 .../frontend/src/pages/settings/general.vue   |  2 +
 .../src/scripts/mfm-function-picker.ts        | 61 +++++++++++++++++++
 packages/frontend/src/store.ts                |  4 ++
 7 files changed, 85 insertions(+), 1 deletion(-)
 create mode 100644 packages/frontend/src/scripts/mfm-function-picker.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f65f62788..199a420f7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,8 +19,9 @@
 - Fix: 自分のdirect noteがuser list timelineに追加されない
 
 ### Client
-- Fix: 一部のモデログ(logYellowでの表示対象)について、表示の色が変わらない問題を修正
 - Feat: AiScript専用のMFM構文`$[clickable.ev=EVENTNAME ...]`を追加。`Mk:C:mfm`のオプション`onClickEv`に関数を渡すと、クリック時に`EVENTNAME`を引数にして呼び出す
+- Enhance: MFM入力補助ボタンを投稿フォームに表示できるように #12787
+- Fix: 一部のモデログ(logYellowでの表示対象)について、表示の色が変わらない問題を修正
 - Fix: `fg`/`bg`MFMに長い単語を指定すると、オーバーフローされずはみ出る問題を修正
 
 ### Server
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 157b8f44d5..dd2f34a69a 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1220,6 +1220,8 @@ export interface Locale {
     "overwriteContentConfirm": string;
     "seasonalScreenEffect": string;
     "decorate": string;
+    "addMfmFunction": string;
+    "enableQuickAddMfmFunction": string;
     "_announcement": {
         "forExistingUsers": string;
         "forExistingUsersDescription": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 7cb678b5f3..b632fbad63 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1217,6 +1217,8 @@ remainingN: "残り: {n}"
 overwriteContentConfirm: "現在の内容に上書きされますがよろしいですか?"
 seasonalScreenEffect: "季節に応じた画面の演出"
 decorate: "デコる"
+addMfmFunction: "装飾を追加"
+enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
 
 _announcement:
   forExistingUsers: "既存ユーザーのみ"
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 8838da15a9..aa37cef6c2 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -86,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ph-hash ph-bold ph-lg"></i></button>
 			<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ph-plug ph-bold ph-lg"></i></button>
 			<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ph-smiley ph-bold ph-lg"></i></button>
+			<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ph-palette ph-bold ph-lg"></i></button>
 		</div>
 		<div :class="$style.footerRight">
 			<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ph-eye ph-bold ph-lg"></i></button>
@@ -127,6 +128,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { miLocalStorage } from '@/local-storage.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { emojiPicker } from '@/scripts/emoji-picker.js';
+import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
 
 const modal = inject('modal');
 
@@ -184,6 +186,8 @@ const poll = ref<{
 const useCw = ref<boolean>(!!props.initialCw);
 const showPreview = ref(defaultStore.state.showPreview);
 watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
+const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
+watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value));
 const cw = ref<string | null>(props.initialCw ?? null);
 const localOnly = ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
 const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]);
@@ -870,6 +874,14 @@ async function insertEmoji(ev: MouseEvent) {
 	);
 }
 
+async function insertMfmFunction(ev: MouseEvent) {
+	mfmFunctionPicker(
+		ev.currentTarget ?? ev.target,
+		textareaEl.value,
+		text,
+	);
+}
+
 function showActions(ev) {
 	os.popupMenu(postFormActions.map(action => ({
 		text: action.title,
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 0839a65ebb..8eacdd32e6 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -52,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkSwitch v-model="expandLongNote">Always expand long notes</MkSwitch>
 				<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
 				<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
+				<MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch>
 				<MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
 				<MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
 				<MkSwitch v-model="showTickerOnReplies">Show instance ticker on replies</MkSwitch>
@@ -296,6 +297,7 @@ const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
 const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
 const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm'));
 const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm'));
+const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction'));
 const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
 const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
 const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
new file mode 100644
index 0000000000..465926fe04
--- /dev/null
+++ b/packages/frontend/src/scripts/mfm-function-picker.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Ref, nextTick } from 'vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { MFM_TAGS } from '@/const.js';
+
+/**
+ * MFMの装飾のリストを表示する
+ */
+export function mfmFunctionPicker(src: any, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
+	return new Promise((res, rej) => {
+		os.popupMenu([{
+			text: i18n.ts.addMfmFunction,
+			type: 'label',
+		}, ...getFunctionList(textArea, textRef)], src);
+	});
+}
+
+function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) : object[] {
+	const ret: object[] = [];
+	MFM_TAGS.forEach(tag => {
+		ret.push({
+			text: tag,
+			icon: 'ti ti-icons',
+			action: () => add(textArea, textRef, tag),
+		});
+	});
+	return ret;
+}
+
+function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) {
+	const caretStart: number = textArea.selectionStart as number;
+	const caretEnd: number = textArea.selectionEnd as number;
+
+	MFM_TAGS.forEach(tag => {
+		if (type === tag) {
+			if (caretStart === caretEnd) {
+				// 単純にFunctionを追加
+				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
+				textRef.value = trimmedText;
+			} else {
+				// 選択範囲を囲むようにFunctionを追加
+				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
+				textRef.value = trimmedText;
+			}
+		}
+	});
+
+	const nextCaretStart: number = caretStart + 3 + type.length;
+	const nextCaretEnd: number = caretEnd + 3 + type.length;
+
+	// キャレットを戻す
+	nextTick(() => {
+		textArea.focus();
+		textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
+	});
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index c86c3d01ad..18cfad2102 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -239,6 +239,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: true,
 	},
+	enableQuickAddMfmFunction: {
+		where: 'device',
+		default: false,
+	},
 	loadRawImages: {
 		where: 'device',
 		default: false,
-- 
GitLab