Image Focal Points

Control how images crop across different aspect ratios with focal point selection and per-context overrides.

Overview

The focal point system has three layers:

  1. Global Configuration - Define image contexts (aspect ratios) at the application level
  2. Per-Image Focal Points - Set where the "important part" of each image is
  3. Per-Context Overrides - Optionally use custom crops or different images for specific contexts

Quick Start

1. Enable the Feature

config/initializers/railspress.rb
Railspress.configure do |config|
  config.enable_post_images    # Enables header_image on Post model
  config.enable_focal_points   # Enables focal point editing UI
end

2. Run Migrations

Terminal
$ rails railspress:install:migrations
$ rails db:migrate

This creates the railspress_focal_points table for storing focal point data.

3. Add JavaScript Import

app/javascript/application.js
import "railspress"

This auto-registers all RailsPress Stimulus controllers (focal point picker, dropzone, etc.).

4. Use in Views

ERB
<%= image_tag url_for(@post.header_image.variant(resize_to_limit: [800, 600])),
      style: @post.focal_point_css(:header_image) %>

Configuration

Image Contexts

Define the aspect ratios your site uses:

config/initializers/railspress.rb
Railspress.configure do |config|
  # Default contexts (you can customize these)
  config.image_contexts = {
    hero:  { aspect: [16, 9], label: "Hero Banner", sizes: [1920, 1280] },
    card:  { aspect: [4, 3],  label: "Card",        sizes: [800, 400] },
    thumb: { aspect: [1, 1],  label: "Thumbnail",   sizes: [200] }
  }

  # Or add individual contexts
  config.add_image_context :wide, aspect: [21, 9], label: "Ultra Wide"
  config.add_image_context :portrait, aspect: [3, 4], label: "Portrait"
end

Database Schema

The focal point data is stored in a polymorphic table:

Migration
create_table :railspress_focal_points do |t|
  t.references :record, polymorphic: true, null: false
  t.string :attachment_name, null: false
  t.decimal :focal_x, precision: 5, scale: 4, default: 0.5
  t.decimal :focal_y, precision: 5, scale: 4, default: 0.5
  t.json :overrides, default: {}
  t.timestamps
end
Column Description
record Polymorphic reference to the parent model (Post, Project, etc.)
attachment_name Which attachment this focal point is for (e.g., "header_image")
focal_x, focal_y Coordinates from 0.0 to 1.0 (0.5, 0.5 = center)
overrides JSON hash of per-context overrides

Adding to Custom Models

Posts automatically have focal points when enable_post_images and enable_focal_points are both enabled.

For custom models (Entities), include the HasFocalPoint concern:

app/models/project.rb
class Project < ApplicationRecord
  include Railspress::Entity
  include Railspress::HasFocalPoint

  has_one_attached :cover_image
  has_one_attached :banner_image

  has_focal_point :cover_image
  has_focal_point :banner_image
end

No migrations needed on your model. The polymorphic railspress_focal_points table stores all focal point data.

Model API

Reading Focal Points

Ruby
post = Post.find(1)

# Get focal point coordinates (returns hash)
post.focal_point(:header_image)
# => { x: 0.3, y: 0.7 }

# Get CSS for object-position
post.focal_point_css(:header_image)
# => "object-position: 30.0% 70.0%"

# Check if focal point is set (not center)
post.has_focal_point?(:header_image)
# => true

Context-Aware Image Selection

Ruby
# Get the image for a specific context
# Returns the original attachment, or an uploaded override blob
image = post.image_for(:hero, :header_image)

# Get CSS for a specific context
# Handles focal points, crops, and uploads appropriately
css = post.image_css_for(:hero, :header_image)

Per-Context Overrides

Ruby
# Check if context has a custom override
post.has_image_override?(:hero, :header_image)
# => true/false

# Get override details
post.image_override(:hero, :header_image)
# => { type: "crop", region: { x: 0.1, y: 0.2, width: 0.6, height: 0.5 } }

# Set an override
post.set_image_override(:hero, {
  type: "crop",
  region: { x: 0.1, y: 0.2, width: 0.6, height: 0.5 }
}, :header_image)

