alchemy_cms 5.2.0 → 6.0.0.b3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -14
  3. data/.gitignore +0 -1
  4. data/.hound.yml +1 -1
  5. data/.rubocop.yml +46 -4
  6. data/CHANGELOG.md +114 -5
  7. data/Gemfile +8 -1
  8. data/README.md +5 -2
  9. data/alchemy_cms.gemspec +78 -65
  10. data/app/assets/javascripts/alchemy/admin.js +0 -2
  11. data/app/assets/javascripts/alchemy/alchemy.base.js.coffee +0 -27
  12. data/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee +2 -1
  13. data/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +1 -1
  14. data/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee +0 -25
  15. data/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee +1 -1
  16. data/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee +2 -0
  17. data/app/assets/javascripts/alchemy/alchemy.fixed_elements.js +1 -1
  18. data/app/assets/javascripts/alchemy/alchemy.gui.js.coffee +3 -1
  19. data/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee +1 -1
  20. data/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee +40 -27
  21. data/app/assets/javascripts/alchemy/templates/node_folder.hbs +1 -1
  22. data/app/assets/stylesheets/alchemy/_extends.scss +15 -2
  23. data/app/assets/stylesheets/alchemy/admin.scss +1 -1
  24. data/app/assets/stylesheets/alchemy/archive.scss +20 -5
  25. data/app/assets/stylesheets/alchemy/buttons.scss +0 -4
  26. data/app/assets/stylesheets/alchemy/elements.scss +73 -61
  27. data/app/assets/stylesheets/alchemy/images.scss +8 -0
  28. data/app/assets/stylesheets/alchemy/node-select.scss +4 -3
  29. data/app/assets/stylesheets/alchemy/page-select.scss +1 -0
  30. data/app/controllers/alchemy/admin/attachments_controller.rb +8 -4
  31. data/app/controllers/alchemy/admin/base_controller.rb +5 -7
  32. data/app/controllers/alchemy/admin/elements_controller.rb +59 -34
  33. data/app/controllers/alchemy/admin/essence_audios_controller.rb +30 -0
  34. data/app/controllers/alchemy/admin/essence_files_controller.rb +0 -14
  35. data/app/controllers/alchemy/admin/essence_pictures_controller.rb +8 -79
  36. data/app/controllers/alchemy/admin/essence_videos_controller.rb +33 -0
  37. data/app/controllers/alchemy/admin/ingredients_controller.rb +30 -0
  38. data/app/controllers/alchemy/admin/layoutpages_controller.rb +0 -1
  39. data/app/controllers/alchemy/admin/pages_controller.rb +7 -22
  40. data/app/controllers/alchemy/admin/pictures_controller.rb +56 -17
  41. data/app/controllers/alchemy/admin/resources_controller.rb +84 -10
  42. data/app/controllers/alchemy/api/elements_controller.rb +13 -4
  43. data/app/controllers/alchemy/api/pages_controller.rb +4 -3
  44. data/app/controllers/concerns/alchemy/admin/archive_overlay.rb +13 -3
  45. data/app/controllers/concerns/alchemy/admin/crop_action.rb +26 -0
  46. data/app/decorators/alchemy/element_editor.rb +26 -1
  47. data/app/decorators/alchemy/ingredient_editor.rb +158 -0
  48. data/app/helpers/alchemy/admin/elements_helper.rb +1 -0
  49. data/app/helpers/alchemy/admin/essences_helper.rb +1 -1
  50. data/app/helpers/alchemy/admin/ingredients_helper.rb +42 -0
  51. data/app/helpers/alchemy/elements_block_helper.rb +23 -6
  52. data/app/helpers/alchemy/elements_helper.rb +12 -5
  53. data/app/helpers/alchemy/pages_helper.rb +3 -11
  54. data/app/jobs/alchemy/base_job.rb +11 -0
  55. data/app/jobs/alchemy/publish_page_job.rb +11 -0
  56. data/app/models/alchemy/attachment.rb +24 -7
  57. data/app/models/alchemy/content.rb +1 -6
  58. data/app/models/alchemy/content/factory.rb +23 -27
  59. data/app/models/alchemy/element.rb +39 -72
  60. data/app/models/alchemy/element/definitions.rb +29 -27
  61. data/app/models/alchemy/element/element_contents.rb +131 -122
  62. data/app/models/alchemy/element/element_essences.rb +111 -98
  63. data/app/models/alchemy/element/element_ingredients.rb +184 -0
  64. data/app/models/alchemy/element/presenters.rb +104 -85
  65. data/app/models/alchemy/elements_repository.rb +126 -0
  66. data/app/models/alchemy/essence_audio.rb +12 -0
  67. data/app/models/alchemy/essence_headline.rb +40 -0
  68. data/app/models/alchemy/essence_picture.rb +4 -116
  69. data/app/models/alchemy/essence_richtext.rb +12 -0
  70. data/app/models/alchemy/essence_video.rb +12 -0
  71. data/app/models/alchemy/image_cropper_settings.rb +87 -0
  72. data/app/models/alchemy/ingredient.rb +183 -0
  73. data/app/models/alchemy/ingredient_validator.rb +97 -0
  74. data/app/models/alchemy/ingredients/audio.rb +29 -0
  75. data/app/models/alchemy/ingredients/boolean.rb +21 -0
  76. data/app/models/alchemy/ingredients/datetime.rb +20 -0
  77. data/app/models/alchemy/ingredients/file.rb +30 -0
  78. data/app/models/alchemy/ingredients/headline.rb +42 -0
  79. data/app/models/alchemy/ingredients/html.rb +19 -0
  80. data/app/models/alchemy/ingredients/link.rb +16 -0
  81. data/app/models/alchemy/ingredients/node.rb +23 -0
  82. data/app/models/alchemy/ingredients/page.rb +23 -0
  83. data/app/models/alchemy/ingredients/picture.rb +41 -0
  84. data/app/models/alchemy/ingredients/richtext.rb +57 -0
  85. data/app/models/alchemy/ingredients/select.rb +10 -0
  86. data/app/models/alchemy/ingredients/text.rb +17 -0
  87. data/app/models/alchemy/ingredients/video.rb +33 -0
  88. data/app/models/alchemy/language.rb +0 -11
  89. data/app/models/alchemy/page.rb +76 -33
  90. data/app/models/alchemy/page/fixed_attributes.rb +53 -51
  91. data/app/models/alchemy/page/page_elements.rb +186 -205
  92. data/app/models/alchemy/page/page_naming.rb +66 -64
  93. data/app/models/alchemy/page/page_natures.rb +139 -142
  94. data/app/models/alchemy/page/page_scopes.rb +117 -102
  95. data/app/models/alchemy/page/publisher.rb +50 -0
  96. data/app/models/alchemy/page/url_path.rb +1 -1
  97. data/app/models/alchemy/page_version.rb +58 -0
  98. data/app/models/alchemy/picture.rb +18 -40
  99. data/app/models/alchemy/picture/calculations.rb +2 -8
  100. data/app/models/alchemy/picture/preprocessor.rb +2 -0
  101. data/app/models/alchemy/picture/transformations.rb +24 -96
  102. data/app/models/concerns/alchemy/picture_thumbnails.rb +181 -0
  103. data/app/models/concerns/alchemy/touch_elements.rb +2 -2
  104. data/app/presenters/alchemy/picture_view.rb +88 -0
  105. data/app/serializers/alchemy/element_serializer.rb +5 -0
  106. data/app/serializers/alchemy/page_tree_serializer.rb +3 -2
  107. data/app/services/alchemy/delete_elements.rb +44 -0
  108. data/app/services/alchemy/duplicate_element.rb +56 -0
  109. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +2 -3
  110. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  111. data/app/views/alchemy/admin/attachments/assign.js.erb +11 -0
  112. data/app/views/alchemy/admin/attachments/index.html.erb +2 -3
  113. data/app/views/alchemy/admin/crop.html.erb +36 -0
  114. data/app/views/alchemy/admin/elements/_element.html.erb +14 -10
  115. data/app/views/alchemy/admin/elements/{_element_footer.html.erb → _footer.html.erb} +0 -0
  116. data/app/views/alchemy/admin/elements/{_new_element_form.html.erb → _form.html.erb} +1 -1
  117. data/app/views/alchemy/admin/elements/{_element_header.html.erb → _header.html.erb} +1 -1
  118. data/app/views/alchemy/admin/elements/{_element_toolbar.html.erb → _toolbar.html.erb} +5 -6
  119. data/app/views/alchemy/admin/elements/create.js.erb +1 -1
  120. data/app/views/alchemy/admin/elements/{trash.js.erb → destroy.js.erb} +2 -6
  121. data/app/views/alchemy/admin/elements/fold.js.erb +2 -2
  122. data/app/views/alchemy/admin/elements/new.html.erb +3 -3
  123. data/app/views/alchemy/admin/elements/order.js.erb +0 -17
  124. data/app/views/alchemy/admin/elements/update.js.erb +3 -2
  125. data/app/views/alchemy/admin/essence_audios/edit.html.erb +7 -0
  126. data/app/views/alchemy/admin/essence_pictures/update.js.erb +0 -1
  127. data/app/views/alchemy/admin/essence_videos/edit.html.erb +11 -0
  128. data/app/views/alchemy/admin/ingredients/_audio_fields.html.erb +4 -0
  129. data/app/views/alchemy/admin/ingredients/_file_fields.html.erb +18 -0
  130. data/app/views/alchemy/admin/ingredients/_picture_fields.html.erb +25 -0
  131. data/app/views/alchemy/admin/ingredients/_video_fields.html.erb +8 -0
  132. data/app/views/alchemy/admin/ingredients/edit.html.erb +4 -0
  133. data/app/views/alchemy/admin/layoutpages/edit.html.erb +0 -5
  134. data/app/views/alchemy/admin/nodes/_node.html.erb +2 -2
  135. data/app/views/alchemy/admin/pages/_anchor_link.html.erb +1 -1
  136. data/app/views/alchemy/admin/pages/_external_link.html.erb +1 -1
  137. data/app/views/alchemy/admin/pages/_file_link.html.erb +1 -1
  138. data/app/views/alchemy/admin/pages/_form.html.erb +0 -6
  139. data/app/views/alchemy/admin/pages/_internal_link.html.erb +1 -1
  140. data/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb +5 -2
  141. data/app/views/alchemy/admin/pages/_toolbar.html.erb +1 -1
  142. data/app/views/alchemy/admin/pages/edit.html.erb +36 -24
  143. data/app/views/alchemy/admin/pages/index.html.erb +2 -9
  144. data/app/views/alchemy/admin/partials/_remote_search_form.html.erb +2 -4
  145. data/app/views/alchemy/admin/partials/_routes.html.erb +7 -11
  146. data/app/views/alchemy/admin/partials/_search_form.html.erb +9 -0
  147. data/app/views/alchemy/admin/pictures/_archive.html.erb +1 -1
  148. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  149. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -7
  150. data/app/views/alchemy/admin/pictures/_infos.html.erb +0 -1
  151. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +4 -4
  152. data/app/views/alchemy/admin/pictures/assign.js.erb +10 -0
  153. data/app/views/alchemy/admin/pictures/index.html.erb +8 -3
  154. data/app/views/alchemy/admin/resources/_filter.html.erb +12 -0
  155. data/app/views/alchemy/admin/resources/_filter_bar.html.erb +14 -17
  156. data/app/views/alchemy/admin/resources/_form.html.erb +3 -0
  157. data/app/views/alchemy/admin/resources/_table_header.html.erb +15 -0
  158. data/app/views/alchemy/admin/resources/index.html.erb +3 -11
  159. data/app/views/alchemy/essences/_essence_audio_editor.html.erb +4 -0
  160. data/app/views/alchemy/essences/_essence_audio_view.html.erb +15 -0
  161. data/app/views/alchemy/essences/_essence_file_editor.html.erb +15 -6
  162. data/app/views/alchemy/essences/_essence_headline_editor.html.erb +36 -0
  163. data/app/views/alchemy/essences/_essence_headline_view.html.erb +10 -0
  164. data/app/views/alchemy/essences/_essence_link_editor.html.erb +8 -4
  165. data/app/views/alchemy/essences/_essence_picture_editor.html.erb +27 -12
  166. data/app/views/alchemy/essences/_essence_picture_view.html.erb +3 -3
  167. data/app/views/alchemy/essences/_essence_text_editor.html.erb +12 -4
  168. data/app/views/alchemy/essences/_essence_video_editor.html.erb +4 -0
  169. data/app/views/alchemy/essences/_essence_video_view.html.erb +18 -0
  170. data/app/views/alchemy/essences/shared/_essence_picture_tools.html.erb +21 -16
  171. data/app/views/alchemy/essences/shared/_linkable_essence_tools.html.erb +2 -2
  172. data/app/views/alchemy/ingredients/_audio_editor.html.erb +5 -0
  173. data/app/views/alchemy/ingredients/_audio_view.html.erb +14 -0
  174. data/app/views/alchemy/ingredients/_boolean_editor.html.erb +11 -0
  175. data/app/views/alchemy/ingredients/_boolean_view.html.erb +1 -0
  176. data/app/views/alchemy/ingredients/_datetime_editor.html.erb +17 -0
  177. data/app/views/alchemy/ingredients/_datetime_view.html.erb +9 -0
  178. data/app/views/alchemy/ingredients/_file_editor.html.erb +52 -0
  179. data/app/views/alchemy/ingredients/_file_view.html.erb +17 -0
  180. data/app/views/alchemy/ingredients/_headline_editor.html.erb +30 -0
  181. data/app/views/alchemy/ingredients/_headline_view.html.erb +9 -0
  182. data/app/views/alchemy/ingredients/_html_editor.html.erb +8 -0
  183. data/app/views/alchemy/ingredients/_html_view.html.erb +1 -0
  184. data/app/views/alchemy/ingredients/_link_editor.html.erb +24 -0
  185. data/app/views/alchemy/ingredients/_link_view.html.erb +9 -0
  186. data/app/views/alchemy/ingredients/_node_editor.html.erb +26 -0
  187. data/app/views/alchemy/ingredients/_node_view.html.erb +1 -0
  188. data/app/views/alchemy/ingredients/_page_editor.html.erb +25 -0
  189. data/app/views/alchemy/ingredients/_page_view.html.erb +4 -0
  190. data/app/views/alchemy/ingredients/_picture_editor.html.erb +60 -0
  191. data/app/views/alchemy/ingredients/_picture_view.html.erb +5 -0
  192. data/app/views/alchemy/ingredients/_richtext_editor.html.erb +12 -0
  193. data/app/views/alchemy/ingredients/_richtext_view.html.erb +3 -0
  194. data/app/views/alchemy/ingredients/_select_editor.html.erb +30 -0
  195. data/app/views/alchemy/ingredients/_select_view.html.erb +1 -0
  196. data/app/views/alchemy/ingredients/_text_editor.html.erb +20 -0
  197. data/app/views/alchemy/ingredients/_text_view.html.erb +16 -0
  198. data/app/views/alchemy/ingredients/_video_editor.html.erb +5 -0
  199. data/app/views/alchemy/ingredients/_video_view.html.erb +17 -0
  200. data/app/views/alchemy/ingredients/shared/_link_tools.html.erb +20 -0
  201. data/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +57 -0
  202. data/config/brakeman.ignore +66 -159
  203. data/config/initializers/dragonfly.rb +10 -0
  204. data/config/locales/alchemy.en.yml +108 -64
  205. data/config/routes.rb +17 -22
  206. data/db/migrate/20201207131309_create_page_versions.rb +19 -0
  207. data/db/migrate/20201207135820_add_page_version_id_to_alchemy_elements.rb +76 -0
  208. data/db/migrate/20210205143548_rename_public_on_and_public_until_on_alchemy_pages.rb +10 -0
  209. data/db/migrate/20210326105046_add_sanitized_body_to_alchemy_essence_richtexts.rb +7 -0
  210. data/db/migrate/20210406093436_add_alchemy_essence_headlines.rb +12 -0
  211. data/db/migrate/20210506135919_create_essence_audios.rb +19 -0
  212. data/db/migrate/20210506140258_create_essence_videos.rb +23 -0
  213. data/db/migrate/20210508091432_create_alchemy_ingredients.rb +22 -0
  214. data/lib/alchemy/admin/preview_url.rb +2 -0
  215. data/lib/alchemy/deprecation.rb +1 -1
  216. data/lib/alchemy/dragonfly/processors/auto_orient.rb +18 -0
  217. data/lib/alchemy/dragonfly/processors/crop_resize.rb +35 -0
  218. data/lib/alchemy/elements_finder.rb +14 -60
  219. data/lib/alchemy/essence.rb +1 -2
  220. data/lib/alchemy/forms/builder.rb +21 -1
  221. data/lib/alchemy/hints.rb +8 -4
  222. data/lib/alchemy/page_layout.rb +0 -13
  223. data/lib/alchemy/permissions.rb +30 -29
  224. data/lib/alchemy/resource.rb +13 -3
  225. data/lib/alchemy/resource_filter.rb +40 -0
  226. data/lib/alchemy/resources_helper.rb +1 -16
  227. data/lib/alchemy/tasks/tidy.rb +29 -0
  228. data/lib/alchemy/test_support.rb +2 -11
  229. data/lib/alchemy/test_support/essence_shared_examples.rb +0 -1
  230. data/lib/alchemy/test_support/factories/element_factory.rb +8 -8
  231. data/lib/alchemy/test_support/factories/essence_audio_factory.rb +7 -0
  232. data/lib/alchemy/test_support/factories/essence_video_factory.rb +7 -0
  233. data/lib/alchemy/test_support/factories/ingredient_factory.rb +25 -0
  234. data/lib/alchemy/test_support/factories/page_factory.rb +20 -1
  235. data/lib/alchemy/test_support/factories/page_version_factory.rb +23 -0
  236. data/lib/alchemy/test_support/having_crop_action_examples.rb +170 -0
  237. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +646 -0
  238. data/lib/alchemy/test_support/shared_ingredient_editor_examples.rb +21 -0
  239. data/lib/alchemy/test_support/shared_ingredient_examples.rb +75 -0
  240. data/lib/alchemy/tinymce.rb +17 -0
  241. data/lib/alchemy/upgrader/six_point_zero.rb +21 -0
  242. data/lib/alchemy/upgrader/tasks/add_page_versions.rb +33 -0
  243. data/lib/alchemy/upgrader/tasks/ingredients_migrator.rb +62 -0
  244. data/lib/alchemy/version.rb +1 -1
  245. data/lib/alchemy_cms.rb +1 -0
  246. data/lib/generators/alchemy/elements/elements_generator.rb +1 -0
  247. data/lib/generators/alchemy/elements/templates/view.html.erb +9 -0
  248. data/lib/generators/alchemy/elements/templates/view.html.haml +9 -0
  249. data/lib/generators/alchemy/elements/templates/view.html.slim +9 -0
  250. data/lib/generators/alchemy/ingredient/ingredient_generator.rb +38 -0
  251. data/lib/generators/alchemy/ingredient/templates/editor.html.erb +14 -0
  252. data/lib/generators/alchemy/ingredient/templates/model.rb.tt +13 -0
  253. data/lib/generators/alchemy/ingredient/templates/view.html.erb +1 -0
  254. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +1 -1
  255. data/lib/generators/alchemy/menus/templates/node.html.erb +1 -1
  256. data/lib/generators/alchemy/menus/templates/node.html.haml +1 -1
  257. data/lib/generators/alchemy/menus/templates/node.html.slim +1 -1
  258. data/lib/generators/alchemy/menus/templates/wrapper.html.erb +1 -1
  259. data/lib/generators/alchemy/menus/templates/wrapper.html.haml +1 -1
  260. data/lib/generators/alchemy/menus/templates/wrapper.html.slim +1 -1
  261. data/lib/tasks/alchemy/thumbnails.rake +4 -2
  262. data/lib/tasks/alchemy/tidy.rake +12 -0
  263. data/lib/tasks/alchemy/upgrade.rake +26 -0
  264. data/package.json +3 -2
  265. data/package/admin.js +11 -1
  266. data/package/src/__tests__/i18n.spec.js +23 -0
  267. data/package/src/file_editors.js +28 -0
  268. data/package/src/i18n.js +1 -3
  269. data/package/src/image_cropper.js +103 -0
  270. data/package/src/image_loader.js +58 -0
  271. data/package/src/node_tree.js +5 -5
  272. data/package/src/picture_editors.js +169 -0
  273. data/package/src/utils/__tests__/ajax.spec.js +20 -12
  274. data/package/src/utils/ajax.js +8 -3
  275. data/vendor/assets/javascripts/jquery_plugins/jquery.Jcrop.min.js +3 -18
  276. data/vendor/assets/stylesheets/jquery.Jcrop.min.scss +2 -28
  277. metadata +292 -55
  278. data/app/assets/javascripts/alchemy/alchemy.image_cropper.js.coffee +0 -44
  279. data/app/assets/javascripts/alchemy/alchemy.trash_window.js.coffee +0 -30
  280. data/app/assets/stylesheets/alchemy/trash.scss +0 -8
  281. data/app/controllers/alchemy/admin/trash_controller.rb +0 -44
  282. data/app/views/alchemy/admin/attachments/_filter_bar.html.erb +0 -29
  283. data/app/views/alchemy/admin/essence_files/assign.js.erb +0 -3
  284. data/app/views/alchemy/admin/essence_pictures/assign.js.erb +0 -4
  285. data/app/views/alchemy/admin/essence_pictures/crop.html.erb +0 -48
  286. data/app/views/alchemy/admin/pictures/_filter_bar.html.erb +0 -30
  287. data/app/views/alchemy/admin/trash/clear.js.erb +0 -4
  288. data/app/views/alchemy/admin/trash/index.html.erb +0 -31
  289. data/lib/alchemy/test_support/factories.rb +0 -16
