Ruby on RailsでQiitaエディターみたいなマークダウン機能を作る。TOC、ライププレビュー、ファイルアップロード、ファイル名、コピーボタンなど全て対応！

こんにちは。ずっと前からQiitaのエディターみたいなマークダウンエディターを作りたかったです。
今回はRailsアプリケーションでマークダウンが使えるまでの実装をご紹介いたします。jQueryは使いません。この記事の内容は以下になります。

- マークダウンの導入
- TOC (Table of contents)
- コードブロックのテーマの設定
- コードブロックの上にファイル名の表示
- コピーボタンの実装
- ライププレビュー
- ファイルアップロード
- アップロードの前に画像を圧縮したりリサイズしたりする

結果はこのようになります。

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/792a9929-0cea-d0ca-aa88-5a0309f3f27c.gif)

**では、始めましょう！**


# マークダウンの導入

### まずは必要なGemをインストールする

```ruby:Gemfile
gem 'redcarpet' # Markdown parser
gem 'rouge' # Syntax highlight
```

### そしてヘルパーファイルを作成する

```ruby:app/helpers/markdown_helper.rb
# 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`テーマを使う場合は：

```
rougify style monokai > app/assets/stylesheets/monokai.scss
```

するとSCSSファイルがコピーされます。

自分は`Tailwind CSS`使うためCSSがリセットされるので、マークダウン用のCSSも用意します。
`ActionText`にも同じようなスタイルを使いたいので`mixin`を作っておきます。

```scss:app/assets/stylesheets/markdown.scss
// 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ファイルをインポートする

```scss:app/assets/stylesheets/application.scss
@import 'monokai';
@import 'markdown';
```

# Viewsで使用

```slim:app/views/**.html.slim
article
  // 記事の項目、項目をクリックするとその項目の内容までスクロールされる
  .toc == toc @article.markdown_content

  // マークダウン形式の内容
  .markdown == markdown(@article.markdown_content)
```

ちゃんとマークダウン形式と`Monokai`テーマのコードブロックになるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/9dc1fb66-9460-5fef-62ea-1166f7f65aab.png)


# コピーボタンの実装

コピーボタンをクリックするとクリップボードにコードを入れて、コピーしたコードが選択されたようにする

```js:app/javascript/entrypoints/application.js
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)
}
```

### 結果

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/042755ca-8d53-f470-ce17-91e6a18c342c.gif)



# ライププレビュー

## APIを作成する

```ruby:config/routes/api.rb
namespace :api, format: :json do
  namespace :v1 do
    post '/articles/preview', to: 'articles#preview'
  end
end
```

```ruby:app/controllers/api/v1/articles_controller.rb
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コードを書く

```javascript:app/javascript/src/markdown.js
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);
    }
  }
})
```

## ライブプレビューを試す

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/ab1018e8-14e4-7c19-154b-90320f77267f.gif)


# ファイルアップロード

テキストエリアにファイルをドロップしてアップロードするには`InlineAttactment`を使います。

## 必要なファイルをダウンロード

こちらで`inline-attachment.js`と`input.inline-attachment.js`をダウンロードします。
https://github.com/Rovak/InlineAttachment/tree/master/src

## ダウンロードしたファイルをプロジェクトにインポートする

```javascript:app/javascript/entrypoints/application.js
import '../src/markdown'
import '../src/inline_attachment' // 追加
import '../src/input.inline_attachment' // 追加
```

## アップロード用のAPIを準備する

```ruby:config/routes/api.rb
# 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
```

```ruby:app/controllers/api/v1/articles_controller.rb
# 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のS３バケットをパブリックにする必要があります。**


## アップロード用のJavaScriptを書く

先ほど作成した`markdown.js`にコードを追加する

```javascript:app/javascript/src/markdown.js
  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() } // アップロード成功したらプレビューに反映する
  })
```

## 結果

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/792a9929-0cea-d0ca-aa88-5a0309f3f27c.gif)

できました。これから個人のアプリケーションで記事を書く時は楽になりました :relaxed:


# 任意：画像を加工してからアップロードする

## 理由

あくまでも記事に表示するだけの画像なのででかいサイズは必要ないと思いますし、S3バケットの容量を節約したくてページの読み込み時間も短くしたいのです。

## 対策

`RMagic`を使って、ローカルで画像を加工してからS3にアップロードします。

### RMagicをインストールする

```ruby:Gemfile
gem 'rmagick'
```

```
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を再インストールする

```
sudo apt-get install libmagickwand-dev
```


### 画像加工メソッドを定義する

```ruby:app/controllers/api/v1/articles_controller.rb
      # 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
```

## テスト

この大きな画像をアップロードしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/dc769bde-66fb-1377-4209-59226aa6a3ab.png)

10秒ぐらいかかりましたが、非同期処理でエディターもそのまま使えるのであまり不便はないです。

そして、アップロードしたファイルをダウンロードしてみると：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35605/f4e30a6a-3cdc-d360-b93f-fe26ac44c3c2.png)

サイズと容量が結構減らしました！　これで画像の沢山ある記事でも安心できると思います。