Summernote-rails Update(3)

http://bit.ly/summernote-rails-for-fileupload-1 on Jun. 6, 2015, in Korean

http://bit.ly/summernote-rails-for-fileupload_2 on Jan. 2, 2016, in Korean

In my blog, the posts on summernote-rails gem are always the most frequent in daily visit statistics.

About 22 months were passed since last post on summernote. Official published version of summernote-rails is 0.8.3, as of Nov. 13, 2017.

Rails version is bumped up to 5.1.4 and ruby 2.4.2. And, maybe, Bootstrap will be updated to the official verion 4 as soon as possible. Right now, Bootstrap version is 4.0.0.beta2.1.

But, the edge version of summernote-rails in GitHub repository is 0.8.8.

In this post, I’ll update how to use summernote-rails gem and, introduce how to upload and delete image files in the summernote editor.

First of all, my local dev environment is as follows:

$ ruby -v
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-darwin16]
$ rails -v
Rails 5.1.4

Let’s get started.

1. Create new project

Open your favorite terminal and create new project called “summernote088“. Of course, we’ll use the default serverless database, sqlite3.

$ rails new summernote088

If you want another database, you can add your favorite database with ‘-d’ option. For example, if you would use mysql or postgresql, you could add an option as follows:

$ rails new summernote088 -d [mysql|postgresql]
$ cd summernote088

2. commit initial state

At this time, you can commit the auto-generated source codes to local repository if you want.

$ git add .
$ git commit -m "initial commit"

3. create develop branch and checkout

And you can add the ”develop” branch to checkout to.

$ git checkout -b develop

4. install jquery gem

