Stimulusでドラッグ&ドロップ時に画像のアップロードを行う

はてなフォトっぽいやつを実装してみます。

やり方

view

formのinput file は非表示にしておきます(d-noneスタイル)。
Stimulusのcontrollerは file-drop-controller というものを用意し、ドラッグ&ドロップの操作はここで行うようにします。
click時の操作は元々用意していた preview-controller にて実施します。

# haml
      .uploading-image.pt-3
        = form_with url: user_images_path(@user), local: true, data: { controller: "preview file-drop" } do |f|
          .input-group.d-none
            = f.file_field :images, class: "form-control override-bs-file image-selection", data: {"action": "input->preview#show", "file-drop-target": "file", "preview-target": "file" }          .drag-and-drop-image.mt-2
            .drag-and-drop-image-message{"data-action": "click->preview#selectImage dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop drop->preview#show"}
              クリックしてファイルを選択するか、ここにファイルをドラッグしてください

Stimulus

簡単なclick時の操作から。
上記のようにviewとしては、Stimulusで扱えるようにinput fileにtargetを追加し、clickイベント発生時に selectImage actionを呼ぶようにしておきます。
Stimulusでは click() を実行するだけ。

export default class extends Controller {
  static targets = ["content", "modal", "file"]

  selectImage() {
    this.fileTarget.click()
  }
}

続いてドラッグ&ドロップ。 こちらもviewでdragover, dragleave, dropイベントを用意します。
drop後にはmodalでpreview表示するために、preview#show も実行するようにしています。)
Stimulusではcssで背景を変更するためのstyleの追加・削除を行い、drop時にinput fileへの設定を行なっているだけです。

// app/javascript/controllers/file_drop_controller.js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static targets = ["file"]

  dragover(e) {
    e.preventDefault()
    e.target.classList.add("dragging")
  }

  dragleave(e) {
    e.preventDefault()
    e.target.classList.remove("dragging")
  }

  drop(e) {
    e.preventDefault()
    e.target.classList.remove("dragging")

    const files = e.dataTransfer.files
    if (typeof files[0] !== 'undefined') {
      this.fileTarget.files = files
    }
  }
}
//scss

  // uploading image
  .drag-and-drop-image {
    border: 2px dashed silver;
    cursor: pointer;
    font-size: 1.2em;
    text-align: center;

    .drag-and-drop-image-message.dragging {
      background-color: rgb(233, 233, 233);
    }
  }

感想

まぁ、Stimlus難しくはないんですよね。単純なやつならいいかなという気はするのですが、もう少しcontrollerとactionが増えてくると辛みがありそうな気がします。

See Also

cropper.jsを使ってActiveStorageで画像アップロード時に切り抜きを行う
プレビューの実装はこちら

参考

Stimulusを使ってドラッグ&ドロップでファイルアップロード - Qiita

cropper.jsを使ってActiveStorageで画像アップロード時に切り抜きを行う

ググってもあんまり出てこなかったのでメモを残しておきます。
JSは Cropper.js を利用しています。ライセンスはMITです。
 

こんな感じ

やり方

cropper.jsを使って画像を表示し、保存ボタン押下時に切り抜くための情報をRailsに送ります。 Rails側では保存前に切り抜き処理を行い、保存するだけ。

view

ライブラリ

yarn add で cropperjs を入れます。

file inputを用意します

Stimulusでmodalを表示する設定も入れておきます。

# show.html.haml
      .uploading-images.pt-3
        = form_with url: user_images_path(@user), local: true, data: { controller: "preview" } do |f|
          .input-group
            = f.file_field :images, class: "form-control override-bs-file image-selection", "data-action": "input->preview#show"

modalで表示

ポイントはhidden_fieldに cropper.js で取得できる切り抜くための値をセットします。
他にもrotateやscaleも取れます。

          #preview-modal.modal{"aria-hidden" => "true", tabindex: "-1", "data-preview-target": "modal"}
            .modal-dialog
              .modal-content
                .modal-header
                  %button.btn-close{"aria-label": "Close", "data-bs-dismiss": "modal", type: "button"}
                .modal-body
                  .modal-preview-content{ "data-preview-target": "content" }
                .modal-footer
                  = f.submit "保存", class: "btn btn-outline-dark small modal-submit"
                  = f.hidden_field :image_x
                  = f.hidden_field :image_y
                  = f.hidden_field :image_w
                  = f.hidden_field :image_h

stimlus

modal表示する際に、input fileから画像を読み取りcropper.jsに渡します。

# app/javascript/controllers/preview_controller.js
import { Controller } from "@hotwired/stimulus"
import * as bootstrap from "bootstrap"
import Cropper from "cropperjs"

export default class extends Controller {
  static targets = ["content", "modal"]
  static bsModal

  initialize() {
    this.bsModal = new bootstrap.Modal(this.modalTarget)
  }