@@ -16,6 +16,8 @@ module Alchemy
16
16
  def call
17
17
  max_image_size = Alchemy::Config.get(:preprocess_image_resize)
18
18
  image_file.thumb!(max_image_size) if max_image_size.present?
19
+ # Auto orient the image so EXIF orientation data is taken into account
20
+ image_file.auto_orient!
19
21
  end
20
22
 
21
23
  private
@@ -11,53 +11,16 @@ module Alchemy
11
11
  include Alchemy::Picture::Calculations
12
12
  end
13
13
 
14
- THUMBNAIL_WIDTH = 160
15
- THUMBNAIL_HEIGHT = 120
16
-
17
- # Returns the default centered image mask for a given size.
18
- # If the mask is bigger than the image, the mask is scaled down
19
- # so the largest possible part of the image is visible.
20
- #
21
- def default_mask(mask_arg)
22
- mask = mask_arg.dup
23
- mask[:width] = image_file_width if mask[:width].zero?
24
- mask[:height] = image_file_height if mask[:height].zero?
25
-
26
- crop_size = size_when_fitting({width: image_file_width, height: image_file_height}, mask)
27
- top_left = get_top_left_crop_corner(crop_size)
28
-
29
- point_and_mask_to_points(top_left, crop_size)
30
- end
31
-
32
- # Returns a size value String for the thumbnail used in essence picture editors.
33
- #
34
- def thumbnail_size(size_string = "0x0", crop = false)
35
- size = sizes_from_string(size_string)
36
-
37
- # only if crop is set do we need to actually parse the size string, otherwise
38
- # we take the base image size.
39
- if crop
40
- size[:width] = get_base_dimensions[:width] if size[:width].zero?
41
- size[:height] = get_base_dimensions[:height] if size[:height].zero?
42
- size = reduce_to_image(size)
43
- else
44
- size = get_base_dimensions
45
- end
46
-
47
- size = size_when_fitting({width: THUMBNAIL_WIDTH, height: THUMBNAIL_HEIGHT}, size)
48
- "#{size[:width]}x#{size[:height]}"
49
- end
50
-
51
14
  # Returns the rendered cropped image. Tries to use the crop_from and crop_size
