| 1 |
<?php |
| 2 |
namespace WPDeveloper\BetterDocs\Modules; |
| 3 |
|
| 4 |
/** |
| 5 |
* Serve animated GIFs in docs as muted, looping video. |
| 6 |
* |
| 7 |
* Docs lean on GIF screencasts, and GIF is a heavy format: on |
| 8 |
* essential-blocks.com, 340 GIFs across 51 docs weighed 756 MB, while the same |
| 9 |
* clips as H.264 MP4 weigh 97 MB. A GIF attachment can be linked to a converted |
| 10 |
* MP4 (and a poster frame) through two attachment meta fields; when it is, the |
| 11 |
* GIF's Image block renders as a <video> at the GIF's own width and height. |
| 12 |
* |
| 13 |
* Content is not modified: editors keep the Image block and the GIF, and |
| 14 |
* removing the meta restores the GIF. Videos load nothing until they are near |
| 15 |
* the screen, then play muted in a loop like the GIF did. |
| 16 |
*/ |
| 17 |
final class GifVideo { |
| 18 |
const VIDEO_META = '_betterdocs_gif_video'; |
| 19 |
const POSTER_META = '_betterdocs_gif_poster'; |
| 20 |
|
| 21 |
private static $printed_script = false; |
| 22 |
|
| 23 |
public static function init() { |
| 24 |
$self = new self(); |
| 25 |
add_action( 'init', [ $self, 'register_meta' ] ); |
| 26 |
add_filter( 'render_block_core/image', [ $self, 'render_image' ], 10, 2 ); |
| 27 |
add_filter( 'attachment_fields_to_edit', [ $self, 'attachment_fields' ], 10, 2 ); |
| 28 |
add_filter( 'attachment_fields_to_save', [ $self, 'save_attachment_fields' ], 10, 2 ); |
| 29 |
add_action( 'wp_enqueue_media', [ $self, 'enqueue_picker' ] ); |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* "Video version" and "Poster image" fields on a GIF's attachment details |
| 34 |
* (media modal and attachment edit screen), so editors can link them |
| 35 |
* without the REST API. |
| 36 |
* |
| 37 |
* @param array $fields Attachment form fields. |
| 38 |
* @param \WP_Post $post Attachment. |
| 39 |
* @return array |
| 40 |
*/ |
| 41 |
public function attachment_fields( $fields, $post ) { |
| 42 |
if ( 'image/gif' !== get_post_mime_type( $post ) || ! current_user_can( 'upload_files' ) ) { |
| 43 |
return $fields; |
| 44 |
} |
| 45 |
|
| 46 |
$fields['betterdocs_gif_video'] = [ |
| 47 |
'label' => __( 'Video version', 'betterdocs' ), |
| 48 |
'input' => 'html', |
| 49 |
'html' => $this->picker_html( $post->ID, self::VIDEO_META, 'video' ), |
| 50 |
'helps' => __( 'An MP4 of this GIF. Docs play it in place of the GIF: same look, much smaller download.', 'betterdocs' ), |
| 51 |
]; |
| 52 |
$fields['betterdocs_gif_poster'] = [ |
| 53 |
'label' => __( 'Poster image', 'betterdocs' ), |
| 54 |
'input' => 'html', |
| 55 |
'html' => $this->picker_html( $post->ID, self::POSTER_META, 'image' ), |
| 56 |
'helps' => __( 'Optional. Shown until the video starts.', 'betterdocs' ), |
| 57 |
]; |
| 58 |
|
| 59 |
return $fields; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* @param int $id GIF attachment ID. |
| 64 |
* @param string $key Meta key. |
| 65 |
* @param string $type Library type to pick from: "video" or "image". |
| 66 |
* @return string |
| 67 |
*/ |
| 68 |
private function picker_html( $id, $key, $type ) { |
| 69 |
$field = 'betterdocs_gif_' . ( self::VIDEO_META === $key ? 'video' : 'poster' ); |
| 70 |
$linked = (int) get_post_meta( $id, $key, true ); |
| 71 |
$file = $linked ? get_attached_file( $linked ) : ''; |
| 72 |
$name = $file ? wp_basename( $file ) : ''; |
| 73 |
|
| 74 |
return sprintf( |
| 75 |
'<span class="betterdocs-gif-field"><input type="hidden" id="attachments-%1$d-%2$s" name="attachments[%1$d][%2$s]" value="%3$d" />' |
| 76 |
. '<span class="betterdocs-gif-name" style="display:block;word-break:break-all;margin-bottom:4px">%4$s</span>' |
| 77 |
. '<button type="button" class="button button-small betterdocs-gif-pick" data-type="%5$s" data-title="%6$s" data-button="%7$s">%8$s</button> ' |
| 78 |
. '<button type="button" class="button-link button-link-delete betterdocs-gif-clear"%9$s>%10$s</button></span>', |
| 79 |
(int) $id, |
| 80 |
esc_attr( $field ), |
| 81 |
$name ? $linked : 0, |
| 82 |
esc_html( $name ), |
| 83 |
esc_attr( $type ), |
| 84 |
esc_attr( 'video' === $type ? __( 'Choose the video version', 'betterdocs' ) : __( 'Choose a poster image', 'betterdocs' ) ), |
| 85 |
esc_attr__( 'Use this file', 'betterdocs' ), |
| 86 |
esc_html__( 'Choose', 'betterdocs' ), |
| 87 |
$name ? '' : ' hidden', |
| 88 |
esc_html__( 'Remove', 'betterdocs' ) |
| 89 |
); |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* Store the picked files; ignore anything that isn't a video (clip) or |
| 94 |
* an image (poster). |
| 95 |
* |
| 96 |
* @param array $post Attachment post data. |
| 97 |
* @param array $attachment Submitted attachment fields. |
| 98 |
* @return array |
| 99 |
*/ |
| 100 |
public function save_attachment_fields( $post, $attachment ) { |
| 101 |
if ( empty( $post['ID'] ) || 'image/gif' !== get_post_mime_type( $post['ID'] ) || ! current_user_can( 'upload_files' ) ) { |
| 102 |
return $post; |
| 103 |
} |
| 104 |
|
| 105 |
foreach ( [ 'betterdocs_gif_video' => [ self::VIDEO_META, 'video/' ], 'betterdocs_gif_poster' => [ self::POSTER_META, 'image/' ] ] as $field => $target ) { |
| 106 |
if ( ! isset( $attachment[ $field ] ) ) { |
| 107 |
continue; |
| 108 |
} |
| 109 |
$value = absint( $attachment[ $field ] ); |
| 110 |
if ( ! $value ) { |
| 111 |
delete_post_meta( $post['ID'], $target[0] ); |
| 112 |
} elseif ( 0 === strpos( (string) get_post_mime_type( $value ), $target[1] ) ) { |
| 113 |
update_post_meta( $post['ID'], $target[0], $value ); |
| 114 |
} |
| 115 |
} |
| 116 |
|
| 117 |
return $post; |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Open the media library to pick the video or poster. Loaded wherever |
| 122 |
* the media modal is. The field is looked up again on select: closing |
| 123 |
* the image picker re-renders the GIF's details (the GIF is in that |
| 124 |
* library), which replaces the field's markup. |
| 125 |
*/ |
| 126 |
public function enqueue_picker() { |
| 127 |
if ( ! current_user_can( 'upload_files' ) ) { |
| 128 |
return; |
| 129 |
} |
| 130 |
|
| 131 |
$js = <<<'JS' |
| 132 |
jQuery(function($){var frames={}; |
| 133 |
function set(name,id,label){var w=$('input[name="'+name+'"]').last().closest('.betterdocs-gif-field');w.find('.betterdocs-gif-name').text(label);w.find('.betterdocs-gif-clear').prop('hidden',!id);w.find('input').val(id).trigger('change');} |
| 134 |
$(document).on('click','.betterdocs-gif-pick',function(e){e.preventDefault();var b=$(this),t=b.data('type'),name=b.closest('.betterdocs-gif-field').find('input').attr('name'); |
| 135 |
var f=frames[t]||(frames[t]=wp.media({title:b.data('title'),library:{type:t},multiple:false,button:{text:b.data('button')}})); |
| 136 |
f.off('select').on('select',function(){var a=f.state().get('selection').first();if(a){set(name,a.id,a.get('filename'));}}); |
| 137 |
f.open();}); |
| 138 |
$(document).on('click','.betterdocs-gif-clear',function(e){e.preventDefault();set($(this).closest('.betterdocs-gif-field').find('input').attr('name'),0,'');}); |
| 139 |
}); |
| 140 |
JS; |
| 141 |
wp_add_inline_script( 'media-views', $js ); |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Attachment IDs of the MP4 and the poster, editable over REST by users |
| 146 |
* who can upload media. |
| 147 |
*/ |
| 148 |
public function register_meta() { |
| 149 |
foreach ( [ self::VIDEO_META => 'video/', self::POSTER_META => 'image/' ] as $key => $type ) { |
| 150 |
register_post_meta( |
| 151 |
'attachment', |
| 152 |
$key, |
| 153 |
[ |
| 154 |
'type' => 'integer', |
| 155 |
'single' => true, |
| 156 |
'show_in_rest' => true, |
| 157 |
'default' => 0, |
| 158 |
'sanitize_callback' => function ( $value ) use ( $type ) { |
| 159 |
$value = absint( $value ); |
| 160 |
return self::is_attachment_type( $value, $type ) ? $value : 0; |
| 161 |
}, |
| 162 |
'auth_callback' => function () { |
| 163 |
return current_user_can( 'upload_files' ); |
| 164 |
}, |
| 165 |
] |
| 166 |
); |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Whether an attachment exists and its mime type starts with $type |
| 172 |
* ("video/" for the clip, "image/" for the poster). |
| 173 |
* |
| 174 |
* @param int $id Attachment ID. |
| 175 |
* @param string $type Mime type prefix. |
| 176 |
* @return bool |
| 177 |
*/ |
| 178 |
private static function is_attachment_type( $id, $type ) { |
| 179 |
return $id && 0 === strpos( (string) get_post_mime_type( $id ), $type ); |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* @param string $content Rendered Image block. |
| 184 |
* @param array $block Parsed block. |
| 185 |
* @return string |
| 186 |
*/ |
| 187 |
public function render_image( $content, $block ) { |
| 188 |
if ( is_admin() || wp_is_json_request() || 'docs' !== get_post_type() ) { |
| 189 |
return $content; |
| 190 |
} |
| 191 |
|
| 192 |
// Core adds "Expand on click" after this filter, and only to an <img>. |
| 193 |
// Its render callback has already hooked the lightbox for this block |
| 194 |
// when it applies, so keep the image there. |
| 195 |
if ( false !== has_filter( 'render_block_core/image', 'block_core_image_render_lightbox' ) ) { |
| 196 |
return $content; |
| 197 |
} |
| 198 |
|
| 199 |
$id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0; |
| 200 |
if ( ! $id && preg_match( '/\bwp-image-(\d+)\b/', $content, $m ) ) { |
| 201 |
$id = (int) $m[1]; |
| 202 |
} |
| 203 |
if ( ! $id || 'image/gif' !== get_post_mime_type( $id ) ) { |
| 204 |
return $content; |
| 205 |
} |
| 206 |
|
| 207 |
// Only the doc page itself gets the video. The REST API, feeds and |
| 208 |
// the Markdown view render content outside the page template (before |
| 209 |
// or during template_redirect) and keep the GIF for their readers. |
| 210 |
if ( ! did_action( 'template_redirect' ) || doing_action( 'template_redirect' ) || is_feed() ) { |
| 211 |
return $content; |
| 212 |
} |
| 213 |
|
| 214 |
$video_id = (int) get_post_meta( $id, self::VIDEO_META, true ); |
| 215 |
$src = self::is_attachment_type( $video_id, 'video/' ) ? wp_get_attachment_url( $video_id ) : ''; |
| 216 |
if ( ! $src || ! preg_match( '/<img\b[^>]*>/i', $content, $img ) ) { |
| 217 |
return $content; |
| 218 |
} |
| 219 |
|
| 220 |
$tag = new \WP_HTML_Tag_Processor( $img[0] ); |
| 221 |
$tag->next_tag( 'img' ); |
| 222 |
|
| 223 |
// Size the video like the image the block shows (full, Medium, …), |
| 224 |
// and keep the block's own inline styles (width, aspect ratio, crop, |
| 225 |
// border), so the video takes the same space as the GIF did. |
| 226 |
$width = $tag->get_attribute( 'width' ); |
| 227 |
$height = $tag->get_attribute( 'height' ); |
| 228 |
if ( ! $width || ! $height ) { |
| 229 |
$meta = wp_get_attachment_metadata( $id ); |
| 230 |
$dimensions = is_array( $meta ) ? wp_image_src_get_dimensions( (string) $tag->get_attribute( 'src' ), $meta, $id ) : false; |
| 231 |
if ( $dimensions ) { |
| 232 |
list( $width, $height ) = $dimensions; |
| 233 |
} elseif ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) { |
| 234 |
$width = (int) $meta['width']; |
| 235 |
$height = (int) $meta['height']; |
| 236 |
} |
| 237 |
} |
| 238 |
// An image shows at its width attribute, capped by its container; pin |
| 239 |
// that width inline, since themes often stretch `figure > video`. |
| 240 |
$style = $width ? sprintf( 'width: %dpx; ', (int) $width ) : ''; |
| 241 |
$style = trim( $style . 'max-width: 100%; height: auto; ' . (string) $tag->get_attribute( 'style' ) ); |
| 242 |
|
| 243 |
$attrs = [ |
| 244 |
'class' => $tag->get_attribute( 'class' ), |
| 245 |
'src' => $src, |
| 246 |
'width' => $width, |
| 247 |
'height' => $height, |
| 248 |
'aria-label' => $tag->get_attribute( 'alt' ), |
| 249 |
'style' => $style, |
| 250 |
'preload' => 'none', |
| 251 |
'data-betterdocs-gif' => '1', |
| 252 |
// The clip plays itself while on screen (print_script()). Keep |
| 253 |
// lazy-load/facade optimizers off it: xSpeed's click-to-play |
| 254 |
// facade collapsed it to a 0x0 button in centred image figures. |
| 255 |
'data-skip-lazy' => '1', |
| 256 |
'data-no-lazy' => '1', |
| 257 |
]; |
| 258 |
$poster_id = (int) get_post_meta( $id, self::POSTER_META, true ); |
| 259 |
$poster = self::is_attachment_type( $poster_id, 'image/' ) ? wp_get_attachment_url( $poster_id ) : ''; |
| 260 |
if ( $poster ) { |
| 261 |
$attrs['poster'] = $poster; |
| 262 |
} |
| 263 |
|
| 264 |
$html = '<video muted loop playsinline'; |
| 265 |
foreach ( $attrs as $name => $value ) { |
| 266 |
if ( null === $value || '' === $value || false === $value ) { |
| 267 |
continue; |
| 268 |
} |
| 269 |
$html .= sprintf( ' %s="%s"', $name, 'src' === $name || 'poster' === $name ? esc_url( $value ) : esc_attr( $value ) ); |
| 270 |
} |
| 271 |
$html .= '></video>'; |
| 272 |
|
| 273 |
$this->enqueue_style(); |
| 274 |
$this->print_script(); |
| 275 |
|
| 276 |
return str_replace( $img[0], $html, $content ); |
| 277 |
} |
| 278 |
|
| 279 |
/** |
| 280 |
* Core's Image and Gallery styles target `img`; apply the same rules |
| 281 |
* (rounded style, wide/full alignment, cropped gallery) to the video. |
| 282 |
*/ |
| 283 |
private function enqueue_style() { |
| 284 |
if ( wp_style_is( 'betterdocs-gif-video', 'enqueued' ) ) { |
| 285 |
return; |
| 286 |
} |
| 287 |
|
| 288 |
$v = 'video[data-betterdocs-gif]'; |
| 289 |
$css = ".wp-block-image {$v}{vertical-align:bottom;box-sizing:border-box}" |
| 290 |
. ".wp-block-image[style*=border-radius] {$v}{border-radius:inherit}" |
| 291 |
. ".wp-block-image.alignfull {$v},.wp-block-image.alignwide {$v}{width:100% !important}" |
| 292 |
. ".wp-block-image.is-style-circle-mask {$v}{border-radius:9999px}" |
| 293 |
. ":root :where(.wp-block-image.is-style-rounded {$v},.wp-block-image .is-style-rounded {$v}){border-radius:9999px}" |
| 294 |
. ".wp-block-gallery.has-nested-images figure.wp-block-image {$v}{display:block;max-width:100% !important}" |
| 295 |
. ".wp-block-gallery.has-nested-images.is-cropped figure.wp-block-image:not(#individual-image) {$v}{width:100% !important;flex:1 0 0%;height:100% !important;object-fit:cover}"; |
| 296 |
|
| 297 |
wp_register_style( 'betterdocs-gif-video', false, [], BETTERDOCS_VERSION ); |
| 298 |
wp_add_inline_style( 'betterdocs-gif-video', $css ); |
| 299 |
wp_enqueue_style( 'betterdocs-gif-video' ); |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Play each video while it is on screen, like the GIF animated; pause it |
| 304 |
* off screen. With reduced motion requested, or when the browser blocks |
| 305 |
* autoplay (e.g. iOS Low Power Mode), show controls instead. |
| 306 |
*/ |
| 307 |
private function print_script() { |
| 308 |
if ( self::$printed_script ) { |
| 309 |
return; |
| 310 |
} |
| 311 |
self::$printed_script = true; |
| 312 |
|
| 313 |
add_action( |
| 314 |
'wp_footer', |
| 315 |
function () { |
| 316 |
$js = <<<'JS' |
| 317 |
(function(){var v=document.querySelectorAll('video[data-betterdocs-gif]');if(!v.length)return; |
| 318 |
if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches){v.forEach(function(e){e.controls=true;e.preload='metadata';});return;} |
| 319 |
function play(e){var p=e.play();if(p&&p.catch)p.catch(function(){e.controls=true;e.preload='metadata';});} |
| 320 |
if(!('IntersectionObserver' in window)){v.forEach(play);return;} |
| 321 |
var o=new IntersectionObserver(function(es){es.forEach(function(x){if(x.isIntersecting)play(x.target);else x.target.pause();});},{rootMargin:'200px 0px'}); |
| 322 |
v.forEach(function(e){o.observe(e);});})(); |
| 323 |
JS; |
| 324 |
wp_print_inline_script_tag( $js, [ 'id' => 'betterdocs-gif-video' ] ); |
| 325 |
}, |
| 326 |
20 |
| 327 |
); |
| 328 |
} |
| 329 |
} |
| 330 |
|