こんにちは。ずっと前からQiitaのエディターみたいなマークダウンエディターを作りたかったです。
今回はRailsアプリケーションでマークダウンが使えるまでの実装をご紹介いたします。jQueryは使いません。この記事の内容は以下になります。
- マークダウンの導入
- TOC (Table of contents)
- コードブロックのテーマの設定
- コードブロックの上にファイル名の表示
- コピーボタンの実装
- ライププレビュー
- ファイルアップロード
- アップロードの前に画像を圧縮したりリサイズしたりする
結果はこのようになります。
では、始めましょう!
マークダウンの導入
まずは必要なGemをインストールする
1
2
gem 'redcarpet' # Markdown parser
gem 'rouge' # Syntax highlight
そしてヘルパーファイルを作成する
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# frozen_string_literal: true
require 'rouge/plugins/redcarpet'
class CustomRenderHTML < Redcarpet::Render::HTML
include Rouge::Plugins::Redcarpet
# Rouge::Plugins::Redcarpetのメソッドを上書きする
def block_code(code, language)
# もしコードブロックに言語とファイル名が定義されたら取得する。例: ```ruby:test.rb
filename = ''
if language.present?
filename = language.split(':')[1]
language = language.split(':')[0]
end
lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText
code.gsub!(/^ /, "\t") if lexer.tag == 'make'
formatter = rouge_formatter(lexer)
result = formatter.format(lexer.lex(code))
return "<div class=#{wrap_class}>#{copy_button}#{result}</div" if filename.blank? && language.blank?
compose_filename_and_language(result, filename, language)
end
def rouge_formatter(_options = {})
options = {
css_class: 'hightlight',
line_numbers: true,
line_format: '<span>%i</span>'
}
Rouge::Formatters::HTMLLegacy.new(options)
end
private
# wrap CSSクラス名の定義
def wrap_class
'highlight-wrap'
end
# コピーボタンの定義。クリックするとJavaScriptファンクションが実行される
def copy_button
"<button onclick='copy(this)'>Copy</button>"
end
# コードブロックの言語、ファイル名、コピーボタンを設置する
def compose_filename_and_language(result, filename, language)
info_section = [filename, language].select(&:present?).map.with_index do |text, i|
i.zero? ? "<span class='highlight-info'>#{text}</span>" : nil
end.compact.join
%(<div class=#{wrap_class}>
#{copy_button}
#{info_section}
#{result}
</div>
)
end
end
module MarkdownHelper
def markdown(text)
options = {
with_toc_data: true,
hard_wrap: true
}
extensions = {
no_intra_emphasis: true,
tables: true,
fenced_code_blocks: true,
autolink: true,
lax_spacing: true,
lax_html_blocks: true,
footnotes: true,
space_after_headers: true,
strikethrough: true,
underline: true,
highlight: true,
quote: true
}
renderer = CustomRenderHTML.new(options)
markdown = Redcarpet::Markdown.new(renderer, extensions)
markdown.render(text)
end
def toc(text)
renderer = Redcarpet::Render::HTML_TOC.new(nesting_level: 6)
markdown = Redcarpet::Markdown.new(renderer)
markdown.render(text)
end
end
スタイル
コードブロックのテーマを設定する
固有のテーマ
Base16, BlackWhiteTheme, Colorful, Github, Gruvbox, IgorPro, Magritte, Molokai, Monokai, MonokaiSublime, Pastie, ThankfulEyes, Tulip
例えばMonokai
テーマを使う場合は:
1
rougify style monokai > app/assets/stylesheets/monokai.scss
自分はTailwind CSS
使うためCSSがリセットされるので、マークダウン用のCSSも用意します。
ActionText
にも同じようなスタイルを使いたいのでmixin
を作っておきます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// Mixins
@mixin richText_wrap {
font-size: 18px;
line-height: 1.7em;
word-break: break-word;
// Horizontal scroll bar
::-webkit-scrollbar {
-webkit-appearance: none;
width: 7px;
}
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #ddd;
-webkit-box-shadow: 0 0 1px rgba(255,255,255,.5);
}
}
@mixin richText_codeBlock {
font-family: 'Fira Code', monospace;
background-color: lightgray;
padding: 1rem;
margin-bottom: 1rem;
pre {
font-size: 1em;
margin: 0;
padding: 0;
}
}
@mixin richText_code {
background-color: lightgray;
padding: 0.25em;
}
@mixin richText_table {
margin: 1em auto;
th, td {
border: 1px solid #bbb;
padding: 0.25em;
}
th {
background-color: dimgray;
color: white;
}
tr:nth-of-type(2n) {
background-color: #eee;
}
}
@mixin richText_a {
color: blue;
&:hover {
text-decoration: underline;
}
}
@mixin richText_blockquote {
background: #ddd;
padding: 1em;
border-left: 8px solid gray;
}
@mixin richText_hr {
margin: 2em 0;
border: 1px solid lightgray;
}
@mixin richText_h {
display: block;
font-weight: bold;
line-height: 1.2em;
}
@mixin richText_h1 {
margin: 1.83em 0 0.5em;
font-size: 1.7em;
background-color: #155e75;
color: white;
border-left: 8px solid #fbbf24;
padding: 0.2em 0.4em;
}
@mixin richText_h2 {
margin: 1.67em 0 0.5em;
font-size: 1.5em;
border-bottom: 1px solid lightgray;
padding-bottom: 0.25em;
}
@mixin richText_h3 {
margin: 1.33em 0 0.5em;
font-size: 1.4em;
}
@mixin richText_h4 {
margin: 1.17em 0 0.5em;
font-size: 1.2em;
}
@mixin richText_h5 {
margin: 0.67em 0 0.5em;
font-size: 0.85em;
}
@mixin richText_h6 {
margin: 0.67em 0 0.5em;
font-size: 0.7em;
}
@mixin richText_l {
margin-left: 1em;
ul, ol {
margin-left: 1.5em;
}
}
@mixin richText_ol {
list-style-type: decimal;
}
@mixin richText_ul {
list-style-type: disc;
}
@mixin richText_p {
margin-bottom: 1em;
}
// Toc
.toc {
a {
&:hover {
text-decoration: underline;
}
}
.toc-current {
background-color: #ddd;
}
.toc-item {
padding: 0.1em 0;
a {
padding: 0.25em 0.5em;
}
}
.toc-h2 { margin-left: 1.25em }
.toc-h3 { margin-left: 2.5em }
.toc-h4 { margin-left: 3.75em }
.toc-h5 { margin-left: 5em }
.toc-h6 { margin-left: 6.25em }
}
// Markdown
.markdown {
@include richText_wrap;
.highlight-wrap {
margin: 1em 0;
position: relative;
&:hover {
button {
opacity: 1; // コードブロックをホバーしたらコピーボタンが現れる
}
}
// ファイル名
.highlight-info {
background: dimgray;
color: white;
padding: 4px 8px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
display: flex;
align-items: center;
&:before {
content: "\26AB";
color: lightgreen;
margin-right: 0.25em;
}
}
// コードブロック
.highlight {
overflow-x: scroll;
padding: 0.75em;
.lineno {
color: #ccc;
}
}
// コピーボタン
button {
position: absolute;
opacity: 0.3;
top: 2px;
right: 8px;
padding: 0.2em 0.8em;
background-color: #ecfdf5;
border-radius: 1em;
&:hover {
background-color: lightgreen;
}
}
}
table:not(.rouge-table) { @include richText_table }
hr { @include richText_hr }
h1, h2, h3, h4, h5, h6 { @include richText_h }
h1 { @include richText_h1 }
h2 { @include richText_h2 }
h3 { @include richText_h3 }
h4 { @include richText_h4 }
h5 { @include richText_h5 }
h6 { @include richText_h6 }
blockquote { @include richText_blockquote }
a { @include richText_a }
ol, ul { @include richText_l }
ol { @include richText_ol }
ul { @include richText_ul }
p > code { @include richText_code }
& > p { @include richText_p }
}
そしてSCSSファイルをインポートする
1
2
@import 'monokai';
@import 'markdown';
Viewsで使用
1
2
3
4
5
6
article
// 記事の項目、項目をクリックするとその項目の内容までスクロールされる
.toc == toc @article.markdown_content
// マークダウン形式の内容
.markdown == markdown(@article.markdown_content)
ちゃんとマークダウン形式とMonokai
テーマのコードブロックになるはずです。
コピーボタンの実装
コピーボタンをクリックするとクリップボードにコードを入れて、コピーしたコードが選択されたようにする
1
2
3
4
5
6
7
8
9
10
11
window.copy = function(e) {
// クリックしたボタンに紐づくコードの範囲の定義
let code = e.closest('.highlight-wrap').querySelector('.rouge-code')
// クリップボードにコードをコピーしてから、ボタンのテキストを変更する
navigator.clipboard.writeText(code.innerText)
.then(() => e.innerText = 'Copied')
// 任意:コピーしたコードが選択されたようにする
window.getSelection().selectAllChildren(code)
}
結果
ライププレビュー
APIを作成する
1
2
3
4
5
namespace :api, format: :json do
namespace :v1 do
post '/articles/preview', to: 'articles#preview'
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
module Api
module V1
class ArticlesController < ApiController
include MarkdownHelper # 先ほど作成したヘルパー
# POST /api/v1/articles/preview
def preview
content = markdown(params[:content])
render json: { content: }
end
end
end
end
JavaScriptコードを書く
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
window.addEventListener('turbo:load', function(){
let editArea = document.getElementById('article_markdown_content') // テキストエリア
let previewArea = document.getElementById('preview') // プレビューエリア
if (!editArea || !previewArea) return // テキストエリアとプレビューエリアがなかったらリターン
// タイピングが1秒停止したらプレビューする、タイピングし続ける時はプレビューしない。
editArea.addEventListener('keyup', delay(function() {
preview()
}, 1000))
// POST リクエストして、マークダウンした形のHTMLを取得する
function preview() {
let content = editArea.value
fetch('/api/v1/articles/preview', {
headers: { 'Content-Type': 'application/json' },
method: 'POST',
body: JSON.stringify({ content })
})
.then((response) => response.json())
.then(data => {
previewArea.innerHTML = data.content
console.log('Updated preview')
})
.catch(() => console.warn('Error occurred while updating preview'))
}
// 遅延ファンクションの定義
function delay(callback, ms) {
let timer = 0
return function() {
let context = this, args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
callback.apply(context, args)
}, ms || 0);
}
}
})
ライブプレビューを試す
ファイルアップロード
テキストエリアにファイルをドロップしてアップロードするにはInlineAttactment
を使います。
必要なファイルをダウンロード
こちらでinline-attachment.js
とinput.inline-attachment.js
をダウンロードします。
https://github.com/Rovak/InlineAttachment/tree/master/src
ダウンロードしたファイルをプロジェクトにインポートする
1
2
3
import '../src/markdown'
import '../src/inline_attachment' // 追加
import '../src/input.inline_attachment' // 追加
アップロード用のAPIを準備する
1
2
3
4
5
6
7
8
# frozen_string_literal: true
namespace :api, format: :json do
namespace :v1 do
post '/articles/preview', to: 'articles#preview'
post '/articles/upload', to: 'articles#upload' # 追加
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# frozen_string_literal: true
module Api
module V1
class ArticlesController < ApiController
# POST /api/v1/articles/upload
def upload
file = params[:file]
file_name = SecureRandom.hex(20)
upload_file = file.tempfile
s3 = Aws::S3::Resource.new(
region: ENV.fetch('AWS_REGION', nil),
access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil),
secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
)
obj = s3.bucket(ENV.fetch('AWS_S3_BUCKET', nil)).object(file_name)
obj.upload_file(upload_file, { acl: 'public-read' })
render json: { filename: obj.public_url }, status: :ok
end
end
end
end
注意:AWSのS3バケットをパブリックにする必要があります。
アップロード用のJavaScriptを書く
先ほど作成したmarkdown.js
にコードを追加する
1
2
3
4
5
6
7
8
inlineAttachment.editors.input.attachToInput(editArea, {
uploadUrl: "/api/v1/articles/upload",
uploadFieldName: 'file',
allowedTypes: ['image/jpeg', 'image/png', 'image/jpg', 'image/gif'],
progressText: '![ファイルをアップロード中...]()',
errorText: 'エラーが発生しました!',
onFileUploaded: () => { preview() } // アップロード成功したらプレビューに反映する
})
結果
できました。これから個人のアプリケーションで記事を書く時は楽になりました :relaxed:
任意:画像を加工してからアップロードする
理由
あくまでも記事に表示するだけの画像なのででかいサイズは必要ないと思いますし、S3バケットの容量を節約したくてページの読み込み時間も短くしたいのです。
対策
RMagic
を使って、ローカルで画像を加工してからS3にアップロードします。
RMagicをインストールする
1
gem 'rmagick'
1
2
3
package MagickCore was not found in the pkg-config search path.
Perhaps you should add the directory containing `MagickCore.pc'
to the PKG_CONFIG_PATH environment variable
libmagickwand-dev
をインストールしてからGemを再インストールする
1
sudo apt-get install libmagickwand-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# POST /api/v1/articles/upload
def upload
@file = params[:file]
compress_image # 画像を加工する
file_name = "#{SecureRandom.hex(20)}.webp"
upload_file = @file.tempfile
s3 = Aws::S3::Resource.new(
region: ENV.fetch('AWS_REGION', nil),
access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID', nil),
secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
)
obj = s3.bucket(ENV.fetch('AWS_S3_BUCKET', nil)).object(file_name)
obj.upload_file(upload_file, { acl: 'public-read' })
render json: { filename: obj.public_url }, status: :ok
end
private
def compress_image
image = Magick::Image.from_blob(@file.read).first
# もし画像の長さは1024px以上だったらリサイズする
image.resize_to_fit!(1024) if image.columns > 1024
image.format = 'webp' # webpならページの読み込み時間が短縮するらしい
image.write(@file.path)
rescue StandardError
nil # 万が一加工が失敗したら元々の画像をアップロードする
end
テスト
この大きな画像をアップロードしてみます。
10秒ぐらいかかりましたが、非同期処理でエディターもそのまま使えるのであまり不便はないです。
そして、アップロードしたファイルをダウンロードしてみると:
サイズと容量が結構減らしました! これで画像の沢山ある記事でも安心できると思います。