52
15
  # parameters. When they can't be parsed, it just crops from the center.
53
16
  #
54
17
  def crop(size, crop_from = nil, crop_size = nil, upsample = false)
55
18
  raise "No size given!" if size.empty?
56
19
 
57
- render_to = sizes_from_string(size)
20
+ render_to = inferred_sizes_from_string(size)
58
21
  if crop_from && crop_size
59
22
  top_left = point_from_string(crop_from)
60
- crop_dimensions = sizes_from_string(crop_size)
23
+ crop_dimensions = inferred_sizes_from_string(crop_size)
61
24
  xy_crop_resize(render_to, top_left, crop_dimensions, upsample)
62
25
  else
63
26
  center_crop(render_to, upsample)
@@ -75,21 +38,30 @@ module Alchemy
75
38
  def landscape_format?
76
39
  image_file.landscape?
77
40
  end
41
+
78
42
  alias_method :landscape?, :landscape_format?
43
+ deprecate landscape_format?: "Use image_file.landscape? instead", deprecator: Alchemy::Deprecation
44
+ deprecate landscape?: "Use image_file.landscape? instead", deprecator: Alchemy::Deprecation
79
45
 
80
46
  # Returns true if picture's width is smaller than it's height
81
47
  #
82
48
  def portrait_format?
