(Add) Tools: MFM Preview (#2)

* wip

* (add) mfm preview

* (fix) iroiro

* (Fix) build error

* (fix) something
This commit is contained in:
かっこかり 2023-09-26 21:57:26 +09:00 committed by GitHub
parent dafeec6a98
commit 1f2b1aa58b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 589 additions and 101 deletions

View File

@ -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); }

View File

@ -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;
}

View File

@ -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;
} | {

14
assets/data/toolsNav.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NavSection } from "./nav";
export default <NavSection[]>[
{
title: "_tools._forUsers.title",
items: [
{
i18n: "_mfmPlayground.title",
description: "_mfmPlayground.description",
to: "/tools/mfm-playground/",
}
]
}
];

25
assets/js/mi/clone.ts Normal file
View File

@ -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<T extends Cloneable>(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<string, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = deepClone(v);
}
return obj as T;
} else {
return x;
}
}

20
assets/js/mi/collapsed.ts Normal file
View File

@ -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;
}

13
assets/js/mi/io-emojis.ts Normal file
View File

@ -0,0 +1,13 @@
import { getIOEmoji } from './io-meta';
import * as Misskey from 'misskey-js';
export const customEmojisMap = new Map<string, Misskey.entities.CustomEmoji>();
async function init() {
const emojis = await getIOEmoji();
for (const emoji of emojis.emojis) {
customEmojisMap.set(emoji.name, emoji);
}
}
init();

View File

@ -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' } : {}),
});
}

33
assets/js/mi/io-meta.ts Normal file
View File

@ -0,0 +1,33 @@
import * as Misskey from 'misskey-js';
export const getIOMeta = async (): Promise<Misskey.entities.LiteInstanceMetadata> => {
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') ?? '');
}
}

View File

@ -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;

View File

@ -1,7 +1,9 @@
<template>
<div class="rounded-lg border border-slate-200 dark:border-slate-800 p-6 mfm-root mb-4 relative overflow-hidden">
<div class="absolute top-0 left-0 px-1 py-0.5 text-xs bg-slate-200 dark:bg-slate-800 rounded-br-lg z-20">{{ $t('_content.preview') }}</div>
<MkMfm :text="text" />
<div class="ignore" :class="$style.mfmRoot">
<MkMfm :text="text" baseHost="misskey.io" />
</div>
</div>
</template>
@ -12,3 +14,9 @@ defineProps<{
text: string;
}>();
</script>
<style module>
.mfmRoot img {
display: inline;
}
</style>

View File

@ -8,7 +8,7 @@
</GNuxtLink>
</div>
<ul
class="fixed z-[9902] top-16 right-0 text-right p-4 w-[80vw] sm:w-[50vw] bg-slate-300/90 dark:bg-slate-950/90 shadow-lg space-y-2 transition-[transform,border-radius] lg:transition-none lg:translate-x-0 lg:backdrop-blur-none lg:w-auto lg:rounded-none lg:shadow-none lg:space-y-0 lg:p-0 lg:relative lg:top-0 lg:right-auto lg:bg-transparent lg:col-span-4 lg:space-x-8 xl:space-x-10 lg:flex lg:justify-center"
class="fixed z-[9902] top-16 right-0 text-right p-4 w-[80vw] sm:w-[50vw] bg-slate-300/90 dark:bg-slate-950/90 shadow-lg space-y-2 transition-[transform,border-radius] lg:transition-none lg:translate-x-0 lg:backdrop-blur-none lg:w-auto lg:rounded-none lg:shadow-none lg:space-y-0 lg:p-0 lg:relative lg:top-0 lg:right-auto lg:bg-transparent dark:lg:bg-transparent lg:col-span-4 lg:space-x-8 xl:space-x-10 lg:flex lg:justify-center"
:class="[(scrollPos <= -40) ? 'rounded-bl-lg' : 'rounded-l-lg', navOpen ? 'translate-x-0' : 'translate-x-full']"
>
<li v-for="item in NavData.center">

View File

