From 4be7cf9d6178b0a78c89f62c0ab0675cac8ff9fc Mon Sep 17 00:00:00 2001 From: NoriDev Date: Wed, 27 Dec 2023 17:19:30 +0900 Subject: [PATCH] 1 Feat, 1 Enhance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 텍스트 장식(MFM, HTML, Markdown)을 보다 편하게 추가할 수 있음 enhance(frontend): HTML 태그 및 Markdown 태그가 자동 완성을 지원함 --- CHANGELOG_CHERRYPICK.md | 5 + locales/en-US.yml | 1 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + locales/ko-KR.yml | 1 + .../src/components/MkAutocomplete.vue | 15 ++- .../frontend/src/components/MkPostForm.vue | 20 ++- .../src/components/MkPostFormSimple.vue | 20 ++- packages/frontend/src/const.ts | 1 + packages/frontend/src/scripts/autocomplete.ts | 60 ++++++++- .../frontend/src/scripts/function-picker.ts | 120 ++++++++++++++++++ 11 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/src/scripts/function-picker.ts diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index d4d241a23f..dc50a14a05 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -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 태그가 자동 완성을 지원함 + - `<`를 입력하면 ``, ~~, , ,
, , `, ```, \(\), \(\\ \) `` 태그를 자동으로 입력할 수 있음 - Fix: '모달 배경색 제거' 옵션이 이모지 피커에 반영되지 않음 - Fix: 열람 주의로 설정된 노트의 리액션이 '더 보기'를 눌러야 표시됨 - Fix: 채널 이름이 긴 경우 게시 양식 표시가 깨지는 문제 (misskey-dev/misskey#12524) diff --git a/locales/en-US.yml b/locales/en-US.yml index f5c6254a92..c318a5c173 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,6 @@ --- _lang_: "English" +showTextDecoration: "Show text decorations" copiedLink: "The link has been copied!" copiedContent: "Contents copied!" copied: "Copied!" diff --git a/locales/index.d.ts b/locales/index.d.ts index 02740a6458..6fce5f1507 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3,6 +3,7 @@ // Do not edit this file directly. export interface Locale { "_lang_": string; + "showTextDecoration": string; "copiedLink": string; "copiedContent": string; "copied": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a69681d4fb..41a7859859 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,6 @@ _lang_: "日本語" +showTextDecoration: "文字装飾を追加" copiedLink: "リンクをコピーしました!" copiedContent: "内容をコピーしました!" copied: "コピーしました!" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 7f5bef4d8f..be81026e34 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,6 @@ --- _lang_: "한국어" +showTextDecoration: "텍스트 장식 표시" copiedLink: "링크를 복사했어요!" copiedContent: "내용을 복사했어요!" copied: "복사했어요!" diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index c7d6f402e8..cf545ab4d7 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ tag }} +
    +
  1. + {{ tag }} +
  2. +
@@ -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([]); const emojis = ref<(EmojiDef)[]>([]); const items = ref([]); const mfmTags = ref([]); +const htmlTags = ref([]); 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 ?? '')); } } diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index d55e02af40..a11829b04b 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -90,6 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
@@ -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 { diff --git a/packages/frontend/src/components/MkPostFormSimple.vue b/packages/frontend/src/components/MkPostFormSimple.vue index f9013f0539..8f0ee2b585 100644 --- a/packages/frontend/src/components/MkPostFormSimple.vue +++ b/packages/frontend/src/components/MkPostFormSimple.vue @@ -105,6 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
@@ -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 { diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 8724e18fd3..5acb9fdd1d 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -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']; diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index bfd455ae38..50705f9fae 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -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}${after}`; + if (value === 'strike') this.text = `${trimmedBefore}~~${after}~~`; + if (value === 'italic') this.text = `${trimmedBefore}${after}`; + if (value === 'small') this.text = `${trimmedBefore}${after}`; + if (value === 'center') this.text = `${trimmedBefore}
${after}
`; + if (value === 'plain') this.text = `${trimmedBefore}${after}`; + 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); + }); } } } diff --git a/packages/frontend/src/scripts/function-picker.ts b/packages/frontend/src/scripts/function-picker.ts new file mode 100644 index 0000000000..2bdcaf4808 --- /dev/null +++ b/packages/frontend/src/scripts/function-picker.ts @@ -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) { + 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) : 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) : 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, 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)}${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)}${textRef.value.substring(caretEnd)}`; + if (tag === 'small') textRef.value = `${textRef.value.substring(0, caretStart)}${textRef.value.substring(caretEnd)}`; + if (tag === 'center') textRef.value = `${textRef.value.substring(0, caretStart)}
${textRef.value.substring(caretEnd)}`; + if (tag === 'plain') textRef.value = `${textRef.value.substring(0, caretStart)}${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)}${textRef.value.substring(caretStart, caretEnd)}${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)}${textRef.value.substring(caretStart, caretEnd)}${textRef.value.substring(caretEnd)}`; + if (tag === 'small') textRef.value = `${textRef.value.substring(0, caretStart)}${textRef.value.substring(caretStart, caretEnd)}${textRef.value.substring(caretEnd)}`; + if (tag === 'center') textRef.value = `${textRef.value.substring(0, caretStart)}
${textRef.value.substring(caretStart, caretEnd)}
${textRef.value.substring(caretEnd)}`; + if (tag === 'plain') textRef.value = `${textRef.value.substring(0, caretStart)}${textRef.value.substring(caretStart, caretEnd)}${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); + }); +}