83
49
  image_file.portrait?
84
50
  end
51
+
85
52
  alias_method :portrait?, :portrait_format?
53
+ deprecate portrait_format?: "Use image_file.portrait? instead", deprecator: Alchemy::Deprecation
54
+ deprecate portrait?: "Use image_file.portrait? instead", deprecator: Alchemy::Deprecation
86
55
 
87
56
  # Returns true if picture's width and height is equal
88
57
  #
89
58
  def square_format?
90
59
  image_file.aspect_ratio == 1.0
91
60
  end
61
+
92
62
  alias_method :square?, :square_format?
63
+ deprecate square_format?: "Use image_file.aspect_ratio instead", deprecator: Alchemy::Deprecation
64
+ deprecate square?: "Use image_file.aspect_ratio instead", deprecator: Alchemy::Deprecation
93
65
 
94
66
  # Returns true if the class we're included in has a meaningful render_size attribute
95
67
  #
@@ -121,62 +93,18 @@ module Alchemy
121
93
  }
122
94
  end
123
95
 
124
- # Given dimensions for a possibly destructive crop operation,
125
- # this function returns the top left corner as a Hash
126
- # with keys :x, :y
127
- #
128
- def get_top_left_crop_corner(dimensions)
129
- {
130
- x: (image_file_width - dimensions[:width]) / 2,
131
- y: (image_file_height - dimensions[:height]) / 2,
132
- }
133
- end
96
+ def inferred_sizes_from_string(string)
97
+ sizes = sizes_from_string(string)
98
+ ratio = image_file_width.to_f / image_file_height
134
99
 
