Содержание

Цель
Сгенерированные файлы
Как экземпляр нашей модели связывается с экземпляром ActionText в таблице action_text_rich_texts
Как сохраняются файлы/рисунки в записях
Итого

Цель

Возможность создания, хранения и редактирования текста с рисунками, прикрепленными файлами - частая задача. Суть задачи - сделать удобный инструмент контент-менеджеру для добавления текстовых данных на сайт.

В Ruby on Rails для этой цели есть модуль ActionText с соответствующей документацией, который добавляет возможность редактирования текста в Rails. Он включает в себя редактор Trix. Редактор написан на JavaScript, запускается на стороне клиента (браузера) и рисует довольно дружественный интерфейс.

Trix-редактор
Trix-редактор

Создание используемой ниже в примерах модели Note, его контроллера, представлений (show/edit/new.html.erb), роутинга и других базовых действий, связанных с этой моделью, выходит за рамки этой статьи.

Сгенерированные файлы

Для начала работы с модулем Action Text устанавливаем нужные библиотеки командой:

bin/rails action_text:install

После выполнения этой команды вы увидите множество измененных и сгенерированных файлов:

Список сгенерированных файлов
Список сгенерированных файлов

Хорошо бы понимать, что происходит. Почитаем файлы и узнаем:

1. Gemfile, Gemfile.lock

Предыдущим действием мы установили гем image_processing. Этот пакет нужен, чтобы обрабатывать (сжимать, изменять размер) и загружать рисунки в добавляемый контент. Этот гем использует либо ImageMagick/GraphicsMagick, либо libvips.

В Gemfile.lock можно посмотреть установленные библиотеки-зависимости для image_processing. Нужность их описана здесь.

Для работы этого гема я добавила в config/application.rb строку (из описания класса ActiveStorage::Variant:

config.active_storage.variant_processor = :mini_magick

2. app/javascript/application.js

Здесь импортировали JavaScript библиотеки trix и @rails/actiontext.

3. config/importmap.rb

В этом файле создаются соответствия библиотек и сгенерированных js-файлов и отдаются клиенту в соответствии c этими названиями. Тут добавился JavaScript код для Trix. В DevTools во вкладке Debugger можно посмотреть эти соответствия. Пример на рисунке ниже, где

1 - imports,

2 - js - файлы,

3 - Trix редактор в браузере.

Напомню, что для генерации компилированных css/js файлов используется команда (см. The Asset Pipeline:

bin/rails assets:precompile

4. test/fixtures/action_text/rich_texts.yml - файл с тестовыми данными

5. app/assets/stylesheets/actiontext.css - стили по умолчанию

Содержимое файла
.trix-content .attachment-gallery > action-text-attachment,
.trix-content .attachment-gallery > .attachment {
  flex: 1 0 33%;
  padding: 0 0.5em;
  max-width: 33%;
}

.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
  flex-basis: 50%;
  max-width: 50%;
}

.trix-content action-text-attachment .attachment {
  padding: 0 !important;
  max-width: 100% !important;
}

Вроде бы обычный css файл, но мы видим, что при генерации html используется элемент action-text-attachment, которого нет в списке html-элементов, но генерируется html-элемент <action-text-attachment> с CustomElementRegistry.

6. db/migrate/20230210142600_create_active_storage_tables.active_storage.rb

В этом файле описан класс миграции, создающий таблицы в префиксом active_storage_:

* active_storage_blobs

* active_storage_attachments

* active_storage_variant_records

Более подробно эти таблицы описаны в документации Active Storage Overview.

Содержимое файла 20230210142600_create_active_storage_tables.active_storage.rb
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
  def change
    # Use Active Record's configured type for primary and foreign keys
    primary_key_type, foreign_key_type = primary_and_foreign_key_types

    create_table :active_storage_blobs, id: primary_key_type do |t|
      t.string   :key,          null: false
      t.string   :filename,     null: false
      t.string   :content_type
      t.text     :metadata
      t.string   :service_name, null: false
      t.bigint   :byte_size,    null: false
      t.string   :checksum

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :key ], unique: true
    end

    create_table :active_storage_attachments, id: primary_key_type do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false, type: foreign_key_type
      t.references :blob,     null: false, type: foreign_key_type

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end

    create_table :active_storage_variant_records, id: primary_key_type do |t|
      t.belongs_to :blob, null: false, index: false, type: foreign_key_type
      t.string :variation_digest, null: false

      t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end
  end

  private
    def primary_and_foreign_key_types
      config = Rails.configuration.generators
      setting = config.options[config.orm][:primary_key_type]
      primary_key_type = setting || :primary_key
      foreign_key_type = setting || :bigint
      [primary_key_type, foreign_key_type]
    end
