inkcite 1.11.0 → 1.12.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +2 -2
  3. data/assets/init/helpers.tsv +7 -0
  4. data/assets/social/facebook.png +0 -0
  5. data/assets/social/pintrest.png +0 -0
  6. data/assets/social/twitter.png +0 -0
  7. data/inkcite.gemspec +5 -4
  8. data/lib/inkcite.rb +17 -6
  9. data/lib/inkcite/animation.rb +135 -0
  10. data/lib/inkcite/cli/base.rb +5 -14
  11. data/lib/inkcite/cli/build.rb +3 -3
  12. data/lib/inkcite/cli/init.rb +3 -3
  13. data/lib/inkcite/cli/preview.rb +25 -1
  14. data/lib/inkcite/cli/server.rb +1 -1
  15. data/lib/inkcite/cli/validate.rb +2 -11
  16. data/lib/inkcite/email.rb +1 -1
  17. data/lib/inkcite/mailer.rb +5 -0
  18. data/lib/inkcite/minifier.rb +58 -11
  19. data/lib/inkcite/renderer.rb +10 -2
  20. data/lib/inkcite/renderer/base.rb +45 -3
  21. data/lib/inkcite/renderer/button.rb +16 -12
  22. data/lib/inkcite/renderer/container_base.rb +14 -4
  23. data/lib/inkcite/renderer/element.rb +13 -3
  24. data/lib/inkcite/renderer/image.rb +46 -19
  25. data/lib/inkcite/renderer/image_base.rb +3 -3
  26. data/lib/inkcite/renderer/in_browser.rb +1 -1
  27. data/lib/inkcite/renderer/link.rb +91 -59
  28. data/lib/inkcite/renderer/lorem.rb +9 -1
  29. data/lib/inkcite/renderer/mobile_image.rb +3 -11
  30. data/lib/inkcite/renderer/mobile_only.rb +48 -0
  31. data/lib/inkcite/renderer/outlook_background.rb +61 -13
  32. data/lib/inkcite/renderer/property.rb +1 -1
  33. data/lib/inkcite/renderer/responsive.rb +10 -8
  34. data/lib/inkcite/renderer/snow.rb +20 -10
  35. data/lib/inkcite/renderer/social.rb +128 -0
  36. data/lib/inkcite/renderer/table.rb +13 -1
  37. data/lib/inkcite/renderer/table_base.rb +3 -3
  38. data/lib/inkcite/renderer/td.rb +32 -7
  39. data/lib/inkcite/renderer/video_preview.rb +257 -0
  40. data/lib/inkcite/uploader.rb +3 -3
  41. data/lib/inkcite/util.rb +19 -5
  42. data/lib/inkcite/version.rb +1 -1
  43. data/lib/inkcite/view.rb +29 -18
  44. data/lib/inkcite/view/context.rb +30 -0
  45. data/test/animation_spec.rb +38 -0
  46. data/test/email_spec.rb +0 -4
  47. data/test/minifier_spec.rb +243 -4
  48. data/test/parser_spec.rb +0 -4
  49. data/test/project/helpers.tsv +3 -0
  50. data/test/renderer/button_spec.rb +15 -13
  51. data/test/renderer/div_spec.rb +13 -4
  52. data/test/renderer/element_spec.rb +1 -5
  53. data/test/renderer/footnote_spec.rb +0 -4
  54. data/test/renderer/image_spec.rb +27 -11
  55. data/test/renderer/link_spec.rb +14 -4
  56. data/test/renderer/lorem_spec.rb +0 -4
  57. data/test/renderer/mobile_image_spec.rb +3 -11
  58. data/test/renderer/mobile_only_spec.rb +21 -0
  59. data/test/renderer/mobile_style_spec.rb +1 -5
  60. data/test/renderer/outlook_background_spec.rb +61 -0
  61. data/test/renderer/redacted_spec.rb +0 -4
  62. data/test/renderer/social_spec.rb +53 -0
  63. data/test/renderer/span_spec.rb +0 -4
  64. data/test/renderer/table_spec.rb +8 -4
  65. data/test/renderer/td_spec.rb +0 -4
  66. data/test/renderer/video_preview_spec.rb +19 -0
  67. data/test/renderer_spec.rb +0 -4
  68. data/test/test_helper.rb +7 -0
  69. data/test/view_spec.rb +12 -4
  70. metadata +89 -56