135
- # Gets the base dimensions (the dimensions of the Picture before scaling).
136
- # If anything is missing, it gets padded with zero (Integer 0).
137
- # This is the order of precedence: crop_size > image_size
138
- def get_base_dimensions
139
- if crop_size?
140
- sizes_from_string(crop_size)
141
- else
142
- image_size
100
+ if sizes[:width].zero?
101
+ sizes[:width] = image_file_width * ratio
143
102
  end
144
- end
145
-
146
- # This function takes a target and a base dimensions hash and returns
147
- # the dimensions of the image when the base dimensions hash fills
148
- # the target.
149
- #
150
- # Aspect ratio will be preserved.
151
- #
152
- def size_when_fitting(target, dimensions = get_base_dimensions)
153
- zoom = [
154
- dimensions[:width].to_f / target[:width],
155
- dimensions[:height].to_f / target[:height],
156
- ].max
157
-
158
- if zoom == 0.0
159
- width = target[:width]
160
- height = target[:height]
161
- else
162
- width = (dimensions[:width] / zoom).round
163
- height = (dimensions[:height] / zoom).round
103
+ if sizes[:height].zero?
104
+ sizes[:height] = image_file_width / ratio
164
105
  end
165
106
 
166
- {width: width.to_i, height: height.to_i}
167
- end
168
-
169
- # Given a point as a Hash with :x and :y, and a mask with
170
- # :width and :height, this function returns the area on the
171
- # underlying canvas as a Hash of two points
172
- #
173
- def point_and_mask_to_points(point, mask)
174
- {
175
- x1: point[:x],
176
- y1: point[:y],
177
- x2: point[:x] + mask[:width],
178
- y2: point[:y] + mask[:height],
179
- }
107
+ sizes
180
108
  end