end

Здесь написаны какие поля создадутся в вышесказанных таблицах, ограничения полей по уникальности первичных и внешних ключей. Например, в таблице active_storage_attachments создаются поля:

  • name с ограничением, что он не должен быть пустым,

  • record_type,

  • record_id с типом bigint, который с record_type создаются с опцией polymorphic: true (см. Active Record Migrations References),

  • blob_id с типом bigint, ссылающийся на запись id из таблицы active_storage_blobs, имеющий ограничение внешнего ключа по blob_id,

  • создается ограничение на уникальность комбинации полей record_type, record_id, name, blob_id.

7. db/migrate/20230210142601_create_action_text_tables.action_text.rb

В этом файле описан класс миграции, создающий таблицы в префиксом action_text_ (там всего одна таблица):

  • action_text_rich_texts

В этой таблице в поле body и будет хранится контент в виде html. Все ссылки на рисунки и файлы будут сгенерированы при создании этого контента и сразу встроены в текст.

Содержимое файла 20230210142601_create_action_text_tables.action_text.rb
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
  def change
    # Use Active Record's configured type for primary and foreign keys
    primary_key_type, foreign_key_type = primary_and_foreign_key_types

    create_table :action_text_rich_texts, id: primary_key_type do |t|
      t.string     :name, null: false
      t.text       :body, size: :long
      t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type

      t.timestamps

      t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
    end
  end

  private
    def primary_and_foreign_key_types
      config = Rails.configuration.generators
      setting = config.options[config.orm][:primary_key_type]
      primary_key_type = setting || :primary_key
      foreign_key_type = setting || :bigint
      [primary_key_type, foreign_key_type]
    end
end

После запуска миграции командой bin/rails db:migrate в итоге должны появится таблицы в вашей базе данных.

8. app/views/layouts/action_text/contents/_content.html.erb

Содержимое файла _content.html.erb
<div class="trix-content">
  <%= yield %>
</div>

Здесь вставляется body, который сохранен в виде html, из таблицы action_text_rich_texts.

9. app/views/active_storage/blobs/_blob.html.erb

Содержимое файла _blob.html.erb
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
  <% if blob.representable? %>
    <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
  <% end %>

  <figcaption class="attachment__caption">
    <% if caption = blob.try(:caption) %>
      <%= caption %>
    <% else %>
      <span class="attachment__name"><%= blob.filename %></span>
      <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
    <% end %>
  </figcaption>
</figure>

<!--Если хочется иметь возможность скачать файл, можно привести ссылку на файл.-->
<% if !blob.image? %>
  <div>
    <span class="attachment__download"><%= link_to blob.filename, rails_blob_path(blob) %></span>
    <span class="attachment__size">(<%= number_to_human_size blob.byte_size %>)</span>
  </div>
<% end %>

В DevTools сохраненный контент с рисунком выглядит таким образом:

В этом html куске страницы рисуется предпросмотр файла и изображение, данные о которой сохранены в таблице active_storage_blobs.

Описание к загруженному через Trix-редактор рисунку хранится как часть текста в таблице action_text_rich_texts.

В _blob.html.erb я добавила возможность скачать файл, в отличие от файла по умолчанию.

Как экземпляр нашей модели связывается с экземпляром ActionText в таблице action_text_rich_texts?