# Clear override (revert to using focal point)
post.clear_image_override(:hero, :header_image)

Override Types

Type Description
focal Use the global focal point (default)
crop Use a custom crop region for this context
upload Use a completely different image for this context

View Helpers

Basic Usage

ERB
<%# Apply focal point CSS to an image %>
<%= image_tag url_for(@post.header_image),
      style: @post.focal_point_css(:header_image),
      class: "object-cover w-full h-64" %>

Context-Aware Usage

app/views/blog/show.html.erb
<%# Hero section - might use focal point or custom crop %>
<div class="hero" style="aspect-ratio: 16/9;">
  <% image = @post.image_for(:hero, :header_image) %>
  <% if image.respond_to?(:variant) %>
    <%= image_tag url_for(image.variant(resize_to_limit: [1920, 1080])),
          style: @post.image_css_for(:hero, :header_image),
          class: "object-cover w-full h-full" %>
  <% elsif image.respond_to?(:url) %>
    <%= image_tag url_for(image),
          class: "object-cover w-full h-full" %>
  <% end %>
</div>

Card Component Example

ERB
<%# Card with 4:3 aspect ratio %>
<article class="card">
  <div class="card-image" style="aspect-ratio: 4/3;">
    <%= image_tag url_for(@post.header_image.variant(resize_to_limit: [800, 600])),
          style: @post.image_css_for(:card, :header_image),
          class: "object-cover w-full h-full" %>
  </div>
  <div class="card-body">
    <h3><%= @post.title %></h3>
  </div>
</article>

Admin UI

Image Section Partial

Use the provided partial in your admin forms:

ERB
<%= form_with model: [:admin, @post] do |form| %>
  <%# ... other fields ... %>

  <% if Railspress.focal_points_enabled? %>
    <%= render "railspress/admin/shared/image_section",
          form: form,
          record: @post,
          attachment_name: :header_image,
          label: "Featured Image" %>
  <% end %>
<% end %>

Partial Options

Option Type Default Description
form FormBuilder required Rails form builder
record Model required Record with HasFocalPoint
attachment_name Symbol required Attachment name (e.g., :header_image)
label String "Main Image" Section label
contexts Hash Railspress.image_contexts Context configs for previews
show_advanced Boolean false Show per-context override UI

Controller Setup

Permit nested focal point attributes:

app/controllers/admin/posts_controller.rb
class Admin::PostsController < ApplicationController
  def post_params
    params.require(:post).permit(
      :title, :content, :header_image, :remove_header_image,
      header_image_focal_point_attributes: [:focal_x, :focal_y, :overrides]
    )
  end
end

How It Works

Display-Time Cropping

The focal point system uses CSS object-position for display-time cropping:

CSS
.image-container {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 30% 70%; /* Focal point at x=0.3, y=0.7 */
}

This means:

  • Original images are never destructively cropped
  • The same image works across all aspect ratios
  • Focal point changes take effect immediately (no re-processing)

Architecture Decisions

Why Polymorphic Table? Instead of adding columns to each model:

  1. No migrations needed for host app models
  2. Consistent pattern across all entity types
  3. Mirrors ActionText approach (action_text_rich_texts)
  4. Single table for all focal point data

Why Display-Time Cropping? Instead of generating pre-cropped variants:

  1. Immediate updates - No waiting for image processing
  2. Single source - One image serves all contexts
  3. Reversible - Change focal point anytime
  4. Storage efficient - No duplicate variants per context

Troubleshooting

Focal Point Not Saving

Ensure nested attributes are permitted in your controller:

Ruby
permitted.push(header_image_focal_point_attributes: [:focal_x, :focal_y, :overrides])

Stimulus Controllers Not Loading

Add the RailsPress import to your application.js:

app/javascript/application.js
import "railspress"  // Auto-registers all RailsPress Stimulus controllers

This requires window.Stimulus to be set. If you use a custom Stimulus setup:

app/javascript/application.js
import { Application } from "@hotwired/stimulus"
import { register } from "railspress"

const application = Application.start()
window.Stimulus = application  // Required for auto-registration
register(application)  // Or register manually

Images Not Cropping Correctly

Ensure you're using object-fit: cover on the image:

CSS
img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}