181
109
 
182
110
  # Converts a dimensions hash to a string of from "20x20"
@@ -197,20 +125,20 @@ module Alchemy
197
125
  # Use imagemagick to custom crop an image. Uses -thumbnail for better performance when resizing.
198
126
  #
199
127
  def xy_crop_resize(dimensions, top_left, crop_dimensions, upsample)
200
- crop_argument = "-crop #{dimensions_to_string(crop_dimensions)}"
128
+ crop_argument = dimensions_to_string(crop_dimensions)
201
129
  crop_argument += "+#{top_left[:x]}+#{top_left[:y]}"
202
130
 
203
- resize_argument = "-resize #{dimensions_to_string(dimensions)}"
131
+ resize_argument = dimensions_to_string(dimensions)
204
132
  resize_argument += ">" unless upsample
205
- image_file.convert "#{crop_argument} #{resize_argument}"
133
+ image_file.crop_resize(crop_argument, resize_argument)
206
134
  end
207
135
 
208
136
  # Used when centercropping.
209
137
  #
210
138
  def reduce_to_image(dimensions)
211
139
  {
212
- width: [dimensions[:width], image_file_width].min,
213
- height: [dimensions[:height], image_file_height].min,
140
+ width: [dimensions[:width].to_i, image_file_width.to_i].min,
141
+ height: [dimensions[:height].to_i, image_file_height.to_i].min,
214
142
  }
