Skip to content

Commit

Permalink
Extract PictureThumbnails concern
Browse files Browse the repository at this point in the history
Holds everything you need to make your model being able to
display image thumbnails and make use of the image cropper.

Your model needs to have `crop_from` and `crop_size` attributes
as well as a `Alchemy::Picture` association named `picture` and
a `settings` Hash.
  • Loading branch information
tvdeyen committed Jun 26, 2021
1 parent 1c3a204 commit a45539a
Show file tree
Hide file tree
Showing 5 changed files with 768 additions and 766 deletions.
176 changes: 4 additions & 172 deletions app/models/alchemy/essence_picture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,93 +23,18 @@

module Alchemy
class EssencePicture < BaseRecord
include Alchemy::PictureThumbnails

acts_as_essence ingredient_column: :picture, belongs_to: {
class_name: "Alchemy::Picture",
foreign_key: :picture_id,
inverse_of: :essence_pictures,
optional: true,
}

delegate :image_file_width, :image_file_height, :image_file, to: :picture, allow_nil: true
before_save :fix_crop_values
before_save :replace_newlines

# The url to show the picture.
#
# Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
# and also adds the security token.
#
# You typically want to set the size the picture should be resized to.
#
# === Example:
#
# essence_picture.picture_url(size: '200x300', crop: true, format: 'gif')
# # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
#
# @option options size [String]
# The size the picture should be resized to.
#
# @option options format [String]
# The format the picture should be rendered in.
# Defaults to the +image_output_format+ from the +Alchemy::Config+.
#
# @option options crop [Boolean]
# If set to true the picture will be cropped to fit the size value.
#
# @return [String]
def picture_url(options = {})
return if picture.nil?

picture.url(picture_url_options.merge(options)) || "missing-image.png"
end
delegate :settings, to: :content

# Picture rendering options
#
# Returns the +default_render_format+ of the associated +Alchemy::Picture+
# together with the +crop_from+ and +crop_size+ values
#
# @return [HashWithIndifferentAccess]
def picture_url_options
return {} if picture.nil?

crop = crop_values_present? || content.settings[:crop]

{
format: picture.default_render_format,
crop: !!crop,
crop_from: crop_from.presence,
crop_size: crop_size.presence,
size: content.settings[:size],
}.with_indifferent_access
end

# Returns an url for the thumbnail representation of the assigned picture
#
# It takes cropping values into account, so it always represents the current
# image displayed in the frontend.
#
# @return [String]
def thumbnail_url
return if picture.nil?

picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
end

# Thumbnail rendering options
#
# @return [HashWithIndifferentAccess]
def thumbnail_url_options
crop = crop_values_present? || content.settings[:crop]

{
size: "160x120",
crop: !!crop,
crop_from: crop_from.presence || default_crop_from&.join("x"),
crop_size: crop_size.presence || default_crop_size&.join("x"),
flatten: true,
format: picture&.image_file_format || "jpg",
}
end
before_save :replace_newlines

# The name of the picture used as preview text in element editor views.
#
Expand All @@ -130,101 +55,8 @@ def serialized_ingredient
picture_url(content.settings)
end

# Show image cropping link for content
def allow_image_cropping?
content && content.settings[:crop] && picture &&
picture.can_be_cropped_to?(
content.settings[:size],
content.settings[:upsample],
) && !!picture.image_file
end

def crop_values_present?
crop_from.present? && crop_size.present?
end

# Settings for the graphical JS image cropper

def image_cropper_settings
Alchemy::ImageCropperSettings.new(
render_size: dimensions_from_string(render_size.presence || content.settings[:size]),
default_crop_from: default_crop_from,
default_crop_size: default_crop_size,
fixed_ratio: content.settings[:fixed_ratio],
image_width: picture&.image_file_width,
image_height: picture&.image_file_height,
).to_h
end

private

def fix_crop_values
%i(crop_from crop_size).each do |crop_value|
if self[crop_value].is_a?(String)
write_attribute crop_value, normalize_crop_value(crop_value)
end
end
end

def default_crop_size
return nil unless content.settings[:crop] && content.settings[:size]

mask = inferred_dimensions_from_string(content.settings[:size])
zoom = thumbnail_zoom_factor(mask)
return nil if zoom.zero?

[(mask[0] / zoom), (mask[1] / zoom)].map(&:round)
end

def thumbnail_zoom_factor(mask)
[
mask[0].to_f / (image_file_width || 1),
mask[1].to_f / (image_file_height || 1),
].max
end

def default_crop_from
return nil unless content.settings[:crop]
return nil if default_crop_size.nil?

[
((image_file_width || 0) - default_crop_size[0]) / 2,
((image_file_height || 0) - default_crop_size[1]) / 2,
].map(&:round)
end

def dimensions_from_string(string)
return if string.nil?

string.split("x", 2).map(&:to_i)
end

def inferred_dimensions_from_string(string)
return if string.nil?

width, height = dimensions_from_string(string)
ratio = image_file_width.to_f / image_file_height.to_i

