Enhance(frontend): MFMの属性にオートコンプリートが利用できるように (#12803)

* MFMのパラメータでオートコンプリートできるように

* tweak conditions & refactor

* ファイル末尾の改行忘れ

* remove console.log & refactor

* 型付けに敗北

* fix

* update CHANGELOG.md

* tweak conditions

* CHANGELOGの様式ミス

* CHANGELOGを書く場所を間違えていたので修正

* move changelog

* move changelog

* typeof MFM_TAGS[number]

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* $[border.noclip ]対応

* Update const.ts

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
1Step621 2024-01-19 18:50:26 +09:00 committed by GitHub
parent b17eb8e537
commit 678dba9245
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 75 additions and 8 deletions

View file

@ -1,5 +1,5 @@
<!-- <!--
## 2023.x.x (unreleased) ## 202x.x.x (unreleased)
### General ### General
- -
@ -38,6 +38,7 @@
- Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように - Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように
- Enhance: Playの説明欄にMFMを使えるように - Enhance: Playの説明欄にMFMを使えるように
- Enhance: チャンネルノートの場合は詳細ページからその前後のノートを見れるように - Enhance: チャンネルノートの場合は詳細ページからその前後のノートを見れるように
- Enhance: MFMの属性でオートコンプリートが使用できるように #12735
- Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 - Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正

View file

@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>{{ tag }}</span> <span>{{ tag }}</span>
</li> </li>
</ol> </ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
</ol>
</div> </div>
</template> </template>
@ -51,7 +56,7 @@ import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js'; import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
type EmojiDef = { type EmojiDef = {
emoji: string; emoji: string;
@ -130,7 +135,7 @@ export default {
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps<{ const props = defineProps<{
type: string; type: string;
q: string | null; q: any;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
close: () => void; close: () => void;
x: number; x: number;
@ -151,6 +156,7 @@ const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]); const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]); const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]); const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1); const select = ref(-1);
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex('high');
@ -251,6 +257,13 @@ function exec() {
} }
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
} else if (props.type === 'mfmParam') {
if (props.q.params.at(-1) === '') {
mfmParams.value = MFM_PARAMS[props.q.tag] ?? [];
return;
}
mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? ''));
} }
} }

View file

@ -109,3 +109,27 @@ export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-foun
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg'; export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
tada: ['speed=', 'delay='],
jelly: ['speed=', 'delay='],
twitch: ['speed=', 'delay='],
shake: ['speed=', 'delay='],
spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'],
jump: ['speed=', 'delay='],
bounce: ['speed=', 'delay='],
flip: ['h', 'v'],
x2: [],
x3: [],
x4: [],
scale: ['x=', 'y='],
position: ['x=', 'y='],
fg: ['color='],
bg: ['color='],
border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
blur: [],
rainbow: ['speed=', 'delay='],
rotate: ['deg='],
ruby: [],
unixtime: [],
};

View file

@ -8,13 +8,13 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
export class Autocomplete { export class Autocomplete {
private suggestion: { private suggestion: {
x: Ref<number>; x: Ref<number>;
y: Ref<number>; y: Ref<number>;
q: Ref<string | null>; q: Ref<any>;
close: () => void; close: () => void;
} | null; } | null;
private textarea: HTMLInputElement | HTMLTextAreaElement; private textarea: HTMLInputElement | HTMLTextAreaElement;
@ -49,7 +49,7 @@ export class Autocomplete {
this.textarea = textarea; this.textarea = textarea;
this.textRef = textRef; this.textRef = textRef;
this.opening = false; this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag']; this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam'];
this.attach(); this.attach();
} }
@ -80,6 +80,7 @@ export class Autocomplete {
const hashtagIndex = text.lastIndexOf('#'); const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':'); const emojiIndex = text.lastIndexOf(':');
const mfmTagIndex = text.lastIndexOf('$'); const mfmTagIndex = text.lastIndexOf('$');
const mfmParamIndex = text.lastIndexOf('.');
const max = Math.max( const max = Math.max(
mentionIndex, mentionIndex,
@ -94,7 +95,8 @@ export class Autocomplete {
const isMention = mentionIndex !== -1; const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1; const isHashtag = hashtagIndex !== -1;
const isMfmTag = mfmTagIndex !== -1; const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
let opened = false; let opened = false;
@ -134,6 +136,17 @@ export class Autocomplete {
} }
} }
if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) {
const mfmParam = text.substring(mfmParamIndex + 1);
if (!mfmParam.includes(' ')) {
this.open('mfmParam', {
tag: text.substring(mfmTagIndex + 2, mfmParamIndex),
params: mfmParam.split(','),
});
opened = true;
}
}
if (!opened) { if (!opened) {
this.close(); this.close();
} }
@ -142,7 +155,7 @@ export class Autocomplete {
/** /**
* *
*/ */
private async open(type: string, q: string | null) { private async open(type: string, q: any) {
if (type !== this.currentType) { if (type !== this.currentType) {
this.close(); this.close();
} }
@ -280,6 +293,22 @@ export class Autocomplete {
const pos = trimmedBefore.length + (value.length + 3); const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos); this.textarea.setSelectionRange(pos, pos);
}); });
} else if (type === 'mfmParam') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf('.'));
const after = source.substring(caret);
// 挿入
this.text = `${trimmedBefore}.${value}${after}`;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + 1);
this.textarea.setSelectionRange(pos, pos);
});
} }
} }
} }