В модели я пишу ассоциацию has_rich_text.

class Note < ApplicationRecord
  has_rich_text :body
end

Затем, при создании экземпляра в контроллере в разрешенные параметры добавляю body:

class NotesController < ApplicationController
  def create
    @note = Note.create!(note_params)

    redirect_to note_path @note
  end
  
  private

  def note_params = params.require(:note).permit(:title, :body)
end

Через has_rich_text в модели работает соответствующий метод из модуля ActionText::Attribute.

Код этого метода с репозитория rails читаю так:

  • Создаются методы для экземпляра модели: предикат body?, сеттер и геттер для этого поля.

  • Создается ассоциация rich_text_body с именем класса ActionText::RichText, с именем поля name. as: :record - признак полиморфной связи, что также видно было в миграции при созданий таблицы action_text_rich_texts. Эта ассоциация инверсная, то есть из экземпляра модели ActionText::RichText можно получить значение этого поля через вызов метода record.

Проверяю в консоли вышенаписанное:
> note = Note.find(1)
=begin
<Note:0x00007efee9a06ce0                                                                
 id: 1,                                                                                  
 title: "РыбаТекст помогает животным",                                                
 hidden: false,                                                                          
 created_at: Sun, 19 Feb 2023 11:24:47.510481000 UTC +00:00,                             
 updated_at: Sun, 19 Feb 2023 11:24:47.613920000 UTC +00:00> 
=end
>
> note.body
=begin
<ActionText::RichText:0x00007efee9848688
 id: 1,                                 
 name: "body",                       
 body: #<ActionText::Content "<div class=\"trix-conte...">,
 record_type: "Note",                   
 record_id: 1,                          
 created_at: Sun, 19 Feb 2023 11:24:47.564128000 UTC +00:00,
 updated_at: Sun, 19 Feb 2023 11:24:47.583755000 UTC +00:00>
=end
>
> action_text_item = ActionText::RichText.find(1)
=begin
<ActionText::RichText:0x00007efee9848688
 id: 1,                                 
 name: "body",                       
 body: #<ActionText::Content "<div class=\"trix-conte...">,
 record_type: "Note",                   
 record_id: 1,                          
 created_at: Sun, 19 Feb 2023 11:24:47.564128000 UTC +00:00,
 updated_at: Sun, 19 Feb 2023 11:24:47.583755000 UTC +00:00>
=end
>
> action_text_item.record
=begin
<Note:0x00007efee9a06ce0                                                                
 id: 1,                                                                                  
 title: "РыбаТекст помогает животным",                                                
 hidden: false,                                                                          
 created_at: Sun, 19 Feb 2023 11:24:47.510481000 UTC +00:00,                             
 updated_at: Sun, 19 Feb 2023 11:24:47.613920000 UTC +00:00>
=end

Итого, видно, что note.body и поиск экземпляра ActionText::RichText по известному id ссылаются на одну и ту же запись, что и показывает, что опция inverse_of работает ожидаемо.

Как сохраняются файлы/рисунки в записях?

При создании новой записи в Trix-редакторе, нажимая на значок скрепки вы можете добавить файл. При выборе оного, например, рисунка, в консоли, где запущен сервер, вы можете увидеть, что делается запрос POST "/rails/active_storage/direct_uploads", который обрабатывается ActiveStorage::DirectUploadsController#create с параметрами:

{
  "blob": {
    "filename": "cat.jpg",
    "content_type": "image/jpeg",
    "byte_size": 107329,
    "checksum": "JuFhMdR3g4JjS73owAJLQA=="
  },
  "direct_upload": {
    "blob": {
      "filename": "cat.jpg",
      "content_type": "image/jpeg",
      "byte_size": 107329,
      "checksum": "JuFhMdR3g4JjS73owAJLQA=="
      }
    }
  }

В ActiveStorage::DirectUploadsController#create происходит сохранение нужных параметров этого файла.

В консоли выглядит таким образом:

TRANSACTION (0.4ms)  BEGIN
ActiveStorage::Blob Create (0.7ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "service_name", "byte_size", "checksum", "created_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id"  [["key", "ikydgdsgyrgupbqqh0wdqtgs75y4"], ["filename", "cat.jpg"], ["content_type", "image/jpeg"], ["metadata", nil], ["service_name", "minio"], ["byte_size", 107329], ["checksum", "JuFhMdR3g4JjS73owAJLQA=="], ["created_at", "2023-02-23 16:35:59.956614"]]
TRANSACTION (0.8ms)  COMMIT
S3 Storage (45.7ms) Generated URL for file at key: ikydgdsgyrgupbqqh0wdqtgs75y4 (http://localhost:9000/main/ikydgdsgyrgupbqqh0wdqtgs75y4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20230223%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230223T163600Z&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&X-Amz-Signature=696eaa54d2f921beaa552de1263fdb8b548708d12ebbdd3df6e97d7077dedeff)

По умолчанию файлы хранятся на локальном диске, описанный как local в файле config/storage.yml и настроенный по умолчанию. В этом случае service будет Disk. Из значение key сохраненного файла генерируется название файла и его папка и все папки с этими файлами хранятся в папке storage.

Когда контент-менеджер добавляет текст, нажимает на кнопку Опубликовать и сохраняет запись. Ссылка на файл сохраняется прямо в теле rich_text. Делается запись в таблицу active_storage_attachments, которая сохраняет связь между таблицами action_text_rich_texts и active_storage_blobs.

В таблице active_storage_attachments:

  • в поле record_id сохраняется id записи из таблицы action_text_rich_texts,

  • в поле record_type сохраняется название модуля/класса записи из таблицы action_text_rich_texts,

  • в поле blob_id сохраняется id файла/рисунка из таблицы active_storage_blobs,

  • в поле name сохраняется тип файла/рисунка.

Итого

Охватила:

  • Описание файлов, генерируемых командой bin/rails action_text:install

  • Таблицы, создаваемые в базе данных, какие данные сохраняются в этих таблицах.

  • Встраивание Trix-редактора в нужную страницу через ассоциацию has_rich_text.

  • Способ сохранения файлов AсtionText по умолчанию.

На этом закончу. Очевидно, это еще не все. Далее надо сделать:

  • Выбор и настройка сервиса для сохранения файлов не локально.

  • Использование пакета libvips вместо ImageMagick. Пишут, что libvips новее и быстрее.

  • Cохранение нескольких вариантов изображений для разного размера экранов устройств с использованием таблицы active_storage_variant_records. В атрибуте has_many_attached :embeds класса ActionText::RichText я не нашла variant.

Удовольствия вам от программирования, друзья!

Комментарии (4)


  1. 127
    00.00.0000 00:00
    +1

    А показ html-сорса там есть? Не вижу в доках такой функциональности


    1. aykuli Автор
      00.00.0000 00:00

      На главной странице доков к Action Text приписано "Work in progress". Хороший повод внести свой вклад )

      .

      А что вы имеет ввиду под `html-сорса` ? Trix-редактора? Или контента файлов _content.html.erb и _blob.html.erb? Может подразумевается, что после выполнения команды bin/rails action_text:install они появляются и нет смысла показывать в доках?

      Или html-сорс вашего контента, созданный с помошью Trix-редактора? Как минимум вы можете посмотреть в DevTools или прямо в базе данных. Я смотрела в Database view редактора кода RubyMine(просто я им пользуюсь), а вы можете свой способ пользовать.


      1. 127
        00.00.0000 00:00
        +1

        Имею ввиду "Или html-сорс вашего контента, созданный с помошью Trix-редактора?"
        Любой html-редактор это умеет. И чаще всего это нужно. Прямо в базу смотреть — так себе идея


        1. aykuli Автор
          00.00.0000 00:00

          по идее ты видишь сразу результат - что на странице редактирования, что на странице отрендеренного контента. В этом же идея WYSIWYG