We’ll use Bootstrap, which is dependent on jQuery library and so you should add jquery-rails gem. The reason is why this project is implemented using Rails 5.1+ which is not any more dependent on jQuery and does not include by default in Gemfile. (https://github.com/rails/jquery-rails)

gem 'jquery-rails', '~> 4.3.1'

Note: From Rails 5.1, rails-ujs library substitute everything jquery_ujs does. So jquery_ujs is not needed any more on using jquery. https://gorails.com/forum/do-i-need-rails-ujs-and-jquery_ujs

After bundling Gemfile, you should add the following code line at the top of the app/assets/javascripts/ application.js.

//= require jquery3

Note: jquery3 means jquery version 3. You can use jquery2 or just jquery.

5. install bootstrap gem

As of November, 2017, the latest version of Bootstrap is 4.0.0.beta2.1. (https://github.com/twbs/bootstrap-rubygem)

Next, add the following code line to Gemfile and bundle install

gem 'bootstrap', '~> 4.0.0.beta2.1'

To use Bootstrap fully, you need to rename application.css file to application.scss. After that, you delete all contents of that file and add the following code line.

@import "bootstrap";

Also, update application.js as follows:

//= require jquery3
//= require popper
//= require bootstrap

Note: In develop mode, you can replace //= require bootstrap with //= require bootstrap-sprockets which provides individual Bootstrap components for ease of debugging.

5. install simple_form

We’ll use ‘simple_form’ gem.

gem 'simple_form'

After running ‘bundle install’ in terminal, you should install simple_form with ‘–bootstrap‘ option.

$ rails generate simple_form:install --bootstrap

As of now, Simpe_form v3.5.0, it does not support Bootstrap 4 beta. And so you should fix config/initializers/simple_form_bootstrap.rb file as follows:

# Reference - https://github.com/printercu/rails_sf_bs4/blob/master/config/initializers/simple_form_bootstrap.rb
# Use this setup block to configure all options available in SimpleForm.
# https://github.com/plataformatec/simple_form/pull/1476
SimpleForm::Inputs::Base.prepend Module.new {
  def merge_wrapper_options(options, wrapper_options)
    if wrapper_options&.key?(:error_class)
      wrapper_options = wrapper_options.dup
      error_class = wrapper_options.delete(:error_class)
      wrapper_options[:class] = "#{wrapper_options[:class]} #{error_class}" if has_errors?
    end
    super(options, wrapper_options)
  end
}

SimpleForm.setup do |config|
  config.error_notification_class = 'alert alert-danger'
  config.button_class = 'btn btn-primary'
  config.boolean_label_class = 'form-check-label'
  config.boolean_style = :nested
  config.item_wrapper_tag = :div
  config.item_wrapper_class = 'form-check'

  # Helpers
  wrapper_options = {class: 'form-group'}
  input_options = {error_class: 'is-invalid'}
  label_class = 'col-form-label'

  horizontal_options = wrapper_options.merge(class: 'form-group row')
  horizontal_label_class = "col-sm-3 #{label_class}"
  horizontal_right_class = 'col-sm-9'
  horizontal_right_offset_class = 'offset-sm-3'

  inline_class = 'mb-2 mr-sm-2 mb-sm-0'

  basic_input = ->(b, type = :basic) do
    b.use :html5
    b.use :placeholder
    break if type == :boolean
    b.optional :maxlength
    b.optional :minlength
    unless type == :file
      b.optional :pattern
      b.optional :min_max
    end
    b.optional :readonly
  end

  error_and_hint = ->(b) do
    b.use :error, wrap_with: {tag: 'span', class: 'invalid-feedback'}
    b.use :hint,  wrap_with: {tag: 'small', class: 'form-text text-muted'}
  end

  # Vertical forms
  config.wrappers :vertical_form, **wrapper_options do |b|
    basic_input.call(b)
    b.use :label, class: label_class
    b.use :input, **input_options, class: 'form-control'
    error_and_hint.call(b)
  end

  config.wrappers :vertical_file_input, **wrapper_options do |b|
    basic_input.call(b, :file)
    b.use :label, class: label_class
    b.use :input, **input_options, class: 'form-control-file'
    error_and_hint.call(b)
  end

  config.wrappers :vertical_boolean, **wrapper_options, class: 'form-check' do |b|
    basic_input.call(b, :boolean)
    b.use :label_input, class: 'form-check-input'
    error_and_hint.call(b)
  end

  config.wrappers :vertical_radio_and_checkboxes, **wrapper_options do |b|
    basic_input.call(b, :boolean)
    b.use :label, class: label_class
    b.use :input, **input_options, class: 'form-check-input'
    error_and_hint.call(b)
  end

  # Horizontal forms
  config.wrappers :horizontal_form, **horizontal_options do |b|
    basic_input.call(b)
    b.use :label, class: horizontal_label_class
    b.wrapper class: horizontal_right_class do |ba|
      ba.use :input, **input_options, class: 'form-control'
      error_and_hint.call(ba)
    end
  end

  config.wrappers :horizontal_file_input, **horizontal_options do |b|
    basic_input.call(b, :file)
    b.use :label, class: horizontal_label_class
    b.wrapper class: horizontal_right_class do |ba|
      ba.use :input, **input_options, class: 'form-control-file'
      error_and_hint.call(ba)
    end
  end

  config.wrappers :horizontal_boolean, **horizontal_options do |b|
    basic_input.call(b, :boolean)
    b.wrapper class: "#{horizontal_right_class} #{horizontal_right_offset_class}" do |wr|
      wr.wrapper class: 'form-check' do |ba|
        ba.use :label_input, class: 'form-check-input'
      end
      error_and_hint.call(wr)
    end
  end

  config.wrappers :horizontal_radio_and_checkboxes, **horizontal_options do |b|
    basic_input.call(b, :boolean)
    b.use :label, class: horizontal_label_class
    b.wrapper class: horizontal_right_class do |ba|
      ba.use :input, **input_options, class: 'form-check-input'
      error_and_hint.call(ba)
    end
  end

  # Inline forms
  config.wrappers :inline_form, class: inline_class do |b|
    basic_input.call(b)
    b.use :label, class: 'sr-only'
    b.use :input, **input_options, class: 'form-control'
    error_and_hint.call(b)
  end

  config.wrappers :inline_boolean, class: "form-check #{inline_class}" do |b|
    basic_input.call(b, :boolean)
    b.use :label_input, class: 'form-check-input'
    error_and_hint.call(b)
  end

  # Multiple selects
  config.wrappers :multi_select, **wrapper_options do |b|
    basic_input.call(b, :boolean)
    b.use :label, class: label_class
    b.wrapper class: 'multi-select d-flex' do |ba|
      ba.use :input, **input_options, class: 'form-control'
    end
    error_and_hint.call(b)
  end

  config.wrappers :horizontal_multi_select, **horizontal_options do |b|
    basic_input.call(b, :boolean)
    b.use :label, class: horizontal_label_class
    b.wrapper class: horizontal_right_class do |wr|
      wr.wrapper class: 'multi-select d-flex' do |ba|
        ba.use :input, **input_options, class: 'form-control'
      end
      error_and_hint.call(wr)
    end
  end

  # Wrappers for forms and inputs using the Bootstrap toolkit.
  # Check the Bootstrap docs (http://getbootstrap.com)
  # to learn about the different styles for forms and inputs,
  # buttons and other elements.
  config.default_wrapper = :vertical_form
  config.wrapper_mappings = {
    check_boxes: :vertical_radio_and_checkboxes,
    radio_buttons: :vertical_radio_and_checkboxes,
    file: :vertical_file_input,
    boolean: :vertical_boolean,
    datetime: :multi_select,
    date: :multi_select,
    time: :multi_select,
  }
end

Tip: you just had better replace the original file with the above codebase.

Here, there is one point to update. You need to changebtn btn-default to btn btn-primary because btn-default class was deprecated in Bootstrap 4 beta 2.

7. install summernote-rails gem

And now, it’s time to add summernote-rail gem. Current available version of summernote-rails published in rubygems.org is 0.8.3. But you can find the edge version 0.8.8 in GitHub repository.

gem 'summernote-rails', github: 'summernote/summernote-rails'

After bundling, in app/assets/stylesheets/application.scss, you should import summernote stylesheet for Bootstrap 4. Additionally, you need to customize the editor styles and so to add new “summernote-custom-theme” stylesheet.

@import "bootstrap";
@import "summernote-bs4";
@import "summernote-custom-theme";

app/assets/stylesheets/summernote-custom-theme.scss,

.note-editor {
  .note-btn {
    background-color: white;
    border-color: #ccc;
  }
  .help-list-item + label {
    display: inline-block;
  }
  .modal-header {
    button.close {
      font-size: 1.2em;
    }
  }
  .modal-footer {
    display: inline-block;
    p:last-child {
      margin-bottom: 0 !important;
    }
  }
}

In app/assets/javascripts/application.js, you should add as follows:

//= require ...
//= require bootstrap
//= require summernote/summernote-bs4
//= require summernote/locales/ko-KR
//= require ...

Additionally, you need create app/assets/javascripts/summernote-init.coffee as follows:

$(document).on 'turbolinks:load', ->
  $('[data-provider="summernote"]').each ->
    $(this).summernote
      lang: 'ko-KR'

and insert it in application.js

//= require ...
//= require bootstrap
//= require summernote/summernote-bs4
//= require summernote/locales/ko-KR
//= require summernote-init
//= require ...

8. scaffolding Post model

Using the scaffold generator of Rails, generate Post resource.

$ rails g scaffold Post title content:text

Post model has two attributes: title and content. Title is string-typed and content text-typed.

On running the above command, the migration file creating posts table is also created. And so you need to run rails db:create before rails db:migrate in your terminal. Finally, posts table will be created physically on your database.

Link tag style of scaffolds.scss is ugly and so you need to customize as the follows:

a {
  color: #000;

  &:visited {
    color: rgb(181, 181, 181);
  }

  &:hover {
    columns: #000;
    background-color: #fff;
  }
}

And add this stylesheet to application.scss as follows:

@import "bootstrap";
@import "summernote-bs4";
@import "summernote-custom-theme";
@import "scaffolds";
@import "posts";

9. Set root path

Now that you created the first resource, you can set up root path in config/routes.rb,

root "posts#index"

10. Modify posts/_form.html.erb

The most important point is that you should add an option (as: :summernote) to the following code line.

<%= f.input :content, as: :summernote %>

This option will produce the data-provider=”summernote” attribute in the following html rendered.

11. install carrierwave gem

In summernote editor, you can insert an image using editor menu icon (picture) or using drag and drop local image files to the editor. For file uploading, we’ll use ‘carrierwave’ gem. Add the following to Gemfile and run “bundle install” in your terminal.

gem 'carrierwave'

Now, you need create Upload model to store uploaded image information. This model has just one attribute called “image”. And generate the uploader for this Upload model

$ rails g model Upload image
$ rails g uploader Image

To create and delete uploaded images, you need the controllers for Upload model instances as the follows:

$ rails g controller uploads create destroy

Let’s write codes in app/controllers/uploads/uploads_controller.rb as follows:

class UploadsController < ApplicationController

  def create
    @upload = Upload.new(upload_params)
    @upload.save

    respond_to do |format|
      format.json { render :json => { url: @upload.image.url, upload_id: @upload.id } }
    end
  end

  def destroy
    @upload = Upload.find(params[:id])
    @remember_id = @upload.id
    @upload.destroy
    FileUtils.remove_dir("#{Rails.root}/public/uploads/upload/image/#{@remember_id}")
    respond_to do |format|
      format.json { render :json => { status: :ok } }
    end
  end

  private

  def upload_params
    params.require(:upload).permit(:image)
  end
end

Also, we need to add resource routing for uploads controller in config/routes.rb,

resources 'uploads', only: [:create, :destroy]

You can code sendFile and deleteFile function and update app/assets/javascripts/summernote-init.coffee as the follows:

sendFile = (file, toSummernote) ->
  data = new FormData
  data.append 'upload[image]', file
  $.ajax
    data: data
    type: 'POST'
    url: '/uploads'
    cache: false
    contentType: false
    processData: false
    success: (data) ->
      img = document.createElement('IMG')
      img.src = data.url
      console.log data
      img.setAttribute('id', "sn-image-#{data.upload_id}")
      toSummernote.summernote 'insertNode', img      

deleteFile = (file_id) ->
  $.ajax
    type: 'DELETE'
    url: "/uploads/#{file_id}"
    cache: false
    contentType: false
    processData: false

$(document).on 'turbolinks:load', ->
  $('[data-provider="summernote"]').each ->
    $(this).summernote
      lang: 'ko-KR'
      height: 400
      callbacks:
        onImageUpload: (files) ->
          sendFile files[0], $(this)
        onMediaDelete: (target, editor, editable) ->
          upload_id = target[0].id.split('-').slice(-1)[0]
          console.log upload_id
          if !!upload_id
            deleteFile upload_id
          target.remove()

That’s it.

Source : https://github.com/luciuschoi/summernote088