@@ -56,7 +56,7 @@ module Inkcite
56
56
 
57
57
  # If the image is missing, record an error to the console and
58
58
  # clear the image allowing the color to take precedence instead.
59
- src = nil unless ctx.assert_image_exists(src)
59
+ src = nil if src && !ctx.assert_image_exists(src)
60
60
 
61
61
  # Flake rotation, used if an image is present. (Rotating a colored
62
62
  # circle has no visual distinction.) For convenience this tag accepts
@@ -77,6 +77,9 @@ module Inkcite
77
77
  # snow effect.
78
78
  mix_background div_wrap, opt, ctx
79
79
 
80
+ # Text alignment within the wrapper
81
+ mix_text_align div_wrap, opt, ctx
82
+
80
83
  # Kick things off by rendering the wrapping div.
81
84
  html = div_wrap.to_s
82
85
 
@@ -146,13 +149,17 @@ module Inkcite
146
149
  start_interval = end_time / flakes.to_f
147
150
  start_time = 0
148
151
 
152
+ # Will hold the sizes of each flake and will be used to offset their
153
+ # initial starting position in the animation section.
154
+ flake_sizes = {}
155
+
149
156
  # Now add individual class definitions for each flake with unique size,
150
157
  # speed and starting position. Also add the animation trigger that loops
151
158
  # infinitely, starts at a random time and uses a random speed to completion.
152
159
  flakes.times do |flake|
153
160
 
154
161
  speed = rand(speed_range).round(1)
155
- size = rand(size_range)
162
+ size = flake_sizes[flake] = rand(size_range)
156
163
 
157
164
  opacity = rand(opacity_range).round(1)
158
165
  if opacity < OPACITY_FLOOR
@@ -169,7 +176,7 @@ module Inkcite
169
176
  style << " border-radius: #{px((size / 2.0).round)};" unless src
170
177
 
171
178
  style << " opacity: #{opacity};" if opacity < OPACITY_CEIL
172
- style << with_browser_prefixes(' ', "animation: #{anim_prefix}#{flake + 1} #{speed}s linear #{start_time.round(1)}s infinite;", webkit_only)
179
+ style << Animation.with_browser_prefixes("animation: #{anim_prefix}#{flake + 1} #{speed}s linear #{start_time.round(1)}s infinite;", ctx)
173
180
  style << ' }'
174
181
 
175
182
  start_time += start_interval
@@ -192,14 +199,17 @@ module Inkcite
192
199
  # Calculate the ending rotation for the flake, if rotation is enabled.
193
200
  end_rotation = rotation_enabled ? rand(ROTATION_RANGE) : 0
194
201
 
195
- _style = "keyframes #{anim_prefix}#{flake + 1} {\n"
196
- _style << " 0% { top: -3%; left: #{start_left}%; }\n"
197
- _style << " 100% { top: 100%; left: #{end_left}%;"
198
- _style << with_browser_prefixes(' ', "transform: rotate(#{end_rotation}deg);", webkit_only, '') if end_rotation != 0
199
- _style << " }\n"
200
- _style << ' }'
202
+ keyframes = Animation::Keyframes.new("#{anim_prefix}#{flake + 1}", ctx)
203
+
204
+ # Start above the div area
205
+ keyframes.add_keyframe(0, :top => px(-flake_sizes[flake]), :left => pct(start_left.round))
206
+
207
+ # End below the div area, applying rotation if necessary.
208
+ keyframe = Animation::Keyframe.new(100, :top => '100%', :left => pct(end_left))
209
+ keyframe.add_with_prefixes(:transform, "rotate(#{end_rotation}deg)", ctx) if end_rotation != 0
210
+ keyframes << keyframe
201
211
 
202
- style << with_browser_prefixes(" @", _style, webkit_only)
212
+ style << keyframes.to_s
203
213
 
204
214
  end
205
215
 
@@ -0,0 +1,128 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Social < Base
4
+
5
+ class Facebook < Social
6
+
7
+ def initialize
8
+ super(:src => 'facebook.png', :alt => 'Facebook', :cta => 'Share')
9
+ end
10
+
11
+ protected
12
+
13
+ def get_share_href url, text, opts, ctx
14
+ %Q(https://www.facebook.com/sharer/sharer.php?u=#{url}&t=#{text})
15
+ end
16
+
17
+ end
18
+
19
+ class Pintrest < Social
20
+
21
+ def initialize
22
+ super(:src => 'pintrest.png', :alt => 'Pintrest', :cta => 'Pin it', :color => '#CB2027')
23
+ end
24
+
25
+ protected_methods
26
+
27
+ def get_share_href url, text, opts, ctx
28
+
29
+ media = opts.delete(:media).to_s
30
+ ctx.error("Pintrest sharing 'media' attribute can't be blank", :id => opts[:id]) if media.blank?
31
+
32
+ %Q(https://www.pinterest.com/pin/create/button/?url=#{url}&media=#{Util::escape(media)}&description=#{text})
33
+ end
34
+
35
+ end
36
+
37
+ class Twitter < Social
38
+
39
+ def initialize
40
+ super(:src => 'twitter.png', :alt => 'Twitter', :cta => 'Tweet', :scale => 81 / 100.0)
41
+ end
42
+
43
+ protected
44
+
45
+ def get_share_href url, text, opts, ctx
46
+ %Q(https://twitter.com/share?url=#{url}&text=#{text})
47
+ end
48
+
49
+ end
50
+
51
+ def initialize defaults
52
+ @defaults = defaults
53
+
54
+ # Ensure a default scale of 1:1 is installed into the defaults
55
+ # if one is not otherwise provided.
56
+ @defaults[:scale] ||= 1.0
57
+
58
+ end
59
+
60
+ def render tag, opts, ctx
61
+
62
+ id = opts[:id]
63
+
64
+ share_url = opts.delete(:href).to_s
65
+ ctx.error("Social sharing 'href' attribute can't be blank", :tag => tag, :id => id) if share_url.blank?
66
+
67
+ share_text = opts.delete(:text).to_s
68
+ ctx.error("Social sharing 'text' attribute can't be blank", :tag => tag, :id => id, :href => share_url) if share_text.blank?
69
+
70
+ # Let the extending class format the fully-qualified URL to the sharing service.
71
+ service_href = get_share_href Util.escape(share_url), Util.escape(share_text), opts, ctx
72
+
73
+ # Check to see if there is a special color for this link (e.g. Pintrest) or
74
+ # if it has been specified by the caller.
75
+ opts[:color] ||= @defaults[:color]
76
+
77
+ if icon = !opts[:noicon]
78
+
79
+ # Ensure that the sharing icon exists in the project.
80
+ ensure_icon_exists ctx
81
+
82
+ height = (opts.delete(:size) || opts.delete(:height) || 15).to_i
83
+ width = (height / @defaults[:scale]).round
84
+
85
+ # Force the font size and line height to match the size of the
86
+ # icon being used - this ensures proper vertical middle alignment.
87
+ opts[FONT_SIZE] = height
88
+ opts[LINE_HEIGHT] = height
89
+
90
+ end
91
+
92
+ cta = opts[:cta] || @defaults[:cta]
93
+
94
+ html = %Q({a href="#{service_href}" #{Renderer.join_hash(opts)}})
95
+ html << %Q({img src=#{@defaults[:src]} height=#{height} width=#{width} display=inline alt="#{@defaults[:alt]}"} ) if icon
96
+ html << %Q(#{cta}{/a})
97
+
98
+ html
99
+ end
100
+
101
+ protected
102
+
103
+ def ensure_icon_exists ctx
104
+
105
+ src = @defaults[:src]
106
+
107
+ # Get the full destination path to the icon in the current project. If
108
+ # the icon already exists, then there is nothing left to do.
109
+ dest_icon_path = ctx.email.image_path(src)
110
+ return if File.exist?(dest_icon_path)
111
+
112
+ # Get the full path to the source icon bundled with Inkcite.
113
+ source_icon_path = File.join(Inkcite.asset_path, 'social', src)
114
+
115
+ # Ensure that the images/ directory exists in the project, then copy
116
+ # the image into it.
117
+ FileUtils.mkpath(ctx.email.image_dir)
118
+ FileUtils.cp(source_icon_path, dest_icon_path)
119
+
120
+ end
121
+
122
+ def get_share_href url, text, opts, ctx
123
+ raise NotImplementedError
124
+ end
125
+
126
+ end
127
+ end
128
+ end
@@ -2,6 +2,10 @@ module Inkcite
2
2
  module Renderer
3
3
  class Table < TableBase
4
4
 
5
+ # Name of the attribute allowing a transition attribute to be placed
6
+ # on the automatically-added <tr> element.
7
+ TR_TRANSITION = :'tr-transition'
8
+
5
9
  def render tag, opt, ctx
6
10
 
7
11
  html = ''
@@ -108,7 +112,15 @@ module Inkcite
108
112
  mix_responsive table, opt, ctx, mobile
109
113
 
110
114
  html << table.to_s
111
- html << '<tr>'
115
+
116
+ tr = Element.new('tr')
117
+
118
+ # Check for a row transition which is necessary to facilitate
119
+ # cross-column animation - e.g. tr-transition="all .5s cubic-bezier(0.075, 0.82, 0.165, 1)"
120
+ tr_transition = opt[TR_TRANSITION]
121
+ tr.style[:transition] = tr_transition unless none?(tr_transition)
122
+
123
+ html << tr.to_s
112
124
 
113
125
  if is_fluid_drop
114
126
 
@@ -12,6 +12,7 @@ module Inkcite
12
12
 
13
13
  def mix_all element, opt, ctx
14
14
 
15
+ mix_animation element, opt, ctx
15
16
  mix_background element, opt, ctx
16
17
  mix_border element, opt, ctx
17
18
  mix_dimensions element, opt, ctx
@@ -20,12 +21,11 @@ module Inkcite
20
21
 
21
22
  def mix_background element, opt, ctx
22
23
 
23
- bgcolor = opt[:bgcolor]
24
- bgcolor = nil if bgcolor == NONE
24
+ bgcolor = detect_bgcolor(opt)
25
25
 
26
26
  # Set the bgcolor attribute of the element as a fallback if
27
27
  # css isn't supported.
28
- element[:bgcolor] = hex(bgcolor) unless bgcolor.blank?
28
+ element[:bgcolor] = bgcolor unless bgcolor.nil?
29
29
 
30
30
  bgimage = opt[:background]
31
31
  bgposition = opt[BACKGROUND_POSITION]
@@ -24,9 +24,12 @@ module Inkcite
24
24
 
25
25
  if tag == CLOSE_TD
26
26
 
27
- # Retrieve the opts that were used to open this TD. We'll need them to
28
- # check for the private _fluid_drop attribute.
29
- tag_stack.pop
27
+ # Retrieve the opts that were used to open this TD.
28
+ open_opt = tag_stack.pop
29
+
30
+ # If the opening tag initiated an automatic outlook background
31
+ # then we need to inject the close tag here.
32
+ html << "{/outlook-bg}\n" if outlook_bg?(open_opt)
30
33
 
31
34
  # Normal HTML produced by the Helper to close the cell.
32
35
  html << '</td>'
@@ -126,7 +129,7 @@ module Inkcite
126
129
  :color => NONE,
127
130
  FONT_SIZE => 1,
128
131
  LINE_HEIGHT => 1
129
- }) unless opt[:flush].blank?
132
+ }) if opt[:flush]
130
133
 
131
134
  # Custom handling for text align on TDs rather than Base's mix_text_align
132
135
  # because if possible, using align= rather than a style keeps emails
@@ -165,11 +168,23 @@ module Inkcite
165
168
 
166
169
  mix_responsive td, opt, ctx, mobile
167
170
 
168
- #outlook-bg <!-&#45;&#91;if gte mso 9]>[n]<v:rect style="width:%width%px;height:%height%px;" strokecolor="none"><v:fill type="tile" src="%src%" /></v:fill></v:rect><v:shape id="theText[rnd]" style="position:absolute;width:%width%px;height:%height%px;margin:0;padding:0;%style%">[n]<!&#91;endif]&#45;->
169
- #/outlook-bg <!-&#45;&#91;if gte mso 9]></v:shape><!&#91;endif]&#45;->
170
-
171
171
  html << td.to_s
172
172
 
173
+ # For convenience and to keep code DRY, support the outlook-bg boolean
174
+ # attribute which causes an {outlook-bg} Helper to be injected automatically
175
+ # inside of the {td}.
176
+ if outlook_bg?(opt)
177
+
178
+ html << "\n"
179
+ html << Element.new('outlook-bg', {
180
+ :bgcolor => detect_bgcolor(opt),
181
+ :width => opt[:width],
182
+ :height => opt[:height],
183
+ :src => opt[:background]
184
+ }).to_helper
185
+
186
+ end
187
+
173
188
  end
174
189
 
175
190
  html
@@ -177,9 +192,19 @@ module Inkcite
177
192
 
178
193
  private
179
194
 
195
+ # Returns true if the conditions are met that enable the
196
+ # automatic {outlook-bg} helper integration.
197
+ def outlook_bg? opt
198
+ opt && opt[OUTLOOK_BG] && !opt[:background].blank?
199
+ end
200
+
180
201
  CLOSE_TD = '/td'
181
202
  LEFT = 'left'
182
203
 
204
+ # Boolean attribute triggering automatic outlook background
205
+ # integration in the TD.
206
+ OUTLOOK_BG = :'outlook-bg'
207
+
183
208
  end
184
209
  end
185
210
  end
@@ -0,0 +1,257 @@
1
+ module Inkcite
2
+ module Renderer
3
+
4
+ # Better video preview courtesy of @stigm
5
+ # https://medium.com/cm-engineering/better-video-previews-for-email-12432ce71846#.2o9qgc7hd
6
+ class VideoPreview < Base
7
+
8
+ def render tag, opt, ctx
9
+
10
+ # Get a unique ID for this video which will make its CSS classes
11
+ # distinct from other videos in the email.
12
+ uid = ctx.unique_id(:video)
13
+
14
+ # Links need an ID
15
+ id = opt[:id] || "video-preview#{uid}"
16
+
17
+ # Grab the URL for the video - this is passed on to the {a} Helper and
18
+ # the user will be warned appropriately if the URL is missing.
19
+ href = opt[:href].freeze
20
+
21
+ # Grab the name of the source file that can be optionally embeded with %1
22
+ # which will increment for each frame (e.g. video%1.jpg becomes video1.jpg,
23
+ # video2.jpg etc. up to the total number of frames). The original source
24
+ # image name is frozen to ensure it isn't modified later.
25
+ src = opt[:src].freeze
26
+
27
+ # This will hold all frame source file names interpolated to include
28
+ # index (e.g. %1 being replaced with the frame number, if present).
29
+ frames = []
30
+
31
+ # For each frame, create a fully-qualified image source and
32
+ # add it to the frame list.
33
+ frame_count = (opt[:frames] || 1).to_i
34
+
35
+ # True if the video clip will animate using smooth fading
36
+ # between several frames of the video.
37
+ has_animation = frame_count > 1
38
+
39
+ # Iterate through the frames and replace %1 with the frame number.
40
+ # this loop also verifies that the referenced image exists.
41
+ frame_count.times do |n|
42
+ frame_src = src.gsub('%1', "#{n + 1}")
43
+
44
+ # Check that non-fully-qualified images exist in the project's
45
+ # images/ directory.
46
+ unless Util::is_fully_qualified?(frame_src)
47
+ ctx.assert_image_exists(frame_src)
48
+ frame_src = ctx.image_url(frame_src)
49
+ end
50
+
51
+ frames << frame_src
52
+ end
53
+
54
+ # Grab the first fully-qualified frame
55
+ first_frame = frames[0]
56
+
57
+ # Duration of the animated frame cycling, if multiple frames are provided.
58
+ duration = (opt[:duration] || 15).to_i
59
+
60
+ # Desired dimensions of the video clip.
61
+ width = opt[:width].to_i
62
+ height = opt[:height].to_i
63
+ ctx.error("Video preview #{uid} is missing dimensions", { :width => width, :height => height, :src => src, :href => href }) unless width > 0 && height > 0
64
+
65
+ # Calculate the scaled width for the left-side of the table
66
+ # which is a crafty way to preserve the aspect ratio of the
67
+ # video while it still fluidly scales.
68
+ scaled_width = (width * SCALE).round
69
+
70
+ # Background color and edge gradient - which defaults to a darker
71
+ # version of the background color if not specified.
72
+ bgcolor = detect_bgcolor(opt, '#5b5f66')
73
+ gradient = opt[:gradient] || Util::darken(bgcolor, 0.25)
74
+
75
+ # This is the name of the class applied to the anchor tag
76
+ # to animate the hover.
77
+ hover_klass = 'video'
78
+ play_button_klass = 'play-button'
79
+
80
+ # This is the name of the animation, if any, that will be
81
+ # assigned to the table and defined in the CSS.
82
+ animation_name = "#{hover_klass}#{uid}-frames"
83
+
84
+ html = []
85
+ html << '<!--[if !vml]-->'
86
+
87
+ # Using an Element to produce the appropriate anchor helper with
88
+ # the desired
89
+ html << Element.new('a', { :id => id, :href => href, :class => hover_klass, :bgcolor => bgcolor, :bggradient => gradient, :block => true }).to_helper
90
+
91
+ table = Element.new('table', {
92
+ :width => '100%', :background => first_frame, BACKGROUND_SIZE => 'cover',
93
+ Table::TR_TRANSITION => %q("all .5s cubic-bezier(0.075, 0.82, 0.165, 1)")
94
+ })
95
+ table[:animation] = %Q("#{animation_name} #{duration}s ease infinite") if has_animation
96
+ html << table.to_helper
97
+
98
+ html << Element.new('td', :width => '25%').to_helper
99
+
100
+ # Transparent spacer for preserving aspect ratio.
101
+ spacer_image_name = "vp-#{scaled_width}x#{height}.png"
102
+ spacer_image = File.join(ctx.email.image_dir, spacer_image_name)
103
+
104
+ # Test if the file exists
105
+ unless File.exist?(spacer_image)
106
+
107
+ # Requiring on-demand, don't load chunky_png unless the user has
108
+ # started using video_preview.
109
+ require 'chunky_png'
110
+
111
+ # Creating an image from scratch, save as an interlaced PNG
112
+ png = ChunkyPNG::Image.new(scaled_width, height, ChunkyPNG::Color::TRANSPARENT)
113
+ png.save(spacer_image, :interlace => true)
114
+
115
+ end
116
+
117
+ # 12/21/16: Using placehold.it is sub-optimal. It would be better
118
+ # to generate a transparent gif using something like Rmagick. I've
119
+ # seen some instances where placeholdit can crash outlook but I guess
120
+ # this part is hidden from outlook so it isn't so bad.
121
+ html << Element.new('img', { :src => ctx.image_url(spacer_image_name), :alt => '', :width => '100%', :border => 0,
122
+ :style => { :height => :auto, :opacity => 0, :visibility => :hidden } }).to_s
123
+
124
+ html << '{/td}'
125
+
126
+ # Center column holds the CSS-based arrow
127
+ html << Element.new('td', :width => '50%', :align => :center, :valign => :middle).to_helper
128
+
129
+ play_arrow_size = (opt[PLAY_ARROW_SIZE] || 30).to_i
130
+
131
+ play_border_radius = (play_arrow_size * 1.1333).round
132
+ play_border_top_bottom = (play_arrow_size * 0.6).round
133
+ play_border_right = (play_arrow_size * 0.5333).round
134
+ play_border_left = (play_arrow_size * 0.8).round
135
+ play_arrow_height = (play_arrow_size * 0.5666).round
136
+
137
+ # These are the arrow and circle border, respectively. Not currently
138
+ # configurable in terms of size or color.
139
+ html << %Q(<div class="#{play_button_klass}" style="background-image: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.1)); border: 4px solid white; border-radius: 50%; box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 2px rgba(0,0,0,0.3); height: #{px(play_border_radius)}; margin: 0 auto; padding: #{px(play_border_top_bottom)} #{px(play_border_right)} #{px(play_border_top_bottom)} #{px(play_border_left)}; transition: transform .5s cubic-bezier(0.075, 0.82, 0.165, 1); width: #{px(play_border_radius)};">)
140
+ html << %Q(<div style="border-color: transparent transparent transparent white; border-style: solid; border-width: #{px(play_arrow_height)} 0 #{px(play_arrow_height)} #{px(play_arrow_size)}; display: block; font-size: 0; height: 0; Margin: 0 auto; width: 0;">&nbsp;</div>)
141
+ html << '</div>'
142
+
143
+ html << '{/td}'
144
+ html << '{td width=25%}&nbsp;{/td}'
145
+ html << '{/table}'
146
+ html << '{/a}'
147
+
148
+ # Pre-loading the images prevents a flash that can occur because the
149
+ # browser only loads the frames once the animation demands them.
150
+ if has_animation && !opt[NO_PRELOAD]
151
+ all_frames = frames.collect { |f| %Q(url(#{f})) }.join(',')
152
+ html << Element.new('div', :style => { BACKGROUND_IMAGE => %Q("#{all_frames}"), :display => 'none' }).to_s + '</div>'
153
+ end
154
+
155
+ # Concludes the if [if !vml] section targeting non-outlook.
156
+ html << '<![endif]-->'
157
+
158
+ # Outlook arrow size also not configurable at this time.
159
+ outlook_arrow_size = 78
160
+ outlook_left = width / 2 - outlook_arrow_size / 2
161
+ outlook_top = height / 2 - outlook_arrow_size / 2
162
+
163
+ # Use the link central processing routine to ensure a viable link has
164
+ # been provided and tag/track it from Outlook.
165
+ outlook_href = Link.process(id, href, false, ctx)
166
+
167
+ html << '<!--[if vml]>'
168
+ html << %Q(<v:group xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" coordsize="#{width},#{height}" coordorigin="0,0" href="#{outlook_href}" style="width:#{width}px;height:#{height}px;">)
169
+ html << %Q(<v:rect fill="t" stroked="f" style="position:absolute;width:#{width};height:#{height};"><v:fill src="#{first_frame}" type="frame"/></v:rect>)
170
+ html << %Q(<v:oval fill="t" strokecolor="white" strokeweight="4px" style="position:absolute;left:#{outlook_left};top:"#{outlook_top};width:#{outlook_arrow_size};height:#{outlook_arrow_size}"><v:fill color="black" opacity="30%" /></v:oval>)
171
+ html << %q(<v:shape coordsize="24,32" path="m,l,32,24,16,xe" fillcolor="white" stroked="f" style="position:absolute;left:289;top:151;width:30;height:34;" />)
172
+ html << '</v:group>'
173
+ html << '<![endif]-->'
174
+
175
+ # Will hold any CSS styles, if there are some necessary
176
+ # to inject into the email.
177
+ styles = []
178
+
179
+ # If this is the first video clip in the email, we need
180
+ # to include the general styles shared across all clips.
181
+ if uid == 1
182
+ styles << ".#{hover_klass}:hover .#{play_button_klass} {"
183
+ styles << ' transform: scale(1.1);'
184
+ styles << '}'
185
+ styles << ".#{hover_klass}:hover tr {"
186
+ styles << ' background-color: rgba(255, 255, 255, .2);'
187
+ styles << '}'
188
+ end
189
+
190
+ # If this video clip has animation, then we need to include
191
+ # the keyframes necessary to smoothly animate between each.
192
+ if has_animation
193
+
194
+ # The time spent in each frame is based on a weighted distribution
195
+ # of frames vs. transition time between frames.
196
+ total_weight = ((FRAME_WEIGHT * frame_count) + (TRANSITION_WEIGHT * frame_count)).to_f
197
+ percent_per_frame = (FRAME_WEIGHT / total_weight * 100.0).round
198
+ percent_per_transition = (TRANSITION_WEIGHT / total_weight * 100.0).round
199
+
200
+ # This will hold the total percentage as we increment toward the
201
+ # end of the animation.
202
+ percent = 0.0
203
+
204
+ keyframes = Animation::Keyframes.new(animation_name, ctx)
205
+
206
+ # Iterate through each frame and add two keyframes, the first
207
+ # being the time at which the frame appears plus another frame
208
+ # after the duration it should be on screen.
209
+ frames.each do |f|
210
+ this_frame_url = "url(#{f})"
211
+
212
+ keyframes.add_keyframe(percent, { BACKGROUND_IMAGE => this_frame_url })
213
+ percent += percent_per_frame
214
+ keyframes.add_keyframe(percent, { BACKGROUND_IMAGE => this_frame_url })
215
+ percent += percent_per_transition
216
+
217
+ end
218
+
219
+ # Transition back to the first frame.
220
+ keyframes.add_keyframe(100, { BACKGROUND_IMAGE => "url(#{first_frame})" })
221
+
222
+ # Add the keyframes to the styles array.
223
+ styles << keyframes.to_s
224
+
225
+ end
226
+
227
+ # Add the styles to the email's header
228
+ ctx.styles << styles.join("\n") unless styles.blank?
229
+
230
+ html.join("\n")
231
+
232
+ end
233
+
234
+ private
235
+
236
+ # Name of the attribute that controls the size of the play button arrow.
237
+ PLAY_ARROW_SIZE = :'play-size'
238
+
239
+ # Name of the boolean attribute that can be provided to disable the
240
+ # preloading of images in an animation.
241
+ NO_PRELOAD = :'no-preload'
242
+
243
+ # Constants defining the weight of a frame relative to the weight of
244
+ # the transition. In this case, 2-to-1 means each frame will be on
245
+ # the screen for twice as long as it takes to transition between
246
+ # them.
247
+ FRAME_WEIGHT = 2.0
248
+ TRANSITION_WEIGHT = 1.0
249
+
250
+ # Scale applied to the width of the image to preserve the aspect
251
+ # ratio of the video that fluidly scales on mobile devices.
252
+ SCALE = 0.25
253
+
254
+ end
255
+ end
256
+ end
257
+