Vue3でMarkdownエディタを作るよ
環境
前提として下記の環境で開発を進める
- JSフレームワーク:Vue3
- スタイルフレームワーク:
- Vuetify3
- Tailwind3
候補パーサー
JSのMarkdownパーサーは主に下記2点
- markdown-it
- marked
両方とも触ってみた感じ特に大きな差異はないが、
markdown-itは調べるときにググりにくいのでmarkedにする
markedで簡単に実装
インストール
npm i marked
簡易的に実装
コンポーネント化してテキストエリアの内容をリアルタイムにレンダリングするようにします。
import { Link, useForm, Head } from '@inertiajs/vue3';
import MarkDownViewer from '@/Components/MarkDownViewer.vue';
const form = useForm({
body: ""
});
// 省略
<v-textarea variant="solo" flat placeholder="Write Me" :height="$vuetify.display.height - 320" auto-grow hide-details v-model="form.body" />
// 省略
<MarkDownViewer :source="form.body" />
// 省略
<script setup lang="ts">
import { watch, ref } from 'vue';
import { Marked } from "marked";
const props = defineProps({
source: {
type: String,
required: true,
},
});
const marked = new Marked();
const compiledMarkdown = ref("");
watch(() => props.source, () => {
compiledMarkdown.value = marked.parse(props.source)
})
</script>
<template>
<div v-html="compiledMarkdown"/>
</template>
試してみると、装飾がされない…
でもちゃんとheaderタグで出力されてるんでとりまヨシ!
非推奨オプションの修正
警告がめっちゃ出てます。
公式ドキュメントをみると非推奨の関数が結構あるので対応します。
警告で出てたライブラリをインストール
npm i marked-mangle marked-gfm-heading-id
<script setup lang="ts">
import { watch, ref } from 'vue';
import { Marked } from "marked";
import { mangle } from "marked-mangle";
import { gfmHeadingId } from "marked-gfm-heading-id";
const props = defineProps({
source: {
type: String,
required: true,
},
});
const marked = new Marked();
let options = {
prefix: "my-prefix-",
};
marked.use(mangle())
.use(gfmHeadingId(options))
.use({ renderer });
const compiledMarkdown = ref("");
watch(() => props.source, () => {
compiledMarkdown.value = marked.parse(props.source)
})
</script>
<template>
<div v-html="compiledMarkdown"/>
</template>
警告が消えた!ヨシ!
またこのプラグインを入れたことで、Headerに自動的にidが振られ、またメールアドレスもマングリングされたのでヨシ!
レンダリング時にクラスを付与
装飾がされていないのでVuetifyのClassを付与して装飾する。
markedにはrendererというクラスがあり、それをオーバーライドすればできるそうなので、
公式ドキュメントくんを参考に実装。
const renderer = new marked.Renderer();
renderer.heading= function (text, level, raw, slugger){
{
let mapping = {
1: 'text-h3',
2: 'text-h4',
3: 'text-h5',
4: 'text-h6'
}
if (this.options.headerIds) {
const id = this.options.headerPrefix + slugger.slug(raw);
return `<h${level} id="${id}" class="${mapping[level]}">${text}</h${level}>\n`;
}
// ignore IDs
return `<h${level} class="${mapping[level]}">${text}</h${level}>\n`;
}
};
marked.use(mangle())
.use(gfmHeadingId(options))
.use({ renderer });
ちゃんとClassが付与されてるみたいなのでヨシ!
コードブロックを装飾
みんな大好きhilightjsでコードを装飾する
本体とプラグインをインストール
npm i highlight.js marked-highlight
インストールしたものとテーマをインポートしてMarkedの定義時に色々設定
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
//const marked = new Marked(); 用済みじゃい
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if ((typeof lang === "string" || lang instanceof String) && lang.includes(":")) {
lang = lang.substring(0, lang.indexOf(':'));
}
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
ヨシ!
コードブロックを装飾
コードブロックをオサレにしたいのでTailwindのドキュメントページ(こんなの)をパクってオシャレにする。
まずはまんまパクってコードを書く
<div
class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10">
<div class="relative flex text-slate-400 text-xs leading-6">
<div class="mt-2 flex-none text-sky-300 px-4 py-1 flex items-center">Terminal</div>
<div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden">
<div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div>
</div>
<div class="absolute top-2 right-0 h-8 flex items-center pr-4">
<div class="relative flex -mr-2">
<button type="button" class="text-slate-500 hover:text-slate-400">
<svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true" class="w-8 h-8">
<path
d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19">
</path>
<path
d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5">
</path>
</svg>
</button>
</div>
</div>
</div>
<div class="relative">
<pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4">
<code class="language-php bg-slate-800">ここにコードを書く</code>
</pre>
</div>
</div>
ええ感じやね!
次にMarkedのコードレンダリング部分をオーバーライドして埋め込む。
renderer.code = function (code, infostring, escaped) {
const filename = infostring.substring(infostring.indexOf(':')).replace(':','');
const lang = (infostring || '').match(/\S*/)![0];
if (this.options.highlight) {
const out = this.options.highlight(code, lang);
if (out != null && out !== code) {
escaped = true;
code = out;
}
}
code = code.replace(/\n$/, '') + '\n';
console.log(lang);
if (!lang) {
return '<div class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10"><div class="relative flex text-slate-400 text-xs leading-6"><div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden"><div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div></div><div class="absolute top-2 right-0 h-8 flex items-center pr-4"><div class="relative flex -mr-2"><button type="button" class="text-slate-500 hover:text-slate-400"><svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"stroke-linejoin="round" aria-hidden="true" class="w-8 h-8"><path d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19"></path><path d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5"></path></svg></button></div></div></div><div class="relative"><pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4"><code class="language-php bg-slate-800">'
+ code
+ '</code></pre></div></div>\n';
}
const tab = filename == '' ? lang:filename;
return '<div class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10"><div class="relative flex text-slate-400 text-xs leading-6"><div class="mt-2 flex-none text-sky-300 px-4 py-1 flex items-center '
+ this.options.langPrefix
+ tab
+ '">'
+ tab
+ '</div><div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden"><div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div></div><div class="absolute top-2 right-0 h-8 flex items-center pr-4"><div class="relative flex -mr-2"><button type="button" class="text-slate-500 hover:text-slate-400"><svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="w-8 h-8"><path d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19"></path><path d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5"></path></svg></button></div></div></div><div class="relative"><pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4"><code class="language-php bg-slate-800">'
+ code
+ '</code></pre></div></div>\n';
}
確認してみよう
コピー機能はとりあえず置いといて、ヨシ!
スタイルを調整
VuetifyがデフォルトのHTMLスタイルをリセットしてしまってるので箇条書きや太字など色々調整していきます。
とりあえずMarkedのサンプルを入れて確認します。
今の出力結果:
Markedの出力結果:
とりあえずリンクと箇条書き、引用を直したい。めんどくさい。
リンクの表示を修正
いつもどおりrendererからリンクの表示関数linkを探してオーバーライドする。
renderer.link = function (href, title , text) {
href = cleanUrl(this.options.sanitize, this.options.baseUrl, href) as any;
if (href === null) {
return text;
}
let out = '<a href="' + href + '"';
if (title) {
out += ' title="' + title + '"';
}
out += '>' + text + '</a>';
return out;
}
Marked.js内のヘルパー関数を使ってるっぽい…
うーん…この関数を独自に書くのも微妙だしできれば内部処理はあんま変えたくない…
やりたくなかったけどCSS書く…カァッ!
CSS書く…カァッ!
ここをこうして
<template>
<div v-html="compiledMarkdown" class="markdown-text"/>
</template>
<style lang="scss" deep>
.v-application .markdown-text {
a {
color: #0f83fd;
}
a:hover{
text-decoration: underline;
}
}
</style>
こう!!!
ヨシ!
箇条書きを書く
いつも通りRendererをオーバーライドしてTailwindのクラスを追加して
renderer.list = function (body, ordered, start) {
const type = ordered ? 'ol' : 'ul',
startatt = (ordered && start !== 1) ? (' start="' + start + '"') : '';
const list_class = ordered ? 'list-decimal':'list-disc';
return '<' + type + startatt + ' class="'+list_class+' pl-10 py-4">\n' + body + '</' + type + '>\n';
}
こう!!!
引用文を装飾
下記のようにもにょもにょして
renderer.blockquote=function(quote) {
return `<blockquote class="p-4 my-4 bg-gray-50 border-l-4 border-gray-400 rounded-md">\n${quote}</blockquote>\n`;
}
こう!