@ -8,7 +8,8 @@
</template>
<script setup lang="ts">
import { parseURL, cleanDoubleSlashes, withTrailingSlash } from 'ufo';
import { cleanDoubleSlashes, withTrailingSlash } from 'ufo';
import { isLocalPath } from '@/assets/js/misc';
import { RouteLocationRaw } from '#vue-router';
/**
@ -26,11 +27,8 @@ const realHref = computed(() => {
const rhf = rawProps.to ?? rawProps.href;
if (rhf && typeof rhf === 'string') {
const runtimeConfig = useRuntimeConfig();
const rootDomain = parseURL(runtimeConfig.public.baseUrl);
const url = parseURL(rhf);
if (!url.host || rootDomain.host === url.host) {
if (isLocalPath(rhf)) {
return withTrailingSlash(cleanDoubleSlashes(rhf), true);
}

View File

@ -0,0 +1,82 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<span v-if="errored">:{{ customEmojiName }}:</span>
<img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/>
</template>
<script lang="ts" setup>
import { getProxiedImageUrl } from '@/assets/js/mi/io-media-proxy';
import { customEmojisMap } from '@/assets/js/mi/io-emojis';
import { parseURL } from 'ufo';
const props = defineProps<{
name: string;
normal?: boolean;
noStyle?: boolean;
host?: string | null;
url?: string;
useOriginalSize?: boolean;
}>();
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
const isLocal = computed(() => props.host === 'misskey.io' && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
const rawUrl = computed(() => {
if (props.url) {
return props.url;
}
if (isLocal.value) {
return customEmojisMap.get(customEmojiName.value)?.url ?? null;
}
return props.host !== 'misskey.io' ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
});
const url = computed(() => {
if (rawUrl.value == null) return null;
const proxied =
(rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
? parseURL(rawUrl.value).host ? rawUrl.value : 'https://misskey.io' + rawUrl.value
: getProxiedImageUrl(
rawUrl.value,
props.useOriginalSize ? undefined : 'emoji',
false,
true,
);
return proxied;
});
const alt = computed(() => `:${customEmojiName.value}:`);
const errored = ref(props.host == null);
</script>
<style lang="scss" module>
.root {
display: inline;
height: 2em;
vertical-align: middle;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.2);
}
}
.normal {
display: inline;
height: 1.25em;
vertical-align: -0.25em;
&:hover {
transform: none;
}
}
.noStyle {
height: auto !important;
}
</style>

49
components/mk/Mention.vue Normal file
View File

@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<GNuxtLink :class="$style.root" :to="url" target="_blank">
<img :class="$style.icon" :src="`https://${localHost}/avatar/@${username}@${host}`" alt="">
<span>
<span>@{{ username }}</span>
<span v-if="host != localHost" :class="$style.host">@{{ host }}</span>
</span>
</GNuxtLink>
</template>
<script lang="ts" setup>
const props = defineProps<{
username: string;
host: string;
localHost: string;
}>();
const canonical = props.host === props.localHost ? `@${props.username}` : `@${props.username}@${props.host}`;
const url = `https://${props.localHost}/${canonical}`;
</script>
<style lang="scss" module>
.root {
display: inline-block;
padding: 4px 8px 4px 4px;
border-radius: 999px;
color: rgb(134, 179, 0)!important;
background-color: rgba(134, 179, 0, 0.1)!important;
}
.icon {
width: 1.5em;
height: 1.5em;
object-fit: cover;
margin: 0 0.2em 0 0;
vertical-align: bottom;
border-radius: 100%;
}
.host {
opacity: 0.5;
}
</style>

View File

@ -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;
@ -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,
host: props.baseHost,
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,
})];
}
}
}
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(),

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import type { NavSection } from '@/assets/data/nav';
import { isSamePath } from 'ufo';
import CaretRightFillIco from 'bi/caret-right-fill.svg';
const props = defineProps<{
d: NavSection;
}>();
const emit = defineEmits([
'toggleNav',
]);
const navData = props.d;
const route = useRoute();
const router = useRouter();
const isShown = ref(false);
const localePath = useLocalePath();
router.afterEach((to, from, failure) => {
if (navData.items.some((e) => isSamePath(route.path, localePath(e.to)))) {
isShown.value = true;
}
});
onMounted(() => {
if (navData.items.some((e) => isSamePath(route.path, localePath(e.to)))) {
isShown.value = true;
}
});
function toggleCollapse() {
isShown.value = !isShown.value;
}
</script>
<template>
<button @click="toggleCollapse()" class="hover:text-accent-600 hover:bg-accent-50 dark:hover:text-accent-100 dark:hover:bg-accent-800 px-4 py-2 rounded-r-full flex items-center text-start"><CaretRightFillIco :class="[{ 'rotate-90': (isShown) }, 'mr-1 h-3 w-3 transition-transform']" />{{ $t(navData.title) }}</button>
<div v-show="isShown">
<GNuxtLink v-for="item in navData.items" @click.native="emit('toggleNav')" :class="[isSamePath(route.path, localePath(item.to)) ? 'bg-accent-100 text-accent-600 dark:text-accent-100 dark:bg-accent-800 font-bold' : 'hover:text-accent-600 hover:bg-accent-50 dark:hover:text-accent-100 dark:hover:bg-accent-800', 'block pl-6 pr-4 py-2 rounded-r-full']" :to="localePath(item.to)">
{{ $t(item.i18n) }}
</GNuxtLink>
</div>
<hr class="mb-1 mt-2" />
</template>

View File

@ -23,12 +23,17 @@ MFMは、Markup language For Misskeyの略で、Misskeyの様々な場所で使
:::
```
@alice
@ai
```
<MfmPreview text="@ai"></MfmPreview>
```
@alice@example.com
@repo@p1.a9z.dev
```
<MfmPreview text="@repo@p1.a9z.dev"></MfmPreview>
### ハッシュタグ
ナンバーサイン + タグで、ハッシュタグを示すことができます。
:::tip
@ -75,6 +80,8 @@ https://example.com
:misskey:
```
<MfmPreview text=":misskey:"></MfmPreview>
### 太字
文字を太く表示して強調することができます。
```

61
layouts/tools.vue Normal file
View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import sections from '@/assets/data/toolsNav';
const emit = defineEmits([
'toggleNav',
]);
const isNavOpen = ref<boolean>(false);
const isAsideNavOpen = ref<boolean>(false);
const localePath = useLocalePath();
useHead({
htmlAttrs: {
class: 'scroll-pt-20',
},
});
</script>
<template>
<div class="bg-white dark:bg-slate-950">
<GNav @toggleNav="isNavOpen = !isNavOpen" :is-open="isNavOpen" :slim="true" :disable-shadow="true" />
<div :class="$style.slimPageRoot" class="overflow-x-hidden">
<aside
class="w-80 lg:w-72 fixed top-0 mt-16 h-screen flex transition-transform lg:translate-x-0"
:class="[isAsideNavOpen ? 'translate-x-0' : '-translate-x-72']"
>
<nav class="border-r pr-2 py-5 flex flex-col w-72 overflow-y-scroll">
<NuxtLink @click.native="emit('toggleNav')" class="block pl-6 pr-4 py-2 rounded-r-full hover:text-accent-600 hover:bg-accent-50 dark:hover:text-accent-100 dark:hover:bg-accent-800" :to="localePath('/tools/')">
{{ $t('_tools.index') }}
</NuxtLink>
<hr class="mb-1 mt-2" />
<ToolsAsideNavSection :d="section" @toggleNav="emit('toggleNav')" v-for="section in sections" />
</nav>
<div class="lg:hidden">
<button @click="isAsideNavOpen = !isAsideNavOpen" class="bg-slate-300 dark:bg-slate-800 hover:bg-slate-400 dark:hover:bg-slate-700" :class="$style.toolsMenuToggle"><span>{{ $t('_tools.menuToggle') }}</span></button>
</div>
</aside>
<main class="ml-8 lg:ml-72 lg:translate-x-0 transition-transform bg-slate-100 dark:bg-slate-900" :class="[isAsideNavOpen ? 'translate-x-72' : 'translate-x-0']">
<div :class="$style.slimPageRoot">
<slot />
</div>
</main>
</div>
</div>
</template>
<style module>
.slimPageRoot {
min-height: calc(100vh - 4rem);
}
.toolsMenuToggle {
@apply block mt-6 w-8 h-min py-2 rounded-r-lg;
}
.toolsMenuToggle span {
@apply tracking-wide;
writing-mode: vertical-rl;
}
</style>

View File

@ -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:

View File

@ -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",
],

View File

@ -17,23 +17,19 @@
</GHero>
<div class="pb-12 lg:mt-12 pt-6 bg-white dark:bg-slate-950">
<div class="container mx-auto max-w-screen-xl px-6 space-y-6 lg:space-y-8">
<GLocalNav :items="[
{
name: $t('_tools._forUsers.title'),
anchor: '#forUsers',
},
]" />
<section>
<GLocalNav :items="sections.map((v) => ({
name: $t(v.title),
anchor: '#' + v.title.replaceAll('.', '_'),
}))" />
<section :id="section.title.replaceAll('.', '_')" v-for="section in sections">
<h2 class="text-2xl lg:text-3xl font-bold mb-4">
{{ $t(`_tools._forUsers.title`) }}
{{ $t(section.title) }}
</h2>
<GLinks :wide="true" :items="[
{
to: localePath('/tools/mfm-playground/'),
title: $t('_mfmPlayground.title'),
description: $t('_mfmPlayground.description'),
}
]" />
<GLinks :wide="true" :items="section.items.map((e) => ({
title: $t(e.i18n),
description: $t(e.description),
to: localePath(e.to),
}))" />
</section>
</div>
</div>
@ -41,6 +37,8 @@
</template>
<script setup lang="ts">
import sections from '@/assets/data/toolsNav';
const localePath = useLocalePath();
</script>

View File

@ -3,23 +3,80 @@
<h1 class='text-2xl lg:text-3xl font-bold mb-4'>
{{ $t(`_mfmPlayground.title`) }}
</h1>
<div class='rounded-lg grid grid-cols-2'>
WIP
<div class='rounded-lg grid md:grid-cols-2 gap-4'>
<div>
{{ $t('_mfmPlayground.preview') }}
<div :class="$style.mfmRoot" class="mb-2 bg-white dark:bg-[#212529] border-gray-200 dark:border-gray-600">
<MkMfm :text="mfmText ?? ''" :baseHost="mfmHost" />
</div>
<div class="text-xs text-gray-500">{{ $t('_mfmPlayground.disclaimer') }}</div>
</div>
<div class="space-y-4">
<div>
<div class="flex">
<label for="mfmPlaygroundMFM">{{ $t('_mfmPlayground.mfm') }}</label>
<div
class="ml-auto"
:class="[(mfmText?.length ?? 0) >= 5000 && 'font-bold text-red-600']"
>
{{ $t('_mfmPlayground.character', [ (mfmText?.length ?? 0) ]) }}
</div>
</div>
<textarea
:rows="(mfmText || '').split('\n').length >= 8 ? (mfmText || '').split('\n').length + 5 : 10"
class="form-control"
id="mfmPlaygroundMFM"
v-model="mfmText"
></textarea>
</div>
<div>
<label for="mfmPlaygroundDomain">{{ $t('_mfmPlayground.domain') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="mfmPlaygroundDomain" v-model="mfmHost" />
<GNuxtLink :to="shareURL" target="_blank" class="btn btn-primary !text-white">{{ $t('_mfmPlayground.noteIt') }}<SendIco class="ml-1" /></GNuxtLink>
</div>
</div>
<div>
<div class="flex">
<div class="w-1/2 md:w-1/3 pr-2 col-form-label">
絵文字が表示されないとき
</div>
<div class="w-1/2 md:w-2/3">
<button @click="clearEmojiCache()" class="btn w-full btn-outline-primary hover:!text-white">{{ $t('_mfmPlayground.clearEmojiCache') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang='ts'>
import SendIco from 'bi/send-fill.svg';
import { parseURL, withQuery } from 'ufo';
definePageMeta({
layout: 'slim',
layout: 'tools',
});
const mfmText = ref<string>();
const mfmHost = ref<string>('misskey.io');
const shareURL = computed(() => {
const domain = 'https://' + (parseURL(mfmHost.value).host ?? mfmHost.value) + '/share';
return withQuery(domain, { text: mfmText.value });
})
function clearEmojiCache() {
if (process.client) {
localStorage.clear();
location.reload();
}
}
</script>
<style module>
.mfmRoot {
@apply rounded-lg p-6 border break-words overflow-hidden;
font-family: Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;
line-height: 1.35;
}