215
143
  end
216
144
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Picture thumbnails and cropping concerns
5
+ module PictureThumbnails
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_save :fix_crop_values
10
+
11
+ delegate :image_file_width, :image_file_height, :image_file, to: :picture, allow_nil: true
12
+ end
13
+
14
+ # The url to show the picture.
15
+ #
16
+ # Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
17
+ # and also adds the security token.
18
+ #
19
+ # You typically want to set the size the picture should be resized to.
20
+ #
21
+ # === Example:
22
+ #
23
+ # essence_picture.picture_url(size: '200x300', crop: true, format: 'gif')
24
+ # # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
25
+ #
26
+ # @option options size [String]
27
+ # The size the picture should be resized to.
28
+ #
29
+ # @option options format [String]
30
+ # The format the picture should be rendered in.
31
+ # Defaults to the +image_output_format+ from the +Alchemy::Config+.
32
+ #
33
+ # @option options crop [Boolean]
34
+ # If set to true the picture will be cropped to fit the size value.
35
+ #
36
+ # @return [String]
37
+ def picture_url(options = {})
38
+ return if picture.nil?
39
+
40
+ picture.url(picture_url_options.merge(options)) || "missing-image.png"
41
+ end
42
+
43
+ # Picture rendering options
44
+ #
45
+ # Returns the +default_render_format+ of the associated +Alchemy::Picture+
46
+ # together with the +crop_from+ and +crop_size+ values
47
+ #
48
+ # @return [HashWithIndifferentAccess]
49
+ def picture_url_options
50
+ return {} if picture.nil?
51
+
52
+ crop = !!settings[:crop]
53
+
54
+ {
55
+ format: picture.default_render_format,
56
+ crop: crop,
57
+ crop_from: crop && crop_from.presence || nil,
58
+ crop_size: crop && crop_size.presence || nil,
59
+ size: settings[:size],
60
+ }.with_indifferent_access
61
+ end
62
+
63
+ # Returns an url for the thumbnail representation of the assigned picture
64
+ #
65
+ # It takes cropping values into account, so it always represents the current
66
+ # image displayed in the frontend.
67
+ #
68
+ # @return [String]
69
+ def thumbnail_url
70
+ return if picture.nil?
71
+
72
+ picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
73
+ end
74
+
75
+ # Thumbnail rendering options
76
+ #
77
+ # @return [HashWithIndifferentAccess]
78
+ def thumbnail_url_options
79
+ crop = !!settings[:crop]
80
+
81
+ {
82
+ size: "160x120",
83
+ crop: crop,
84
+ crop_from: crop && crop_from.presence || default_crop_from&.join("x"),
85
+ crop_size: crop && crop_size.presence || default_crop_size&.join("x"),
86
+ flatten: true,
87
+ format: picture&.image_file_format || "jpg",
88
+ }
89
+ end
90
+
91
+ # Settings for the graphical JS image cropper
92
+ def image_cropper_settings
93
+ Alchemy::ImageCropperSettings.new(
94
+ render_size: dimensions_from_string(render_size.presence || settings[:size]),
95
+ default_crop_from: default_crop_from,
96
+ default_crop_size: default_crop_size,
97
+ fixed_ratio: settings[:fixed_ratio],
98
+ image_width: picture&.image_file_width,
99
+ image_height: picture&.image_file_height,
100
+ ).to_h
101
+ end
102
+
103
+ # Show image cropping link for content
104
+ def allow_image_cropping?
105
+ settings[:crop] && picture &&
106
+ picture.can_be_cropped_to?(
107
+ settings[:size],
108
+ settings[:upsample],
109
+ ) && !!picture.image_file
110
+ end
111
+
112
+ private
113
+
114
+ def default_crop_size
115
+ return nil unless settings[:crop] && settings[:size]
116
+
117
+ mask = inferred_dimensions_from_string(settings[:size])
118
+ zoom = thumbnail_zoom_factor(mask)
119
+ return nil if zoom.zero?
120
+
121
+ [(mask[0] / zoom), (mask[1] / zoom)].map(&:round)
122
+ end
123
+
124
+ def thumbnail_zoom_factor(mask)
125
+ [
126
+ mask[0].to_f / (image_file_width || 1),
127
+ mask[1].to_f / (image_file_height || 1),
128
+ ].max
129
+ end
130
+
131
+ def default_crop_from
132
+ return nil unless settings[:crop]
133
+ return nil if default_crop_size.nil?
134
+
135
+ [
136
+ ((image_file_width || 0) - default_crop_size[0]) / 2,
137
+ ((image_file_height || 0) - default_crop_size[1]) / 2,
138
+ ].map(&:round)
139
+ end
140
+
141
+ def dimensions_from_string(string)
142
+ return if string.nil?
143
+
144
+ string.split("x", 2).map(&:to_i)
145
+ end
146
+
147
+ def inferred_dimensions_from_string(string)
148
+ return if string.nil?
149
+
150
+ width, height = dimensions_from_string(string)
151
+ ratio = image_file_width.to_f / image_file_height.to_i
152
+
153
+ if width.zero? && ratio.is_a?(Float)
154
+ width = height * ratio
155
+ end
156
+
157
+ if height.zero? && ratio.is_a?(Float)
158
+ height = width / ratio
159
+ end
160
+
161
+ [width.to_i, height.to_i]
162
+ end
163
+
164
+ def fix_crop_values
165
+ %i[crop_from crop_size].each do |crop_value|
166
+ if public_send(crop_value).is_a?(String)
167
+ public_send("#{crop_value}=", normalize_crop_value(crop_value))
168
+ end
169
+ end
170
+ end
171
+
172
+ def normalize_crop_value(crop_value)
173
+ public_send(crop_value).split("x").map { |n| normalize_number(n) }.join("x")
174
+ end
175
+
176
+ def normalize_number(number)
177
+ number = number.to_f.round
178
+ number.negative? ? 0 : number
179
+ end
180
+ end
181
+ end
@@ -10,7 +10,7 @@ module Alchemy
10
10
  #