  show(e) {
    if (e.target.value !== "") {
      this.bsModal.show()
      const file = e.target.files[0]
      const blob = window.URL.createObjectURL(file)

      const img = document.createElement("img")
      img.setAttribute("src", blob)
      img.classList.add("preview-img")

      this.contentTarget.innerHTML = ''
      this.contentTarget.appendChild(img)

      new Cropper(img, {
        scalable: false,
        rotatable: false,
        zoomable: false,
        crop(event) {
          document.getElementById("image_x").value = event.detail.x;
          document.getElementById("image_y").value = event.detail.y;
          document.getElementById("image_w").value = event.detail.width;
          document.getElementById("image_h").value = event.detail.height;
        }
      })
    }
  }
}

Rails

ライブラリ

画像編集のために、image_processing をインストールします。
かつてはImageMagickが利用されていましたが、今はlibvipsがデフォルトで利用されるとのとこ。
macbrew install vips でインストールできます。

gem "image_processing", ">= 1.2"

Rails Controller

crop を使います。
crop! で切り抜きが実行されます。

  def create
    user = User.find(params[:user_id])

    uploaded_image = params[:images]
    cropped_image = ImageProcessing::Vips
      .source(uploaded_image.tempfile.path)
      .crop!(params[:image_x].to_f, params[:image_y].to_f, params[:image_w].to_f, params[:image_h].to_f)
    uploaded_image.tempfile = cropped_image

    user.images.attach(uploaded_image)

    redirect_to user_path(user), notice: "画像を登録しました。"
  end

参考

Rails,Cropper,CarrierWave,MiniMagickで画像を任意の位置でトリミングして登録するまで - Qiita

RailsのHotwireでCSSアニメーションさせる方法(登録処理もTurbo/Stimlusで実装)

昨日の続きです。
RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法2(Stimlus化)

画像の登録もTurbo/Stimulusで実施してなかったので修正してみます。

やってみる

css

fade-in用を追加。

.fade-in {
  animation: fade-in .4s linear;
  opacity: 1;
}
.fade-out {
  animation: fade-out .4s linear;
  opacity: 0;
}
@keyframes fade-in {
  0%   { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes fade-out {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

html

登録用にturbo_frame_tagで囲います。animationを追加したいので、data属性にcontrollerも追加。

# show.html.haml
= turbo_frame_tag :user_images, data: { controller: "stream-animations" } do
  = render partial: "image", collection: @user.images, locals: { user: @user }

Stimulsでアニメーション用のcssを取得できるようにdata属性にleaving_classを追加。

# users/_image.html.haml
= turbo_frame_tag "user_image_#{image.id}", data: { entering_class: "fade-in", leaving_class: "fade-out" } do
  .image-wrapper.position-relative
    = image_tag image
    = link_to user_image_path(user, image), remote: true, data: { turbo_method: :delete, turbo_confirm: "Can I delete it?" }, class: "delete-image-link position-absolute top-0 end-0" do
      %span ×

Railsのcontroller

createでturbo_stream.appendを行います。

class Users::ImagesController < ApplicationController
  def create
    user = User.find(params[:user_id])
    user.images.attach(params[:images])
    image = user.images.last

    render turbo_stream: turbo_stream.append(:user_images, partial: "users/image", locals: { user:, image: })
  end

  def destroy
    user = User.find(params[:user_id])
    user.images.find(params[:id]).purge

    render turbo_stream: turbo_stream.remove("user_image_#{params[:id]}")
  end
end

Stimlus

append用の処理を追加。
template, turboFrameElm の辺りが謎ですが、これで追加される要素が取得できます。
なので、これにアニメーション用のクラスを追加すればokです。

# stream_animations_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    document.addEventListener("turbo:before-stream-render", this.animate)
  }

  disconnect() {
    document.removeEventListener("turbo:before-stream-render", this.animate)
  }

  animate(e) {
    const turboStreamElm = e.target
    const { action, target } = turboStreamElm

    if (action === "append") {
      const template = turboStreamElm.firstElementChild
      const turboFrameElm = template.content.firstElementChild
      const { enteringClass } = turboFrameElm.dataset
      if (enteringClass) {
        turboFrameElm.classList.add(enteringClass)
      }
    } else if (action === "remove") {
      const turboFrameElm = document.getElementById(target)
      const { leavingClass } = turboFrameElm.dataset
      if (leavingClass) {
        e.preventDefault()
        turboFrameElm.classList.add(leavingClass)
        turboFrameElm.addEventListener("animationend", () => {
          e.target.performAction()
        })
      }
    }
  }
}

一応できましたが、、、

stimulsの処理自体は大した行数ではないですが、若干面倒に感じます。cssアニメーションはデフォルト機能として欲しいところ。

参考

Add a `streamElement` property to the `turbo:before-stream-render` event by nbgoodall · Pull Request #20 · hotwired/turbo

See Also

RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法
RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法2(Stimlus化)