if width.zero? && ratio.is_a?(Float)
width = height * ratio
end

if height.zero? && ratio.is_a?(Float)
height = width / ratio
end

[width.to_i, height.to_i]
end

def normalize_crop_value(crop_value)
self[crop_value].split("x").map { |n| normalize_number(n) }.join("x")
end

def normalize_number(number)
number = number.to_f.round
number.negative? ? 0 : number
end

def replace_newlines
return nil if caption.nil?

Expand Down
185 changes: 185 additions & 0 deletions app/models/concerns/alchemy/picture_thumbnails.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# frozen_string_literal: true

module Alchemy
# Picture thumbnails and cropping concerns
module PictureThumbnails
extend ActiveSupport::Concern

included do
before_save :fix_crop_values

delegate :image_file_width, :image_file_height, :image_file, to: :picture, allow_nil: true
end

# The url to show the picture.
#
# Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
# and also adds the security token.
#
# You typically want to set the size the picture should be resized to.
#
# === Example:
#
# essence_picture.picture_url(size: '200x300', crop: true, format: 'gif')
# # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
#
# @option options size [String]
# The size the picture should be resized to.
#
# @option options format [String]
# The format the picture should be rendered in.
# Defaults to the +image_output_format+ from the +Alchemy::Config+.
#
# @option options crop [Boolean]
# If set to true the picture will be cropped to fit the size value.
#
# @return [String]
def picture_url(options = {})
return if picture.nil?

picture.url(picture_url_options.merge(options)) || "missing-image.png"
end

# Picture rendering options
#
# Returns the +default_render_format+ of the associated +Alchemy::Picture+
# together with the +crop_from+ and +crop_size+ values
#
# @return [HashWithIndifferentAccess]
def picture_url_options
return {} if picture.nil?

crop = crop_values_present? || settings[:crop]

{
format: picture.default_render_format,
crop: !!crop,
crop_from: crop_from.presence,
crop_size: crop_size.presence,
size: settings[:size],
}.with_indifferent_access
end

# Returns an url for the thumbnail representation of the assigned picture
#
# It takes cropping values into account, so it always represents the current
# image displayed in the frontend.
#
# @return [String]
def thumbnail_url
return if picture.nil?

picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
end

# Thumbnail rendering options
#
# @return [HashWithIndifferentAccess]
def thumbnail_url_options
crop = crop_values_present? || settings[:crop]

{
size: "160x120",
crop: !!crop,
crop_from: crop_from.presence || default_crop_from&.join("x"),
crop_size: crop_size.presence || default_crop_size&.join("x"),
flatten: true,
format: picture&.image_file_format || "jpg",
}
end

# Settings for the graphical JS image cropper
def image_cropper_settings
Alchemy::ImageCropperSettings.new(
render_size: dimensions_from_string(render_size.presence || settings[:size]),
default_crop_from: default_crop_from,
default_crop_size: default_crop_size,
fixed_ratio: settings[:fixed_ratio],
image_width: picture&.image_file_width,
image_height: picture&.image_file_height,
).to_h
end

# Show image cropping link for content
def allow_image_cropping?
settings[:crop] && picture &&
picture.can_be_cropped_to?(
settings[:size],
settings[:upsample],
) && !!picture.image_file
end

private

def crop_values_present?
crop_from.present? && crop_size.present?
end

def default_crop_size
return nil unless settings[:crop] && settings[:size]

mask = inferred_dimensions_from_string(settings[:size])
zoom = thumbnail_zoom_factor(mask)
return nil if zoom.zero?

[(mask[0] / zoom), (mask[1] / zoom)].map(&:round)
end

def thumbnail_zoom_factor(mask)
[
mask[0].to_f / (image_file_width || 1),
mask[1].to_f / (image_file_height || 1),
].max
end

def default_crop_from
return nil unless settings[:crop]
return nil if default_crop_size.nil?

[
((image_file_width || 0) - default_crop_size[0]) / 2,
((image_file_height || 0) - default_crop_size[1]) / 2,
].map(&:round)
end

def dimensions_from_string(string)
return if string.nil?

string.split("x", 2).map(&:to_i)
end

def inferred_dimensions_from_string(string)
return if string.nil?

width, height = dimensions_from_string(string)
ratio = image_file_width.to_f / image_file_height.to_i

if width.zero? && ratio.is_a?(Float)
width = height * ratio
end

if height.zero? && ratio.is_a?(Float)
height = width / ratio
end

[width.to_i, height.to_i]
end

def fix_crop_values
%i[crop_from crop_size].each do |crop_value|
if public_send(crop_value).is_a?(String)
public_send("#{crop_value}=", normalize_crop_value(crop_value))
end
end
end

def normalize_crop_value(crop_value)
public_send(crop_value).split("x").map { |n| normalize_number(n) }.join("x")
end

def normalize_number(number)
number = number.to_f.round
number.negative? ? 0 : number
end
end
end
Loading

0 comments on commit a45539a

Please sign in to comment.