11
11
  module TouchElements
12
12
  def self.included(base)
13
- base.after_save(:touch_elements)
13
+ base.after_update(:touch_elements)
14
14
  end
15
15
 
16
16
  private
@@ -18,7 +18,7 @@ module Alchemy
18
18
  def touch_elements
19
19
  return unless respond_to?(:elements)
20
20
 
21
- elements.map(&:touch)
21
+ elements.touch_all
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Renders a picture ingredient view
5
+ class PictureView
6
+ include ActionView::Helpers::AssetTagHelper
7
+ include ActionView::Helpers::UrlHelper
8
+ include Rails.application.routes.url_helpers
9
+
10
+ attr_reader :ingredient, :html_options, :options, :picture
11
+
12
+ DEFAULT_OPTIONS = {
13
+ show_caption: true,
14
+ disable_link: false,
15
+ srcset: [],
16
+ sizes: [],
17
+ }.with_indifferent_access
18
+
19
+ def initialize(ingredient, options = {}, html_options = {})
20
+ @ingredient = ingredient
21
+ @options = DEFAULT_OPTIONS.merge(ingredient.settings).merge(options || {})
22
+ @html_options = html_options || {}
23
+ @picture = ingredient.picture
24
+ end
25
+
26
+ def render
27
+ return if picture.blank?
28
+
29
+ output = caption ? img_tag + caption : img_tag
30
+
31
+ if is_linked?
32
+ output = link_to(output, url_for(ingredient.link), {
33
+ title: ingredient.link_title.presence,
34
+ target: ingredient.link_target == "blank" ? "_blank" : nil,
35
+ data: { link_target: ingredient.link_target.presence },
36
+ })
37
+ end
38
+
39
+ if caption
40
+ content_tag(:figure, output, { class: ingredient.css_class.presence }.merge(html_options))
41
+ else
42
+ output
43
+ end
44
+ end
45
+
46
+ def caption
47
+ return unless show_caption?
48
+
49
+ @_caption ||= content_tag(:figcaption, ingredient.caption)
50
+ end
51
+
52
+ def src
53
+ ingredient.picture_url(options.except(*DEFAULT_OPTIONS.keys))
54
+ end
55
+
56
+ def img_tag
57
+ @_img_tag ||= image_tag(
58
+ src, {
59
+ alt: alt_text,
60
+ title: ingredient.title.presence,
61
+ class: caption ? nil : ingredient.css_class.presence,
62
+ srcset: srcset.join(", ").presence,
63
+ sizes: options[:sizes].join(", ").presence,
64
+ }.merge(caption ? {} : html_options)
65
+ )
66
+ end
67
+
68
+ def show_caption?
69
+ options[:show_caption] && ingredient.caption.present?
70
+ end
71
+
72
+ def is_linked?
73
+ !options[:disable_link] && ingredient.link.present?
74
+ end
75
+
76
+ def srcset
77
+ options[:srcset].map do |size|
78
+ url = ingredient.picture_url(size: size)
79
+ width, height = size.split("x")
80
+ width.present? ? "#{url} #{width}w" : "#{url} #{height}h"
81
+ end
82
+ end
83
+
84
+ def alt_text
85
+ ingredient.alt_tag.presence || html_options.delete(:alt) || ingredient.picture.name&.humanize
86
+ end
87
+ end
88
+ end