From 1f2b1aa58b8738bc2f28f26526aca161928ad707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Tue, 26 Sep 2023 21:57:26 +0900 Subject: [PATCH] (Add) Tools: MFM Preview (#2) * wip * (add) mfm preview * (fix) iroiro * (Fix) build error * (fix) something --- assets/css/mfm.scss | 9 ++ assets/css/tailwind.css | 42 ++++++--- assets/data/nav.ts | 11 ++- assets/data/toolsNav.ts | 14 +++ assets/js/mi/clone.ts | 25 ++++++ assets/js/mi/collapsed.ts | 20 +++++ assets/js/mi/io-emojis.ts | 13 +++ assets/js/mi/io-media-proxy.ts | 23 +++++ assets/js/mi/io-meta.ts | 33 +++++++ assets/js/misc/index.ts | 41 +++++---- components/content/MfmPreview.vue | 10 ++- components/g/Nav.vue | 2 +- components/g/NuxtLink.vue | 8 +- components/mk/CustomEmoji.vue | 82 +++++++++++++++++ components/mk/Mention.vue | 49 ++++++++++ components/mk/Mfm.ts | 90 ++++++++++--------- components/tools/AsideNavSection.vue | 44 +++++++++ content/ja/docs/2.for-users/3.features/mfm.md | 11 ++- layouts/tools.vue | 61 +++++++++++++ locales/ja-JP.yml | 9 ++ nuxt.config.ts | 2 +- pages/tools/index.vue | 28 +++--- pages/tools/mfm-playground.vue | 63 ++++++++++++- 23 files changed, 589 insertions(+), 101 deletions(-) create mode 100644 assets/data/toolsNav.ts create mode 100644 assets/js/mi/clone.ts create mode 100644 assets/js/mi/collapsed.ts create mode 100644 assets/js/mi/io-emojis.ts create mode 100644 assets/js/mi/io-media-proxy.ts create mode 100644 assets/js/mi/io-meta.ts create mode 100644 components/mk/CustomEmoji.vue create mode 100644 components/mk/Mention.vue create mode 100644 components/tools/AsideNavSection.vue create mode 100644 layouts/tools.vue diff --git a/assets/css/mfm.scss b/assets/css/mfm.scss index a56c18a9..2e20a7e9 100644 --- a/assets/css/mfm.scss +++ b/assets/css/mfm.scss @@ -1,3 +1,12 @@ +@charset "utf-8"; + +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* MFM */ @keyframes blink { 0% { opacity: 1; transform: scale(1); } 30% { opacity: 1; transform: scale(1); } diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index e2f1cdd2..de5ac291 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -127,28 +127,42 @@ html.light .markdown-body { @apply font-content-sans; } -.markdown-body h1 > a[href^='#'], -.markdown-body h2 > a[href^='#'], -.markdown-body h3 > a[href^='#'], -.markdown-body h4 > a[href^='#'], -.markdown-body h5 > a[href^='#'], -.markdown-body h6 > a[href^='#'] { +.markdown-body > h1 > a[href^='#'], +.markdown-body :not(.ignore) h1 > a[href^='#'], +.markdown-body > h2 > a[href^='#'], +.markdown-body :not(.ignore) h2 > a[href^='#'], +.markdown-body > h3 > a[href^='#'], +.markdown-body :not(.ignore) h3 > a[href^='#'], +.markdown-body > h4 > a[href^='#'], +.markdown-body :not(.ignore) h4 > a[href^='#'], +.markdown-body > h5 > a[href^='#'], +.markdown-body :not(.ignore) h5 > a[href^='#'], +.markdown-body > h6 > a[href^='#'] , +.markdown-body :not(.ignore) h6 > a[href^='#'] { color: var(--color-fg-default); cursor: pointer; } -.markdown-body h1 > a[href^='#']:hover, -.markdown-body h2 > a[href^='#']:hover, -.markdown-body h3 > a[href^='#']:hover, -.markdown-body h4 > a[href^='#']:hover, -.markdown-body h5 > a[href^='#']:hover, -.markdown-body h6 > a[href^='#']:hover { +.markdown-body > h1 > a[href^='#']:hover, +.markdown-body :not(.ignore) h1 > a[href^='#']:hover, +.markdown-body > h2 > a[href^='#']:hover, +.markdown-body :not(.ignore) h2 > a[href^='#']:hover, +.markdown-body > h3 > a[href^='#']:hover, +.markdown-body :not(.ignore) h3 > a[href^='#']:hover, +.markdown-body > h4 > a[href^='#']:hover, +.markdown-body :not(.ignore) h4 > a[href^='#']:hover, +.markdown-body > h5 > a[href^='#']:hover, +.markdown-body :not(.ignore) h5 > a[href^='#']:hover, +.markdown-body > h6 > a[href^='#']:hover , +.markdown-body :not(.ignore) h6 > a[href^='#']:hover { text-decoration: none; } -.markdown-body ul { +.markdown-body > ul , +.markdown-body :not(.ignore) ul { list-style: disc; } -.markdown-body ol { +.markdown-body > ol , +.markdown-body :not(.ignore) ol { list-style: decimal; } \ No newline at end of file diff --git a/assets/data/nav.ts b/assets/data/nav.ts index e3c2b9eb..6664d112 100644 --- a/assets/data/nav.ts +++ b/assets/data/nav.ts @@ -1,10 +1,19 @@ import { FunctionalComponent } from "nuxt/dist/app/compat/capi"; import GHIcon from "bi/github.svg"; +export type NavSection = { + /** セクションタイトル 翻訳キー */ + title: string; + /** アイテム */ + items: NavItem[]; +}; + /** ナビゲーションバー アイテム */ -type NavItem = { +export type NavItem = { /** 翻訳キー */ i18n: string; + /** 説明文 翻訳キー */ + description?: string; /** リンク先 */ to: string; } | { diff --git a/assets/data/toolsNav.ts b/assets/data/toolsNav.ts new file mode 100644 index 00000000..02b322c4 --- /dev/null +++ b/assets/data/toolsNav.ts @@ -0,0 +1,14 @@ +import type { NavSection } from "./nav"; + +export default [ + { + title: "_tools._forUsers.title", + items: [ + { + i18n: "_mfmPlayground.title", + description: "_mfmPlayground.description", + to: "/tools/mfm-playground/", + } + ] + } +]; \ No newline at end of file diff --git a/assets/js/mi/clone.ts b/assets/js/mi/clone.ts new file mode 100644 index 00000000..ac239ce9 --- /dev/null +++ b/assets/js/mi/clone.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html +// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった +// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045 + +type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; + +export function deepClone(x: T): T { + if (typeof x === 'object') { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} \ No newline at end of file diff --git a/assets/js/mi/collapsed.ts b/assets/js/mi/collapsed.ts new file mode 100644 index 00000000..35293485 --- /dev/null +++ b/assets/js/mi/collapsed.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +export function shouldCollapsed(note: Misskey.entities.Note): boolean { + const collapsed = note?.cw == null && note?.text != null && ( + (note.text.includes('$[x2')) || + (note.text.includes('$[x3')) || + (note.text.includes('$[x4')) || + (note.text.includes('$[scale')) || + (note.text.split('\n').length > 9) || + (note.text.length > 500) || + (note.files.length >= 5) + ); + + return collapsed; +} \ No newline at end of file diff --git a/assets/js/mi/io-emojis.ts b/assets/js/mi/io-emojis.ts new file mode 100644 index 00000000..3cc8bc72 --- /dev/null +++ b/assets/js/mi/io-emojis.ts @@ -0,0 +1,13 @@ +import { getIOEmoji } from './io-meta'; +import * as Misskey from 'misskey-js'; +export const customEmojisMap = new Map(); + +async function init() { + const emojis = await getIOEmoji(); + + for (const emoji of emojis.emojis) { + customEmojisMap.set(emoji.name, emoji); + } +} + +init(); \ No newline at end of file diff --git a/assets/js/mi/io-media-proxy.ts b/assets/js/mi/io-media-proxy.ts new file mode 100644 index 00000000..8427cf16 --- /dev/null +++ b/assets/js/mi/io-media-proxy.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** 各インスタンスから画像引っ張ってくるのがとても大変なのでio経由で出す */ +import { withQuery } from "ufo"; + +export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string { + const localProxy = 'https://misskey.io/proxy'; + + if (imageUrl.startsWith('https://proxy.misskeyusercontent.com/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { + // もう既にproxyっぽそうだったらurlを取り出す + imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; + } + + return withQuery(`${mustOrigin ? localProxy : 'https://proxy.misskeyusercontent.com'}/${type === 'preview' ? 'preview.webp' : 'image.webp'}`, { + url: imageUrl, + ...(!noFallback ? { 'fallback': '1' } : {}), + ...(type ? { [type]: '1' } : {}), + ...(mustOrigin ? { origin: '1' } : {}), + }); +} \ No newline at end of file diff --git a/assets/js/mi/io-meta.ts b/assets/js/mi/io-meta.ts new file mode 100644 index 00000000..00fa71f7 --- /dev/null +++ b/assets/js/mi/io-meta.ts @@ -0,0 +1,33 @@ +import * as Misskey from 'misskey-js'; + +export const getIOMeta = async (): Promise => { + if (!process.client) { + return {}; + } + if (!sessionStorage.getItem('miHub_io_meta')) { + const meta = await fetch('https://misskey.io/api/meta'); + + const metaText = await meta.text(); + sessionStorage.setItem('miHub_io_meta', metaText); + + return JSON.parse(metaText); + } else { + return JSON.parse(sessionStorage.getItem('miHub_io_meta') ?? ''); + } +} + +export const getIOEmoji = async (): Promise<{ emojis: Misskey.entities.CustomEmoji[] }> => { + if (!process.client) { + return { emojis: [] }; + } + if (!localStorage.getItem('miHub_io_emoji')) { + const emoji = await fetch('https://misskey.io/api/emojis'); + + const emojiText = await emoji.text(); + localStorage.setItem('miHub_io_emoji', emojiText); + + return JSON.parse(emojiText); + } else { + return JSON.parse(localStorage.getItem('miHub_io_emoji') ?? ''); + } +} \ No newline at end of file diff --git a/assets/js/misc/index.ts b/assets/js/misc/index.ts index e9b6f873..35a85fc8 100644 --- a/assets/js/misc/index.ts +++ b/assets/js/misc/index.ts @@ -1,5 +1,12 @@ import type { NavItem } from '@nuxt/content/dist/runtime/types'; +import { parseURL } from 'ufo'; +/** + * オブジェクトのパス文字列からオブジェクトの内部を参照 + * @param o オブジェクト + * @param s パス + * @returns パスの先にあるもの + */ export function resolveObjPath(o: object, s: string): any { s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties s = s.replace(/^\./, ''); // strip a leading dot @@ -15,33 +22,33 @@ export function resolveObjPath(o: object, s: string): any { return o; } +/** + * URLがドメイン内部かどうかを判別 + * @param link 判別したいURL + * @param base ローカルの基準となるドメイン + */ export function isLocalPath(link: string, base?: string): boolean { let baseUrl; + if (base) { baseUrl = base; } else { const runtimeConfig = useRuntimeConfig(); baseUrl = runtimeConfig.public.baseUrl; } - const rootDomain = new URL(baseUrl); - try { - const url = new URL(link); - if (!url.hostname || rootDomain.hostname === url.hostname) { - return true; - } else if (rootDomain.hostname !== url.hostname) { - return false; - } - return false; - } catch(error) { - if(link !== '') { - return true; - } else { - throw error; - } - } - + + const rootDomain = parseURL(base); + const url = parseURL(link); + + return (!url.host || rootDomain.host === url.host); } +/** + * ナビゲーションObjectを合致する条件まで深掘り + * @param obj ナビゲーションObject + * @param condition 深掘りを停止する条件 + * @returns 深掘りしたナビゲーションObject + */ export const findDeepObject = (obj: NavItem, condition: (v: NavItem) => boolean): NavItem | null => { if (condition(obj)) { return obj; diff --git a/components/content/MfmPreview.vue b/components/content/MfmPreview.vue index f54b7a3e..a95b20d2 100644 --- a/components/content/MfmPreview.vue +++ b/components/content/MfmPreview.vue @@ -1,7 +1,9 @@ @@ -12,3 +14,9 @@ defineProps<{ text: string; }>(); + + \ No newline at end of file diff --git a/components/g/Nav.vue b/components/g/Nav.vue index 66ff321a..2d8b4be5 100644 --- a/components/g/Nav.vue +++ b/components/g/Nav.vue @@ -8,7 +8,7 @@
  • diff --git a/components/g/NuxtLink.vue b/components/g/NuxtLink.vue index 9c91d153..009a3b76 100644 --- a/components/g/NuxtLink.vue +++ b/components/g/NuxtLink.vue @@ -8,7 +8,8 @@ + + \ No newline at end of file diff --git a/components/mk/Mention.vue b/components/mk/Mention.vue new file mode 100644 index 00000000..d676230b --- /dev/null +++ b/components/mk/Mention.vue @@ -0,0 +1,49 @@ + + + + + + + \ No newline at end of file diff --git a/components/mk/Mfm.ts b/components/mk/Mfm.ts index 840e15de..0fe2b31c 100644 --- a/components/mk/Mfm.ts +++ b/components/mk/Mfm.ts @@ -1,8 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { VNode, h } from 'vue'; import * as mfm from 'mfm-js'; import MkGoogle from '@/components/mk/Google.vue'; import MkSparkle from '@/components/mk/Sparkle.vue'; -import NuxtLink from '@/components/g/NuxtLink'; +import MkCustomEmoji from '@/components/mk/CustomEmoji.vue'; +import MkMention from '@/components/mk/Mention.vue'; +import NuxtLink from '@/components/g/NuxtLink.vue'; import ProseAVue from '@/components/content/ProseA.vue'; const QUOTE_STYLE = ` @@ -21,6 +28,7 @@ export default function(props: { isNote?: boolean; emojiUrls?: string[]; rootScale?: number; + baseHost?: string; }) { const isNote = props.isNote !== undefined ? props.isNote : true; @@ -99,12 +107,12 @@ export default function(props: { case 'spin': { const direction = token.props.args.left ? 'reverse' : - token.props.args.alternate ? 'alternate' : - 'normal'; + token.props.args.alternate ? 'alternate' : + 'normal'; const anime = token.props.args.x ? 'mfm-spinX' : - token.props.args.y ? 'mfm-spinY' : - 'mfm-spin'; + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; const speed = validTime(token.props.args.speed) ?? '1.5s'; style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; break; @@ -122,8 +130,8 @@ export default function(props: { case 'flip': { const transform = (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : - token.props.args.v ? 'scaleY(-1)' : - 'scaleX(-1)'; + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; style = `transform: ${transform};`; break; } @@ -145,12 +153,12 @@ export default function(props: { case 'font': { const family = token.props.args.serif ? 'serif' : - token.props.args.monospace ? 'monospace' : - token.props.args.cursive ? 'cursive' : - token.props.args.fantasy ? 'fantasy' : - token.props.args.emoji ? 'emoji' : - token.props.args.math ? 'math' : - null; + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; if (family) style = `font-family: ${family};`; break; } @@ -226,22 +234,26 @@ export default function(props: { return [h(ProseAVue, { key: Math.random(), href: token.props.url, + target: '_blank', rel: 'nofollow noopener', }, token.props.url)]; } case 'link': { - return [h(ProseAVue, { + return [h(NuxtLink, { key: Math.random(), to: token.props.url, + target: '_blank', rel: 'nofollow noopener', }, genEl(token.children, scale))]; } case 'mention': { + //@ts-ignore return [h(MkMention, { key: Math.random(), - host: (token.props.host) || host, + host: (token.props.host) ?? props.baseHost, + localHost: props.baseHost, username: token.props.username, })]; } @@ -249,7 +261,7 @@ export default function(props: { case 'hashtag': { return [h(NuxtLink, { key: Math.random(), - to: `https://misskey.io/tags/${encodeURIComponent(token.props.hashtag)}`, + to: `https://${props.baseHost ?? 'misskey.io'}/tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:rgb(255, 145, 86);', }, `#${token.props.hashtag}`)]; } @@ -268,34 +280,20 @@ export default function(props: { case 'emojiCode': { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (props.author?.host == null) { - return [h(MkCustomEmoji, { - key: Math.random(), - name: token.props.name, - normal: props.plain, - host: null, - useOriginalSize: scale >= 2.5, - })]; - } else { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) { - return [h('span', `:${token.props.name}:`)]; - } else { - return [h(MkCustomEmoji, { - key: Math.random(), - name: token.props.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - url: props.emojiUrls ? props.emojiUrls[token.props.name] : null, - normal: props.plain, - host: props.author.host, - useOriginalSize: scale >= 2.5, - })]; - } - } + return [h(MkCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: props.plain, + host: props.baseHost, + useOriginalSize: scale >= 2.5, + })]; } case 'unicodeEmoji': { - return [h('span', token.props.emoji)]; + return [h('img', { + style: 'display:inline;height:1.25em;vertical-align:-.25em;', + src: `https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/svg/${token.props.emoji.codePointAt(0)?.toString(16)}.svg`, + })]; } case 'mathInline': { @@ -306,6 +304,16 @@ export default function(props: { return [h('code', token.props.formula)]; } + case 'inlineCode': { + return [h('code', token.props.code)]; + } + + case 'blockCode': { + return [h('pre', { + class: 'p-4 bg-gray-200/50 rounded', + }, h('code', token.props.code))]; + } + case 'search': { return [h(MkGoogle, { key: Math.random(), diff --git a/components/tools/AsideNavSection.vue b/components/tools/AsideNavSection.vue new file mode 100644 index 00000000..994b611f --- /dev/null +++ b/components/tools/AsideNavSection.vue @@ -0,0 +1,44 @@ + + \ No newline at end of file diff --git a/content/ja/docs/2.for-users/3.features/mfm.md b/content/ja/docs/2.for-users/3.features/mfm.md index 42a670e1..8fbee660 100644 --- a/content/ja/docs/2.for-users/3.features/mfm.md +++ b/content/ja/docs/2.for-users/3.features/mfm.md @@ -23,12 +23,17 @@ MFMは、Markup language For Misskeyの略で、Misskeyの様々な場所で使 ::: ``` -@alice +@ai ``` + + + ``` -@alice@example.com +@repo@p1.a9z.dev ``` + + ### ハッシュタグ ナンバーサイン + タグで、ハッシュタグを示すことができます。 :::tip @@ -75,6 +80,8 @@ https://example.com :misskey: ``` + + ### 太字 文字を太く表示して強調することができます。 ``` diff --git a/layouts/tools.vue b/layouts/tools.vue new file mode 100644 index 00000000..f5f8ca16 --- /dev/null +++ b/layouts/tools.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 9a86bd75..dccc2d41 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -173,13 +173,22 @@ _links: _tools: title: "ツール集" + index: "ツール集 ホーム画面" description: "Misskey向けの便利ツールを公開中!" + menuToggle: "メニュー" _forUsers: title: "Misskeyユーザー向け" _mfmPlayground: title: "MFMお試しコーナー" description: "MFMを自由に練習できます!Misskeyの投稿画面・ノートの画面を再現!" + preview: "プレビュー" + disclaimer: "ここに表示される通りに描画されるとは限りません。コードのシンタックスハイライトには対応していません。" + mfm: "MFM" + character: "{0} 文字" + domain: "表示を再現するサーバー" + noteIt: "ノート" + clearEmojiCache: "絵文字のキャッシュを削除" _api: _permissions: diff --git a/nuxt.config.ts b/nuxt.config.ts index 986e0e57..d3e051b5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,8 +23,8 @@ export default defineNuxtConfig({ } }, css: [ - "@/assets/css/mfm.scss", "github-markdown-css/github-markdown.css", + "@/assets/css/mfm.scss", "@/assets/css/tailwind.css", "@/assets/css/bootstrap-forms.scss", ], diff --git a/pages/tools/index.vue b/pages/tools/index.vue index c92f5daf..a8e64b7a 100644 --- a/pages/tools/index.vue +++ b/pages/tools/index.vue @@ -17,23 +17,19 @@
    - -
    + +

    - {{ $t(`_tools._forUsers.title`) }} + {{ $t(section.title) }}

    - +
    @@ -41,6 +37,8 @@ diff --git a/pages/tools/mfm-playground.vue b/pages/tools/mfm-playground.vue index c787cec9..256b7a32 100644 --- a/pages/tools/mfm-playground.vue +++ b/pages/tools/mfm-playground.vue @@ -3,23 +3,80 @@

    {{ $t(`_mfmPlayground.title`) }}

    -
    - WIP +
    +
    + {{ $t('_mfmPlayground.preview') }} +
    + +
    +
    {{ $t('_mfmPlayground.disclaimer') }}
    +
    +
    +
    +
    + +
    + {{ $t('_mfmPlayground.character', [ (mfmText?.length ?? 0) ]) }} +
    +
    + +
    +
    + +
    + + {{ $t('_mfmPlayground.noteIt') }} +
    +
    +
    +
    +
    + 絵文字が表示されないとき +
    +
    + +
    +
    +
    +