Add ActionText with Trix rich text editor to React

Rails 6 introduced Action Text for rich text content and editing. To integrate Action Text with React applications, you need to use Basecamp's Trix editor. This post covers how to set up Action Text with Trix in a React frontend.

What is Action Text?

Action Text brings rich text content and editing to Rails. It includes the Trix editor and handles everything needed for rich text content, including attachments, images, and formatting.

Backend Setup

1. Install Action Text

rails action_text:install
rails db:migrate

2. Configure Model

Add Action Text to your model:

# app/models/post.rb
class Post < ApplicationRecord
  has_rich_text :content
end

3. API Endpoint

Create an API endpoint to handle Action Text:

# app/controllers/api/posts_controller.rb
class Api::PostsController < ApplicationController
  def create
    post = Post.new(post_params)

    if post.save
      render json: {
        id: post.id,
        content: post.content.to_s,
        content_trix: post.content.to_trix_html
      }
    else
      render json: { errors: post.errors }, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :content)
  end
end

Frontend Setup

1. Install Trix

Install Trix via npm:

npm install trix
# or
yarn add trix

2. Import Trix Styles

Import Trix CSS in your React app:

// src/index.js or App.js
import 'trix/dist/trix.css'

3. Create Trix Component

Create a React component for Trix:

// src/components/TrixEditor.js
import React, { useEffect, useRef } from 'react'
import Trix from 'trix'

function TrixEditor({ value, onChange, ...props }) {
  const trixRef = useRef(null)
  const inputRef = useRef(null)

  useEffect(() => {
    const editor = trixRef.current?.editor

    if (editor && value !== editor.getDocument().toString()) {
      editor.loadHTML(value || '')
    }
  }, [value])

  useEffect(() => {
    const trixElement = trixRef.current

    const handleChange = () => {
      const html = trixElement?.value || ''
      onChange?.(html)
    }

    trixElement?.addEventListener('trix-change', handleChange)

    return () => {
      trixElement?.removeEventListener('trix-change', handleChange)
    }
  }, [onChange])

  return (
    <div>
      <input
        ref={inputRef}
        type="hidden"
        id="trix-input"
        value={value || ''}
      />
      <trix-editor
        ref={trixRef}
        input="trix-input"
        {...props}
      />
    </div>
  )
}

export default TrixEditor

Using the Component

Use TrixEditor in your forms:

// src/components/PostForm.js
import React, { useState } from 'react'
import TrixEditor from './TrixEditor'

function PostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  const handleSubmit = async (e) => {
    e.preventDefault()

    const response = await fetch('/api/posts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        post: {
          title,
          content,
        },
      }),
    })

    const data = await response.json()
    console.log('Post created:', data)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Post title"
        required
      />
      <TrixEditor
        value={content}
        onChange={setContent}
        placeholder="Write your post..."
      />
      <button type="submit">Create Post</button>
    </form>
  )
}

export default PostForm

Handling Direct Uploads

For file attachments, configure direct uploads:

Backend: Direct Upload Endpoint

# app/controllers/api/direct_uploads_controller.rb
class Api::DirectUploadsController < ApplicationController
  def create
    blob = ActiveStorage::Blob.create_before_direct_upload!(
      filename: params[:filename],
      byte_size: params[:byte_size],
      content_type: params[:content_type],
      checksum: params[:checksum]
    )

    render json: {
      signed_id: blob.signed_id,
      url: blob.service_url_for_direct_upload,
      headers: blob.service_headers_for_direct_upload
    }
  end
end

Frontend: Configure Trix for Direct Upload

// src/components/TrixEditor.js
import React, { useEffect, useRef } from 'react'
import Trix from 'trix'

function TrixEditor({ value, onChange, ...props }) {
  const trixRef = useRef(null)
  const inputRef = useRef(null)

  useEffect(() => {
    const trixElement = trixRef.current

    const handleAttachmentAdd = async (event) => {
      const attachment = event.attachment
      const file = attachment.file

      if (file) {
        // Get direct upload URL
        const formData = new FormData()
        formData.append('filename', file.name)
        formData.append('byte_size', file.size)
        formData.append('content_type', file.type)
        formData.append('checksum', await calculateChecksum(file))

        const response = await fetch('/api/direct_uploads', {
          method: 'POST',
          body: formData,
        })

        const { url, headers, signed_id } = await response.json()

        // Upload file directly
        await fetch(url, {
          method: 'PUT',
          headers: JSON.parse(headers),
          body: file,
        })

        // Set attachment attributes
        attachment.setAttributes({
          url: url,
          sgid: signed_id,
        })
      }
    }

    trixElement?.addEventListener('trix-attachment-add', handleAttachmentAdd)

    return () => {
      trixElement?.removeEventListener('trix-attachment-add', handleAttachmentAdd)
    }
  }, [])

  // ... rest of component
}

async function calculateChecksum(file) {
  const buffer = await file.arrayBuffer()
  const hashBuffer = await crypto.subtle.digest('MD5', buffer)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

export default TrixEditor

Rendering Action Text Content

To render Action Text content in React:

// src/components/PostContent.js
import React from 'react'

function PostContent({ content }) {
  return (
    <div
      dangerouslySetInnerHTML={{ __html: content }}
      className="trix-content"
    />
  )
}

export default PostContent

Styling Trix Content

Add styles for rendered Trix content:

/* src/styles/trix-content.css */
.trix-content {
  /* Match Trix editor styles */
}

.trix-content img {
  max-width: 100%;
  height: auto;
}

.trix-content .attachment {
  display: inline-block;
  position: relative;
  max-width: 100%;
  margin: 0.5em 0;
}

Pre-populating Editor

To pre-populate the editor with existing content:

// When editing an existing post
const [content, setContent] = useState(post.content_trix || '')

<TrixEditor
  value={content}
  onChange={setContent}
/>

Common Issues and Solutions

Issue: Trix not loading

Make sure Trix CSS is imported and the component is properly initialized.

Issue: Attachments not working

Configure direct uploads and ensure CORS is set up correctly for your storage service.

Issue: Content not saving

Ensure you're sending the HTML content, not just the text content.

Best Practices

  1. Sanitize content: Sanitize HTML content before rendering
  2. Handle errors: Provide error handling for uploads
  3. Loading states: Show loading indicators during uploads
  4. File size limits: Enforce file size limits
  5. Content validation: Validate content on both client and server

Conclusion

Integrating Action Text with Trix in React requires some setup, but provides a powerful rich text editing solution. By configuring direct uploads and properly handling Trix events, you can create a seamless rich text editing experience in your React application.

Add ActionText with Trix rich text editor to React - Abhay Nikam