1 Feat, 1 Enhance

feat: 텍스트 장식(MFM, HTML, Markdown)을 보다 편하게 추가할 수 있음

enhance(frontend): HTML 태그 및 Markdown 태그가 자동 완성을 지원함
This commit is contained in:
NoriDev 2023-12-27 17:19:30 +09:00
parent 04e4347a5d
commit 4be7cf9d61
11 changed files with 239 additions and 6 deletions

View file

@ -36,6 +36,9 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023xx](CHANGE
- Feat: 아이콘 장식을 세부 조정할 수 있음 ([Secineralyr/misskey.dream@b3299181](https://github.com/Secineralyr/misskey.dream/commit/b329918194f1991c84633361d8a1319cf203641c), [Secineralyr/misskey.dream@1a9642bb](https://github.com/Secineralyr/misskey.dream/commit/1a9642bb9087a256522767e113c3bbfa87ec2e47))
- 위치, 크기, 불투명도를 추가로 조정할 수 있습니다.
- Feat: 노트를 클릭하여 자세히 볼 수 있음
- Feat: 텍스트 장식(MFM, HTML, Markdown)을 보다 편하게 추가할 수 있음
- 노트 작성 폼에서 텍스트 장식 버튼을 눌러 사용할 수 있음
- 텍스트를 선택한 상태에서도 적용 가능
- Revert: 사용자 통계 표시 기능 제거 ([MisskeyIO/misskey@114c7fe6](https://github.com/MisskeyIO/misskey/commit/114c7fe6b37dd6bddbcd9d92406f8b13bf688e8b))
### Client
@ -64,6 +67,8 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023xx](CHANGE
- 토큰을 생성할 때 토큰을 복사할 필요없이 '확인' 버튼을 누르면 자동으로 클립보드에 토큰이 복사됨
- 토큰을 삭제할 때 삭제 전 대화 상자가 표시됨
- Enhance: 링크 또는 내용을 복사할 때 토스트 알림 표시
- Enhance: HTML 태그 및 Markdown 태그가 자동 완성을 지원함
- `<`를 입력하면 ``<b>, ~~, <i>, <small>, <center>, <plain>, `, ```, \(\), \(\\ \) `` 태그를 자동으로 입력할 수 있음
- Fix: '모달 배경색 제거' 옵션이 이모지 피커에 반영되지 않음
- Fix: 열람 주의로 설정된 노트의 리액션이 '더 보기'를 눌러야 표시됨
- Fix: 채널 이름이 긴 경우 게시 양식 표시가 깨지는 문제 (misskey-dev/misskey#12524)

View file

@ -1,5 +1,6 @@
---
_lang_: "English"
showTextDecoration: "Show text decorations"
copiedLink: "The link has been copied!"
copiedContent: "Contents copied!"
copied: "Copied!"

1
locales/index.d.ts vendored
View file

@ -3,6 +3,7 @@
// Do not edit this file directly.
export interface Locale {
"_lang_": string;
"showTextDecoration": string;
"copiedLink": string;
"copiedContent": string;
"copied": string;

View file

@ -1,5 +1,6 @@
_lang_: "日本語"
showTextDecoration: "文字装飾を追加"
copiedLink: "リンクをコピーしました!"
copiedContent: "内容をコピーしました!"
copied: "コピーしました!"

View file

@ -1,5 +1,6 @@
---
_lang_: "한국어"
showTextDecoration: "텍스트 장식 표시"
copiedLink: "링크를 복사했어요!"
copiedContent: "내용을 복사했어요!"
copied: "복사했어요!"

View file

@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>{{ tag }}</span>
</li>
</ol>
<ol v-else-if="htmlTags.length > 0" ref="suggests" :class="$style.list">
<li v-for="tag in htmlTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
<span>{{ tag }}</span>
</li>
</ol>
</div>
</template>
@ -50,7 +55,7 @@ import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js';
import { MFM_TAGS, HTML_TAGS } from '@/const.js';
type EmojiDef = {
emoji: string;
@ -151,6 +156,7 @@ const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]);
const htmlTags = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
@ -251,6 +257,13 @@ function exec() {
}
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
} else if (props.type === 'htmlTag') {
if (!props.q || props.q === '') {
htmlTags.value = HTML_TAGS;
return;
}
htmlTags.value = HTML_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
}
}

View file

@ -90,6 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip="i18n.ts.disableRightClick" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: disableRightClick }]" @click="disableRightClick = !disableRightClick"><i class="ti ti-mouse-off"></i></button>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-tooltip="i18n.ts.showTextDecoration" :class="['_button', $style.footerButton]" @click="insertFunction"><i class="ti ti-palette"></i></button>
</div>
<div :class="$style.footerRight">
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="$style.footerButton" @click="showPreviewMenu"><i class="ti ti-eye"></i></button>
@ -133,6 +134,7 @@ import { claimAchievement } from '@/scripts/achievements.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { vibrate } from '@/scripts/vibrate.js';
import * as sound from '@/scripts/sound.js';
import { functionPicker } from '@/scripts/function-picker.js';
const modal = inject('modal');
@ -929,6 +931,14 @@ async function insertEmoji(ev: MouseEvent) {
);
}
async function insertFunction(ev: MouseEvent) {
functionPicker(
ev.currentTarget ?? ev.target,
textareaEl.value,
text,
);
}
function showActions(ev) {
os.popupMenu(postFormActions.map(action => ({
text: action.title,
@ -1331,9 +1341,17 @@ defineExpose({
.footerLeft {
flex: 1;
display: grid;
grid-auto-flow: row;
grid-auto-flow: column;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
grid-auto-rows: 40px;
overflow: scroll;
max-width: 80%;
-ms-overflow-style: none;
scrollbar-width: none;
.scroll::-webkit-scrollbar {
display: none;
}
}
.footerRight {

View file

@ -105,6 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip="i18n.ts.disableRightClick" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: disableRightClick }]" @click="disableRightClick = !disableRightClick"><i class="ti ti-mouse-off"></i></button>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-tooltip="i18n.ts.showTextDecoration" :class="['_button', $style.footerButton]" @click="insertFunction"><i class="ti ti-palette"></i></button>
</div>
<div :class="$style.footerRight">
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="$style.footerButton" @click="showPreviewMenu"><i class="ti ti-eye"></i></button>
@ -150,6 +151,7 @@ import { emojiPicker } from '@/scripts/emoji-picker.js';
import { vibrate } from '@/scripts/vibrate.js';
import XSigninDialog from '@/components/MkSigninDialog.vue';
import * as sound from '@/scripts/sound.js';
import { functionPicker } from '@/scripts/function-picker.js';
const modal = inject('modal');
@ -923,6 +925,14 @@ async function insertEmoji(ev: MouseEvent) {
);
}
async function insertFunction(ev: MouseEvent) {
functionPicker(
ev.currentTarget ?? ev.target,
textareaEl.value,
text,
);
}
function showActions(ev) {
os.popupMenu(postFormActions.map(action => ({
text: action.title,
@ -1380,9 +1390,17 @@ defineExpose({
.footerLeft {
flex: 1;
display: grid;
grid-auto-flow: row;
grid-auto-flow: column;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
grid-auto-rows: 40px;
overflow: scroll;
max-width: 80%;
-ms-overflow-style: none;
scrollbar-width: none;
.scroll::-webkit-scrollbar {
display: none;
}
}
.footerRight {

View file

@ -111,3 +111,4 @@ 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 MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'fade', 'rotate', 'ruby', 'unixtime'];
export const HTML_TAGS = ['bold', 'strike', 'italic', 'small', 'center', 'plain', 'inlinecode', 'blockcode', 'mathinline', 'mathblock'];

View file

@ -8,7 +8,7 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'htmlTag';
export class Autocomplete {
private suggestion: {
@ -49,7 +49,7 @@ export class Autocomplete {
this.textarea = textarea;
this.textRef = textRef;
this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'htmlTag'];
this.attach();
}
@ -80,12 +80,14 @@ export class Autocomplete {
const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':');
const mfmTagIndex = text.lastIndexOf('$');
const htmlTagIndex = text.lastIndexOf('<');
const max = Math.max(
mentionIndex,
hashtagIndex,
emojiIndex,
mfmTagIndex);
mfmTagIndex,
htmlTagIndex);
if (max === -1) {
this.close();
@ -95,6 +97,7 @@ export class Autocomplete {
const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
const isMfmTag = mfmTagIndex !== -1;
const isHtmlTag = htmlTagIndex !== -1;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
let opened = false;
@ -134,6 +137,14 @@ export class Autocomplete {
}
}
if (isHtmlTag && !opened && this.onlyType.includes('htmlTag')) {
const htmlTag = text.substring(htmlTagIndex + 1);
if (!htmlTag.includes(' ')) {
this.open('htmlTag', htmlTag.replace('<', ''));
opened = true;
}
}
if (!opened) {
this.close();
}
@ -280,6 +291,49 @@ export class Autocomplete {
const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos);
});
} else if (type === 'htmlTag') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf('<'));
const after = source.substring(caret);
// 挿入
if (value === 'bold') this.text = `${trimmedBefore}<b>${after}</b>`;
if (value === 'strike') this.text = `${trimmedBefore}~~${after}~~`;
if (value === 'italic') this.text = `${trimmedBefore}<i>${after}</i>`;
if (value === 'small') this.text = `${trimmedBefore}<small>${after}</small>`;
if (value === 'center') this.text = `${trimmedBefore}<center>${after}</center>`;
if (value === 'plain') this.text = `${trimmedBefore}<plain>${after}</plain>`;
if (value === 'inlinecode') this.text = trimmedBefore + '`' + after + '`';
if (value === 'blockcode') this.text = trimmedBefore + '```' + '\n' + after + '\n' + '```';
if (value === 'mathinline') this.text = trimmedBefore + '\\(' + after + '\\)';
if (value === 'mathblock') this.text = trimmedBefore + '\\(' + after + '\\\\ \\)';
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + (
value.includes('bold')
? -1
: value.includes('strike')
? -4
: value.includes('italic')
? -3
: value.includes('small') || value.includes('center') || value.includes('plain')
? 2
: value.includes('inlinecode')
? -9
: value.includes('blockcode')
? -5
: value.includes('mathinline')
? -8
: value.includes('mathblock')
? -7
: 3
));
this.textarea.setSelectionRange(pos, pos);
});
}
}
}

View file

@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { nextTick, Ref } from 'vue';
import * as os from '@/os.js';
import { MFM_TAGS, HTML_TAGS } from '@/const.js';
/**
* MFMの装飾のリストを表示する
*/
export function functionPicker(src: any, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
return new Promise((res, rej) => {
os.popupMenu([{
type: 'label',
}, ...getHTMLFunctionList(textArea, textRef)
, { type: 'divider' }
, ...getMFMFunctionList(textArea, textRef)], src);
});
}
function getHTMLFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) : object[] {
const ret: object[] = [];
HTML_TAGS.forEach(tag => {
ret.push({
text: tag,
icon: tag === 'bold' ? 'ti ti-bold' : tag === 'strike' ? 'ti ti-strikethrough' : tag === 'italic' ? 'ti ti-italic' : tag === 'small' ? 'ti ti-text-decrease' : tag === 'center' ? 'ti ti-align-center' : tag === 'plain' ? 'ti ti-clear-formatting' : tag === 'inlinecode' ? 'ti ti-code' : tag === 'blockcode' ? 'ti ti-script' : tag === 'mathinline' ? 'ti ti-math' : tag === 'mathblock' ? 'ti ti-math-function' : 'ti ti-icons',
action: () => add(textArea, textRef, tag),
});
});
return ret;
}
function getMFMFunctionList(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を追加
textRef.value = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
} else {
// 選択範囲を囲むようにFunctionを追加
textRef.value = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
}
}
});
HTML_TAGS.forEach(tag => {
if (type === tag) {
if (caretStart === caretEnd) {
// 単純にFunctionを追加
if (tag === 'bold') textRef.value = `${textRef.value.substring(0, caretStart)}<b></b>${textRef.value.substring(caretEnd)}`;
if (tag === 'strike') textRef.value = `${textRef.value.substring(0, caretStart)}~~~~${textRef.value.substring(caretEnd)}`;
if (tag === 'italic') textRef.value = `${textRef.value.substring(0, caretStart)}<i></i>${textRef.value.substring(caretEnd)}`;
if (tag === 'small') textRef.value = `${textRef.value.substring(0, caretStart)}<small></small>${textRef.value.substring(caretEnd)}`;
if (tag === 'center') textRef.value = `${textRef.value.substring(0, caretStart)}<center></center>${textRef.value.substring(caretEnd)}`;
if (tag === 'plain') textRef.value = `${textRef.value.substring(0, caretStart)}<plain></plain>${textRef.value.substring(caretEnd)}`;
if (tag === 'inlinecode') textRef.value = textRef.value.substring(0, caretStart) + '``' + textRef.value.substring(caretEnd);
if (tag === 'inlineblock') textRef.value = textRef.value.substring(0, caretStart) + '```' + '\n' + '\n' + '```' + textRef.value.substring(caretEnd);
if (tag === 'mathinline') textRef.value = textRef.value.substring(0, caretStart) + '\\(\\)' + textRef.value.substring(caretEnd);
if (tag === 'mathblock') textRef.value = textRef.value.substring(0, caretStart) + '\\(\\\\ \\)' + textRef.value.substring(caretEnd);
} else {
// 選択範囲を囲むようにFunctionを追加
if (tag === 'bold') textRef.value = `${textRef.value.substring(0, caretStart)}<b>${textRef.value.substring(caretStart, caretEnd)}</b>${textRef.value.substring(caretEnd)}`;
if (tag === 'strike') textRef.value = `${textRef.value.substring(0, caretStart)}~~${textRef.value.substring(caretStart, caretEnd)}~~${textRef.value.substring(caretEnd)}`;
if (tag === 'italic') textRef.value = `${textRef.value.substring(0, caretStart)}<i>${textRef.value.substring(caretStart, caretEnd)}</i>${textRef.value.substring(caretEnd)}`;
if (tag === 'small') textRef.value = `${textRef.value.substring(0, caretStart)}<small>${textRef.value.substring(caretStart, caretEnd)}</small>${textRef.value.substring(caretEnd)}`;
if (tag === 'center') textRef.value = `${textRef.value.substring(0, caretStart)}<center>${textRef.value.substring(caretStart, caretEnd)}</center>${textRef.value.substring(caretEnd)}`;
if (tag === 'plain') textRef.value = `${textRef.value.substring(0, caretStart)}<plain>${textRef.value.substring(caretStart, caretEnd)}</plain>${textRef.value.substring(caretEnd)}`;
if (tag === 'inlinecode') textRef.value = textRef.value.substring(0, caretStart) + '`' + textRef.value.substring(caretStart, caretEnd) + '`' + textRef.value.substring(caretEnd);
if (tag === 'inlineblock') textRef.value = textRef.value.substring(0, caretStart) + '```' + '\n' + textRef.value.substring(caretStart, caretEnd) + '\n' + '```' + textRef.value.substring(caretEnd);
if (tag === 'mathinline') textRef.value = textRef.value.substring(0, caretStart) + '\\(' + textRef.value.substring(caretStart, caretEnd) + '\\)' + textRef.value.substring(caretEnd);
if (tag === 'mathblock') textRef.value = textRef.value.substring(0, caretStart) + '\\(' + textRef.value.substring(caretStart, caretEnd) + '\\\\ \\)' + textRef.value.substring(caretEnd);
}
}
});
const caretArray = (
type.includes('bold')
? -1
: type.includes('strike')
? -4
: type.includes('italic')
? -3
: type.includes('small') || type.includes('center') || type.includes('plain')
? 2
: type.includes('inlinecode')
? -9
: type.includes('inlineblock')
? -5
: type.includes('mathinline')
? -8
: type.includes('mathblock')
? -7
: 3
);
const nextCaretStart: number = caretStart + caretArray + type.length;
const nextCaretEnd: number = caretEnd + caretArray + type.length;
// キャレットを戻す
nextTick(() => {
textArea.focus();
textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
});
}