Last active
September 12, 2025 15:09
-
-
Save sethrubenstein/63befb34c78e763b22bcc7a1d9613e00 to your computer and use it in GitHub Desktop.
core/dialog extensibility
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Core Dialog Block | |
| * | |
| * @package PRC\Platform\Blocks | |
| */ | |
| namespace PRC\Platform\Blocks; | |
| use WP_HTML_Tag_Processor; | |
| /** | |
| * Block Name: Core Dialog | |
| * Version: 0.1.0 | |
| * Requires at least: 6.1 | |
| * Requires PHP: 8.1 | |
| * Author: Pew Research Center | |
| * | |
| * @package prc-block | |
| */ | |
| class Core_Dialog { | |
| /** | |
| * Block name | |
| * | |
| * @var string | |
| */ | |
| public $block_name = 'prc-block/dialog'; // @TODO: replace with core/dialog when finally merged into WP core. | |
| /** | |
| * Block JSON | |
| * | |
| * @var array | |
| */ | |
| public $block_json; | |
| /** | |
| * Editor script handle | |
| * | |
| * @var string | |
| */ | |
| public $editor_script_handle; | |
| /** | |
| * Style handle | |
| * | |
| * @var string | |
| */ | |
| public $style_handle; | |
| /** | |
| * View script module handle | |
| * | |
| * @var string | |
| */ | |
| public $view_script_module_handle; | |
| /** | |
| * Constructor | |
| * | |
| * @param mixed $loader Loader. | |
| */ | |
| public function __construct( $loader ) { | |
| require_once PRC_BLOCK_LIBRARY_DIR . '/build/core-dialog/util.php'; | |
| $this->block_json = prc_block_library_manifest( 'core-dialog' ); | |
| $this->init( $loader ); | |
| } | |
| /** | |
| * Initialize the block | |
| * | |
| * @param mixed $loader Loader. | |
| */ | |
| public function init( $loader = null ) { | |
| if ( null !== $loader ) { | |
| $loader->add_action( 'init', $this, 'register_assets' ); | |
| $loader->add_action( 'enqueue_block_editor_assets', $this, 'register_editor_assets' ); | |
| $loader->add_action( 'enqueue_block_assets', $this, 'register_editor_style' ); | |
| $loader->add_filter( 'render_block_' . $this->block_name . '-element', $this, 'render_block__dialog_element', 10, 2 ); | |
| } | |
| } | |
| /** | |
| * Register assets | |
| * | |
| * @hook init | |
| * @return void | |
| */ | |
| public function register_assets() { | |
| $this->style_handle = register_block_style_handle( $this->block_json, 'style' ); | |
| $this->editor_script_handle = register_block_script_handle( $this->block_json, 'editorScript' ); | |
| $this->view_script_module_handle = register_block_script_module_id( $this->block_json, 'viewScriptModule' ); | |
| } | |
| /** | |
| * Register editor assets | |
| * | |
| * @hook enqueue_block_editor_assets | |
| * @return void | |
| */ | |
| public function register_editor_assets() { | |
| wp_enqueue_script( $this->editor_script_handle ); | |
| } | |
| /** | |
| * Register editor style | |
| * | |
| * @hook enqueue_block_assets | |
| */ | |
| public function register_editor_style() { | |
| wp_enqueue_style( $this->style_handle ); | |
| } | |
| /** | |
| * Render callback for prc-block/dialog-element | |
| * | |
| * @param string $block_content The block content. | |
| * @param array $block The block data. | |
| * | |
| * @hook render_block_prc-block/dialog-element | |
| */ | |
| public function render_block__dialog_element( $block_content, $block ) { | |
| wp_enqueue_script_module( $this->view_script_module_handle ); | |
| $tag = new WP_HTML_Tag_Processor( $block_content ); | |
| $tag->next_tag( 'dialog' ); | |
| $tag->set_attribute( 'data-wp-init--videoSupport', 'callbacks.onVideoInit' ); | |
| $tag->set_attribute( 'data-wp-watch--on-open-start-video', 'callbacks.onOpenStartVideo' ); | |
| $tag->set_attribute( 'data-wp-watch--on-close-stop-video', 'callbacks.onCloseStopVideo' ); | |
| return $tag->get_updated_html(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * WordPress Dependencies | |
| */ | |
| import { store, getElement } from '@wordpress/interactivity'; | |
| const { VideoPressIframeApi } = window; | |
| /** | |
| * Load the YouTube iframe API script dynamically. | |
| * | |
| * @returns {Promise} A promise that resolves when the script has loaded. | |
| */ | |
| const loadYouTubeIframeAPI = () => { | |
| return new Promise((resolve, reject) => { | |
| // Check if the API is already loaded | |
| if (window.YT && window.YT.Player) { | |
| resolve(window.YT); | |
| return; | |
| } | |
| // Create a global callback for when the API loads | |
| window.onYouTubeIframeAPIReady = () => { | |
| resolve(window.YT); | |
| }; | |
| const script = document.createElement('script'); | |
| script.src = 'https://www.youtube.com/iframe_api'; | |
| script.onload = () => { | |
| // The onYouTubeIframeAPIReady callback will be called by the API itself | |
| }; | |
| script.onerror = (error) => { | |
| reject(error); | |
| }; | |
| document.head.appendChild(script); | |
| }); | |
| }; | |
| /** | |
| * Add VideoPress and YouTube support to prc-block/dialog. | |
| * VideoPress API Docs: https://github.com/Automattic/videopress-player-api-doc/blob/trunk/public-js-api.md | |
| * YouTube IFrame API: https://developers.google.com/youtube/iframe_api_reference | |
| */ | |
| const { state, actions } = store('prc-block/dialog', { | |
| state: { | |
| watchedVideos: [], | |
| videoPressAPIReady: false, | |
| youTubeAPIReady: false, | |
| }, | |
| actions: { | |
| /** | |
| * Plays the video in the dialog if a VideoPress or YouTube block is present. | |
| * @param id The dialog block id. | |
| */ | |
| play: (id) => { | |
| // These are direct id state calls instead of derived state because when calling play directly we want to be explicit about which video dialog we're targeting. | |
| // Sanity Checks: | |
| if (!id) { | |
| return; | |
| } | |
| // Play VideoPress video if available | |
| if (state.dialogs[id].videoPressAPI && state.videoPressAPIReady) { | |
| state.dialogs[id].videoPressAPI.controls.play(); | |
| } | |
| // Play YouTube video if available | |
| if (state.dialogs[id].youTubePlayer && state.youTubeAPIReady) { | |
| state.dialogs[id].youTubePlayer.playVideo(); | |
| } | |
| }, | |
| /** | |
| * Pauses the video in the dialog if a VideoPress or YouTube block is present. | |
| * @param id The dialog block id. | |
| */ | |
| pause: (id) => { | |
| // Sanity Checks: | |
| if (!id) { | |
| return; | |
| } | |
| // Pause VideoPress video if available | |
| if (state.dialogs[id].videoPressAPI && state.videoPressAPIReady) { | |
| state.dialogs[id].videoPressAPI.controls.pause(); | |
| } | |
| // Pause YouTube video if available | |
| if (state.dialogs[id].youTubePlayer && state.youTubeAPIReady) { | |
| state.dialogs[id].youTubePlayer.pauseVideo(); | |
| } | |
| }, | |
| /** | |
| * Resets the video in the dialog to the beginning if a VideoPress or YouTube block is present. | |
| * @param id The dialog block id. | |
| */ | |
| reset: (id) => { | |
| // Sanity Checks: | |
| if (!id) { | |
| return; | |
| } | |
| // Reset VideoPress video if available | |
| if (state.dialogs[id].videoPressAPI && state.videoPressAPIReady) { | |
| state.dialogs[id].videoPressAPI.controls.seek(0); | |
| } | |
| // Reset YouTube video if available | |
| if (state.dialogs[id].youTubePlayer && state.youTubeAPIReady) { | |
| state.dialogs[id].youTubePlayer.seekTo(0); | |
| } | |
| }, | |
| /** | |
| * When the element initializes, hoist the VideoPress API if a VideoPress block is present in the dialog content into global state. | |
| */ | |
| initVideoPressAPI: async () => { | |
| // Because the VideoPress block is on the page the VideoPressIframeApi should be available, | |
| // access it from window global, we import this (if available) at the top of the file. | |
| // If(when) a VideoPress JS module becomes available, we'll use that. | |
| if (!VideoPressIframeApi) { | |
| return; | |
| } | |
| const { id } = state; | |
| if (!id) { | |
| return; | |
| } | |
| const { ref } = getElement(); | |
| const hasVideoPress = ref.querySelector( | |
| '.jetpack-videopress-player' | |
| ); | |
| if (!hasVideoPress) { | |
| return; | |
| } | |
| const iframe = hasVideoPress.querySelector('iframe'); | |
| if (!iframe) { | |
| return; | |
| } | |
| let duration = null; | |
| /** | |
| * Set up the VideoPress API object. | |
| * Turn off shareButton's by default. | |
| * Set up a listener to get the initial duration once loaded. | |
| * Hoist the VideoPress API instance into state for later use. | |
| */ | |
| const VideoPressInstance = VideoPressIframeApi(iframe, () => { | |
| // Disable the share button by default. | |
| VideoPressInstance.customize.set({ shareButton: false }); | |
| // Set up a listener to get the initial duration once loaded. | |
| VideoPressInstance.info.onInfoUpdated( async () => { | |
| // Get the duration and store it in state for later use. | |
| duration = await VideoPressInstance.info.duration(); | |
| }); | |
| }); | |
| // Hoist the VideoPress API instance into state for later use. | |
| state.dialogs[id].videoPressAPI = VideoPressInstance; | |
| // Mark that the VideoPress API is ready. | |
| state.videoPressAPIReady = true; | |
| /** | |
| * Set up an event listener to track playback duration time updates. | |
| * | |
| * Note: This event fires very frequently, so be cautious about what you do in this callback. | |
| * | |
| * Note: This is the total user playback duration, not the position in the video. | |
| * For example, if a user watches the first 10 seconds of a 60 second video, | |
| * then rewinds and watches the first 10 seconds again, this value will be 20 seconds. | |
| */ | |
| VideoPressInstance.status.onPlaybackTimeUpdated( | |
| ( newPlaybackTime ) => { | |
| const percentage = (( newPlaybackTime / duration ) * 100 ).toFixed(2); | |
| // If the user has watched more cumulatively more than 70 percent of the video duration, log that they've watched it. | |
| if ( percentage >= 70 && ! state.watchedVideos.includes( id ) ) { | |
| state.watchedVideos = [ ...state.watchedVideos, id ]; | |
| } | |
| } | |
| ); | |
| }, | |
| /** | |
| * When the element initializes, hoist the YouTube API if a YouTube iframe is present in the dialog content into global state. | |
| */ | |
| initYouTubeAPI: async () => { | |
| const { id } = state; | |
| if (!id) { | |
| return; | |
| } | |
| const { ref } = getElement(); | |
| // Check for YouTube iframes | |
| // YouTube embeds typically have src containing "youtube.com/embed/" or "youtu.be/" | |
| const youtubeIframe = ref.querySelector('iframe[src*="youtube.com/embed/"], iframe[src*="youtu.be/"]'); | |
| if (!youtubeIframe) { | |
| return; | |
| } | |
| // Load the YouTube API if needed | |
| try { | |
| const YT = await loadYouTubeIframeAPI(); | |
| // Extract the video ID from the iframe src | |
| const src = youtubeIframe.src; | |
| const srcUrl = new URL(src); | |
| // Add API support to the iframe if not already present | |
| if (!srcUrl.search.includes('enablejsapi=1')) { | |
| srcUrl.searchParams.set('enablejsapi', '1'); | |
| youtubeIframe.src = srcUrl.toString(); | |
| } | |
| // Initialize the YouTube player with the iframe | |
| const youTubePlayer = new YT.Player(youtubeIframe, { | |
| events: { | |
| 'onReady': (event) => { | |
| state.youTubeAPIReady = true; | |
| }, | |
| 'onStateChange': (event) => { | |
| const eventId = event.data; | |
| // Track video completion (state 0 = ended) | |
| if (eventId === YT.PlayerState.ENDED) { | |
| if (!state.watchedVideos.includes(id)) { | |
| state.watchedVideos = [...state.watchedVideos, id]; | |
| } | |
| } | |
| // Track video watching progress | |
| if (eventId === YT.PlayerState.PLAYING) { | |
| // Set up an interval to check progress | |
| if (state.dialogs[id].youTubeProgressInterval) { | |
| clearInterval(state.dialogs[id].youTubeProgressInterval); | |
| } | |
| state.dialogs[id].youTubeProgressInterval = setInterval(() => { | |
| const player = state.dialogs[id].youTubePlayer; | |
| if (player && typeof player.getCurrentTime === 'function') { | |
| const currentTime = player.getCurrentTime(); | |
| const duration = player.getDuration(); | |
| const percentage = ((currentTime / duration) * 100).toFixed(2); | |
| // If the user has watched more than 70 percent of the video duration, log that they've watched it | |
| if (percentage >= 70 && !state.watchedVideos.includes(id)) { | |
| console.log("Marking YouTube video as watched:", id, percentage, state.dialogs[id]); | |
| state.watchedVideos = [...state.watchedVideos, id]; | |
| // Clear the interval once we've marked as watched | |
| clearInterval(state.dialogs[id].youTubeProgressInterval); | |
| state.dialogs[id].youTubeProgressInterval = null; | |
| } | |
| } | |
| }, 2000); // Check every 2 seconds | |
| } | |
| // Clear interval when paused or ended | |
| if (eventId === YT.PlayerState.PAUSED || eventId === YT.PlayerState.ENDED) { | |
| if (state.dialogs[id].youTubeProgressInterval) { | |
| clearInterval(state.dialogs[id].youTubeProgressInterval); | |
| state.dialogs[id].youTubeProgressInterval = null; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Store the YouTube player instance in state | |
| state.dialogs[id].youTubePlayer = youTubePlayer; | |
| } catch (error) { | |
| console.error('Error loading YouTube API:', error); | |
| } | |
| }, | |
| }, | |
| callbacks: { | |
| onOpenStartVideo: () => { | |
| const { id, isOpen } = state; | |
| // Sanity Checks: | |
| if (!id) { | |
| return; | |
| } | |
| if ( isOpen ) { | |
| actions.play(id); | |
| } | |
| }, | |
| onCloseStopVideo: async () => { | |
| const { id, isOpen } = state; | |
| if (!id) { | |
| return; | |
| } | |
| // Check if there's a video player (VideoPress or YouTube) | |
| const hasVideoPlayer = undefined !== state.dialogs[id].videoPressAPI || undefined !== state.dialogs[id].youTubePlayer; | |
| if ( ! isOpen && hasVideoPlayer ) { | |
| // When the dialog closes, check if the video has been marked watch, if so reset it, otherwise pause it. | |
| actions.pause(id); | |
| if ( state.watchedVideos.includes( id ) ) { | |
| // If we've already logged this video as watched, reset to the beginning. | |
| actions.reset(id); | |
| return; | |
| } | |
| } | |
| }, | |
| /** | |
| * Initialize both VideoPress and YouTube players when the dialog content is loaded. | |
| */ | |
| onVideoInit: async () => { | |
| // Initialize VideoPress | |
| actions.initVideoPressAPI(); | |
| // Initialize YouTube | |
| actions.initYouTubeAPI(); | |
| } | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment