at the GIF's own width and height. * * Content is not modified: editors keep the Image block and the GIF, and * removing the meta restores the GIF. Videos load nothing until they are near * the screen, then play muted in a loop like the GIF did. */ final class GifVideo { const VIDEO_META = '_betterdocs_gif_video'; const POSTER_META = '_betterdocs_gif_poster'; private static $printed_script = false; public static function init() { $self = new self(); add_action( 'init', [ $self, 'register_meta' ] ); add_filter( 'render_block_core/image', [ $self, 'render_image' ], 10, 2 ); add_filter( 'attachment_fields_to_edit', [ $self, 'attachment_fields' ], 10, 2 ); add_filter( 'attachment_fields_to_save', [ $self, 'save_attachment_fields' ], 10, 2 ); add_action( 'wp_enqueue_media', [ $self, 'enqueue_picker' ] ); } /** * "Video version" and "Poster image" fields on a GIF's attachment details * (media modal and attachment edit screen), so editors can link them * without the REST API. * * @param array $fields Attachment form fields. * @param \WP_Post $post Attachment. * @return array */ public function attachment_fields( $fields, $post ) { if ( 'image/gif' !== get_post_mime_type( $post ) || ! current_user_can( 'upload_files' ) ) { return $fields; } $fields['betterdocs_gif_video'] = [ 'label' => __( 'Video version', 'betterdocs' ), 'input' => 'html', 'html' => $this->picker_html( $post->ID, self::VIDEO_META, 'video' ), 'helps' => __( 'An MP4 of this GIF. Docs play it in place of the GIF: same look, much smaller download.', 'betterdocs' ), ]; $fields['betterdocs_gif_poster'] = [ 'label' => __( 'Poster image', 'betterdocs' ), 'input' => 'html', 'html' => $this->picker_html( $post->ID, self::POSTER_META, 'image' ), 'helps' => __( 'Optional. Shown until the video starts.', 'betterdocs' ), ]; return $fields; } /** * @param int $id GIF attachment ID. * @param string $key Meta key. * @param string $type Library type to pick from: "video" or "image". * @return string */ private function picker_html( $id, $key, $type ) { $field = 'betterdocs_gif_' . ( self::VIDEO_META === $key ? 'video' : 'poster' ); $linked = (int) get_post_meta( $id, $key, true ); $file = $linked ? get_attached_file( $linked ) : ''; $name = $file ? wp_basename( $file ) : ''; return sprintf( '' . '%4$s' . ' ' . '', (int) $id, esc_attr( $field ), $name ? $linked : 0, esc_html( $name ), esc_attr( $type ), esc_attr( 'video' === $type ? __( 'Choose the video version', 'betterdocs' ) : __( 'Choose a poster image', 'betterdocs' ) ), esc_attr__( 'Use this file', 'betterdocs' ), esc_html__( 'Choose', 'betterdocs' ), $name ? '' : ' hidden', esc_html__( 'Remove', 'betterdocs' ) ); } /** * Store the picked files; ignore anything that isn't a video (clip) or * an image (poster). * * @param array $post Attachment post data. * @param array $attachment Submitted attachment fields. * @return array */ public function save_attachment_fields( $post, $attachment ) { if ( empty( $post['ID'] ) || 'image/gif' !== get_post_mime_type( $post['ID'] ) || ! current_user_can( 'upload_files' ) ) { return $post; } foreach ( [ 'betterdocs_gif_video' => [ self::VIDEO_META, 'video/' ], 'betterdocs_gif_poster' => [ self::POSTER_META, 'image/' ] ] as $field => $target ) { if ( ! isset( $attachment[ $field ] ) ) { continue; } $value = absint( $attachment[ $field ] ); if ( ! $value ) { delete_post_meta( $post['ID'], $target[0] ); } elseif ( 0 === strpos( (string) get_post_mime_type( $value ), $target[1] ) ) { update_post_meta( $post['ID'], $target[0], $value ); } } return $post; } /** * Open the media library to pick the video or poster. Loaded wherever * the media modal is. The field is looked up again on select: closing * the image picker re-renders the GIF's details (the GIF is in that * library), which replaces the field's markup. */ public function enqueue_picker() { if ( ! current_user_can( 'upload_files' ) ) { return; } $js = <<<'JS' jQuery(function($){var frames={}; 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');} $(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'); var f=frames[t]||(frames[t]=wp.media({title:b.data('title'),library:{type:t},multiple:false,button:{text:b.data('button')}})); f.off('select').on('select',function(){var a=f.state().get('selection').first();if(a){set(name,a.id,a.get('filename'));}}); f.open();}); $(document).on('click','.betterdocs-gif-clear',function(e){e.preventDefault();set($(this).closest('.betterdocs-gif-field').find('input').attr('name'),0,'');}); }); JS; wp_add_inline_script( 'media-views', $js ); } /** * Attachment IDs of the MP4 and the poster, editable over REST by users * who can upload media. */ public function register_meta() { foreach ( [ self::VIDEO_META => 'video/', self::POSTER_META => 'image/' ] as $key => $type ) { register_post_meta( 'attachment', $key, [ 'type' => 'integer', 'single' => true, 'show_in_rest' => true, 'default' => 0, 'sanitize_callback' => function ( $value ) use ( $type ) { $value = absint( $value ); return self::is_attachment_type( $value, $type ) ? $value : 0; }, 'auth_callback' => function () { return current_user_can( 'upload_files' ); }, ] ); } } /** * Whether an attachment exists and its mime type starts with $type * ("video/" for the clip, "image/" for the poster). * * @param int $id Attachment ID. * @param string $type Mime type prefix. * @return bool */ private static function is_attachment_type( $id, $type ) { return $id && 0 === strpos( (string) get_post_mime_type( $id ), $type ); } /** * @param string $content Rendered Image block. * @param array $block Parsed block. * @return string */ public function render_image( $content, $block ) { if ( is_admin() || wp_is_json_request() || 'docs' !== get_post_type() ) { return $content; } // Core adds "Expand on click" after this filter, and only to an . // Its render callback has already hooked the lightbox for this block // when it applies, so keep the image there. if ( false !== has_filter( 'render_block_core/image', 'block_core_image_render_lightbox' ) ) { return $content; } $id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0; if ( ! $id && preg_match( '/\bwp-image-(\d+)\b/', $content, $m ) ) { $id = (int) $m[1]; } if ( ! $id || 'image/gif' !== get_post_mime_type( $id ) ) { return $content; } // Only the doc page itself gets the video. The REST API, feeds and // the Markdown view render content outside the page template (before // or during template_redirect) and keep the GIF for their readers. if ( ! did_action( 'template_redirect' ) || doing_action( 'template_redirect' ) || is_feed() ) { return $content; } $video_id = (int) get_post_meta( $id, self::VIDEO_META, true ); $src = self::is_attachment_type( $video_id, 'video/' ) ? wp_get_attachment_url( $video_id ) : ''; if ( ! $src || ! preg_match( '/]*>/i', $content, $img ) ) { return $content; } $tag = new \WP_HTML_Tag_Processor( $img[0] ); $tag->next_tag( 'img' ); // Size the video like the image the block shows (full, Medium, …), // and keep the block's own inline styles (width, aspect ratio, crop, // border), so the video takes the same space as the GIF did. $width = $tag->get_attribute( 'width' ); $height = $tag->get_attribute( 'height' ); if ( ! $width || ! $height ) { $meta = wp_get_attachment_metadata( $id ); $dimensions = is_array( $meta ) ? wp_image_src_get_dimensions( (string) $tag->get_attribute( 'src' ), $meta, $id ) : false; if ( $dimensions ) { list( $width, $height ) = $dimensions; } elseif ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) { $width = (int) $meta['width']; $height = (int) $meta['height']; } } // An image shows at its width attribute, capped by its container; pin // that width inline, since themes often stretch `figure > video`. $style = $width ? sprintf( 'width: %dpx; ', (int) $width ) : ''; $style = trim( $style . 'max-width: 100%; height: auto; ' . (string) $tag->get_attribute( 'style' ) ); $attrs = [ 'class' => $tag->get_attribute( 'class' ), 'src' => $src, 'width' => $width, 'height' => $height, 'aria-label' => $tag->get_attribute( 'alt' ), 'style' => $style, 'preload' => 'none', 'data-betterdocs-gif' => '1', // The clip plays itself while on screen (print_script()). Keep // lazy-load/facade optimizers off it: xSpeed's click-to-play // facade collapsed it to a 0x0 button in centred image figures. 'data-skip-lazy' => '1', 'data-no-lazy' => '1', ]; $poster_id = (int) get_post_meta( $id, self::POSTER_META, true ); $poster = self::is_attachment_type( $poster_id, 'image/' ) ? wp_get_attachment_url( $poster_id ) : ''; if ( $poster ) { $attrs['poster'] = $poster; } $html = ''; $this->enqueue_style(); $this->print_script(); return str_replace( $img[0], $html, $content ); } /** * Core's Image and Gallery styles target `img`; apply the same rules * (rounded style, wide/full alignment, cropped gallery) to the video. */ private function enqueue_style() { if ( wp_style_is( 'betterdocs-gif-video', 'enqueued' ) ) { return; } $v = 'video[data-betterdocs-gif]'; $css = ".wp-block-image {$v}{vertical-align:bottom;box-sizing:border-box}" . ".wp-block-image[style*=border-radius] {$v}{border-radius:inherit}" . ".wp-block-image.alignfull {$v},.wp-block-image.alignwide {$v}{width:100% !important}" . ".wp-block-image.is-style-circle-mask {$v}{border-radius:9999px}" . ":root :where(.wp-block-image.is-style-rounded {$v},.wp-block-image .is-style-rounded {$v}){border-radius:9999px}" . ".wp-block-gallery.has-nested-images figure.wp-block-image {$v}{display:block;max-width:100% !important}" . ".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}"; wp_register_style( 'betterdocs-gif-video', false, [], BETTERDOCS_VERSION ); wp_add_inline_style( 'betterdocs-gif-video', $css ); wp_enqueue_style( 'betterdocs-gif-video' ); } /** * Play each video while it is on screen, like the GIF animated; pause it * off screen. With reduced motion requested, or when the browser blocks * autoplay (e.g. iOS Low Power Mode), show controls instead. */ private function print_script() { if ( self::$printed_script ) { return; } self::$printed_script = true; add_action( 'wp_footer', function () { $js = <<<'JS' (function(){var v=document.querySelectorAll('video[data-betterdocs-gif]');if(!v.length)return; if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches){v.forEach(function(e){e.controls=true;e.preload='metadata';});return;} function play(e){var p=e.play();if(p&&p.catch)p.catch(function(){e.controls=true;e.preload='metadata';});} if(!('IntersectionObserver' in window)){v.forEach(play);return;} var o=new IntersectionObserver(function(es){es.forEach(function(x){if(x.isIntersecting)play(x.target);else x.target.pause();});},{rootMargin:'200px 0px'}); v.forEach(function(e){o.observe(e);});})(); JS; wp_print_inline_script_tag( $js, [ 'id' => 'betterdocs-gif-video' ] ); }, 20 ); } }