| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\Editors\BlockEditor\Blocks; |
| 4 |
|
| 5 |
use WPDeveloper\BetterDocs\Editors\BlockEditor\Block; |
| 6 |
use WPDeveloper\BetterDocs\Shortcodes\AIActions; |
| 7 |
use WPDeveloper\BetterDocs\Shortcodes\Listen; |
| 8 |
|
| 9 |
/** |
| 10 |
* betterdocs/reading-time — the single-doc meta row. |
| 11 |
* |
| 12 |
* Three independent parts: estimated reading time and the Listen pill on the |
| 13 |
* leading edge, the AI Actions "Copy page" button on the trailing edge, each with |
| 14 |
* its own switch. The block name stays `reading-time` because it is what every |
| 15 |
* saved post, pattern and FSE template already refers to; only the title |
| 16 |
* changed. |
| 17 |
* |
| 18 |
* Every control defaults to the matching Settings-panel value, so an untouched |
| 19 |
* block renders exactly what the site-wide configuration says and the controls |
| 20 |
* exist to override one instance. See the JS counterpart in |
| 21 |
* `react-src/gutenberg/blocks/reading-time/src/settingDefaults.js` — the two |
| 22 |
* halves have to agree or the editor preview and the frontend diverge. |
| 23 |
*/ |
| 24 |
class ReadingTime extends Block { |
| 25 |
|
| 26 |
// Appended, not replaced: Block::$editor_styles defaults to the BetterDocs |
| 27 |
// editor stylesheet, and assigning over it would take that away from this |
| 28 |
// block alone. |
| 29 |
protected $editor_styles = [ |
| 30 |
'betterdocs-blocks-editor', |
| 31 |
'reading-time', |
| 32 |
'betterdocs-ai-actions', |
| 33 |
'betterdocs-listen' |
| 34 |
]; |
| 35 |
|
| 36 |
protected $frontend_styles = [ |
| 37 |
'reading-time', |
| 38 |
'betterdocs-ai-actions', |
| 39 |
'betterdocs-listen' |
| 40 |
]; |
| 41 |
|
| 42 |
// Two of the three parts are interactive: clipboard copy and the disclosure menu |
| 43 |
// for AI Actions, the whole player for Listen. |
| 44 |
protected $frontend_scripts = [ |
| 45 |
'betterdocs' |
| 46 |
]; |
| 47 |
|
| 48 |
/** |
| 49 |
* Action id => [ block attribute, Settings key ]. |
| 50 |
* |
| 51 |
* Ids and Settings keys come from Core\AIActions::registry(); the attribute |
| 52 |
* names come from `src/inspector.js`, which lists them in the same order. |
| 53 |
* Spelled out rather than derived because the three of these that a rule would |
| 54 |
* get right are outnumbered by the four it would not. |
| 55 |
* |
| 56 |
* @var array<string, string[]> |
| 57 |
*/ |
| 58 |
const AI_ACTIONS = [ |
| 59 |
'copy-page' => [ 'aiCopyPage', 'ai_actions_copy_page' ], |
| 60 |
'view-markdown' => [ 'aiViewMarkdown', 'ai_actions_view_markdown' ], |
| 61 |
'open-chatgpt' => [ 'aiChatGPT', 'ai_actions_chatgpt' ], |
| 62 |
'open-claude' => [ 'aiClaude', 'ai_actions_claude' ], |
| 63 |
'open-aistudio' => [ 'aiGemini', 'ai_actions_gemini' ], |
| 64 |
'open-perplexity' => [ 'aiPerplexity', 'ai_actions_perplexity' ], |
| 65 |
'open-grok' => [ 'aiGrok', 'ai_actions_grok' ] |
| 66 |
]; |
| 67 |
|
| 68 |
public function get_name() { |
| 69 |
return 'reading-time'; |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Server-side fallbacks for attributes an older saved block does not carry, |
| 74 |
* and for any control Gutenberg omitted because it still equals its default. |
| 75 |
* |
| 76 |
* These read the Settings panel for the same reason the JS defaults do: a |
| 77 |
* block placed before this feature existed has none of the new attributes, and |
| 78 |
* should behave as though its controls were never touched. |
| 79 |
* |
| 80 |
* @return array |
| 81 |
*/ |
| 82 |
public function get_default_attributes() { |
| 83 |
$settings = betterdocs()->settings; |
| 84 |
|
| 85 |
$defaults = [ |
| 86 |
'blockId' => '', |
| 87 |
'enableReadingTime' => (bool) $settings->get( 'enable_estimated_reading_time' ), |
| 88 |
'readingTimeTitle' => (string) $settings->get( 'estimated_reading_time_title' ), |
| 89 |
'readingTimeText' => (string) $settings->get( 'estimated_reading_time_text' ), |
| 90 |
'singularReadingTimeText' => (string) $settings->get( 'singular_estimated_reading_time_text' ), |
| 91 |
'enableAIActions' => (bool) $settings->get( 'enable_ai_actions' ), |
| 92 |
'aiButtonLabel' => (string) $settings->get( 'ai_actions_button_label' ), |
| 93 |
'aiPromptTemplate' => (string) $settings->get( 'ai_actions_prompt_template' ), |
| 94 |
'enableListen' => (bool) $settings->get( 'enable_listen' ), |
| 95 |
'listenButtonLabel' => (string) $settings->get( 'listen_button_label' ), |
| 96 |
'listenShowSpeed' => (bool) $settings->get( 'listen_show_speed' ) |
| 97 |
]; |
| 98 |
|
| 99 |
foreach ( self::AI_ACTIONS as list( $attribute, $setting_key ) ) { |
| 100 |
$defaults[ $attribute ] = (bool) $settings->get( $setting_key ); |
| 101 |
} |
| 102 |
|
| 103 |
return $defaults; |
| 104 |
} |
| 105 |
|
| 106 |
public function render( $attributes, $content ) { |
| 107 |
$reading_time = $this->reading_time_markup( $attributes ); |
| 108 |
$listen = $this->listen_markup( $attributes ); |
| 109 |
$ai_actions = $this->ai_actions_markup( $attributes ); |
| 110 |
|
| 111 |
// Nothing switched on, or a doc where nothing resolves: emit no wrapper |
| 112 |
// rather than an empty flex row taking up vertical space. Matches what |
| 113 |
// views/templates/parts/doc-meta.php does for the classic layouts. |
| 114 |
if ( '' === $reading_time && '' === $listen && '' === $ai_actions ) { |
| 115 |
return; |
| 116 |
} |
| 117 |
|
| 118 |
// The same wrapper views/templates/parts/doc-meta.php emits, so the block |
| 119 |
// inherits the classic row's layout — including the child margin reset that |
| 120 |
// keeps the button on the reading-time pill's centre line — without a line |
| 121 |
// of CSS of its own. |
| 122 |
printf( |
| 123 |
'<div class="%s"><div class="betterdocs-doc-meta">', |
| 124 |
esc_attr( isset( $attributes['blockId'] ) ? $attributes['blockId'] : '' ) |
| 125 |
); |
| 126 |
|
| 127 |
// One leading box holding both, rather than one each: the row's `> *` rule |
| 128 |
// zeroes child block margins, and the 8px gap between the pills is the |
| 129 |
// leading box's own, so two boxes would space them by the row's wider 16px. |
| 130 |
$leading = $reading_time . $listen; |
| 131 |
|
| 132 |
if ( '' !== $leading ) { |
| 133 |
echo '<div class="betterdocs-doc-meta-start">' . $leading . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered template parts, escaped at source. |
| 134 |
} |
| 135 |
|
| 136 |
if ( '' !== $ai_actions ) { |
| 137 |
echo '<div class="betterdocs-doc-meta-end">' . $ai_actions . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered template parts, escaped at source. |
| 138 |
} |
| 139 |
|
| 140 |
echo '</div></div>'; |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* @param array $attributes |
| 145 |
* @return string |
| 146 |
*/ |
| 147 |
protected function reading_time_markup( $attributes ) { |
| 148 |
if ( empty( $attributes['enableReadingTime'] ) ) { |
| 149 |
return ''; |
| 150 |
} |
| 151 |
|
| 152 |
// esc_attr, not esc_html: these land inside double-quoted shortcode |
| 153 |
// attributes, where an unescaped quote would break out of the attribute. |
| 154 |
return (string) do_shortcode( |
| 155 |
'[betterdocs_reading_time' |
| 156 |
. ' singular_reading_text="' . esc_attr( $attributes['singularReadingTimeText'] ) . '"' |
| 157 |
. ' reading_text="' . esc_attr( $attributes['readingTimeText'] ) . '"' |
| 158 |
. ' reading_title="' . esc_attr( $attributes['readingTimeTitle'] ) . '"]' |
| 159 |
); |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* @param array $attributes |
| 164 |
* @return string |
| 165 |
*/ |
| 166 |
protected function listen_markup( $attributes ) { |
| 167 |
if ( empty( $attributes['enableListen'] ) || ! isset( betterdocs()->listen ) ) { |
| 168 |
return ''; |
| 169 |
} |
| 170 |
|
| 171 |
// Handed off by token rather than as attributes, for the reason |
| 172 |
// Shortcodes\Listen::push_context() gives: `show_speed` is tri-state, and a |
| 173 |
// shortcode attribute string cannot express "inherit the setting". |
| 174 |
$context = Listen::push_context( $this->listen_args( $attributes ) ); |
| 175 |
|
| 176 |
return trim( |
| 177 |
(string) do_shortcode( '[betterdocs_listen context="' . esc_attr( $context ) . '"]' ) |
| 178 |
); |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Per-instance overrides for Core\Listen::render(). |
| 183 |
* |
| 184 |
* A blank label means "inherit", so it is left out entirely rather than passed |
| 185 |
* as an empty string. `show_speed` is passed whenever the attribute exists at |
| 186 |
* all, because false is a legitimate value there and omitting it would fall |
| 187 |
* back to the site-wide setting instead of switching the control off. |
| 188 |
* |
| 189 |
* @param array $attributes |
| 190 |
* @return array |
| 191 |
*/ |
| 192 |
protected function listen_args( $attributes ) { |
| 193 |
$args = [ |
| 194 |
'widget_type' => 'blocks', |
| 195 |
'blockId' => isset( $attributes['blockId'] ) ? $attributes['blockId'] : '' |
| 196 |
]; |
| 197 |
|
| 198 |
if ( ! empty( $attributes['listenButtonLabel'] ) ) { |
| 199 |
$args['button_label'] = (string) $attributes['listenButtonLabel']; |
| 200 |
} |
| 201 |
|
| 202 |
if ( isset( $attributes['listenShowSpeed'] ) ) { |
| 203 |
$args['show_speed'] = (bool) $attributes['listenShowSpeed']; |
| 204 |
} |
| 205 |
|
| 206 |
return $args; |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* @param array $attributes |
| 211 |
* @return string |
| 212 |
*/ |
| 213 |
protected function ai_actions_markup( $attributes ) { |
| 214 |
if ( empty( $attributes['enableAIActions'] ) || ! isset( betterdocs()->ai_actions ) ) { |
| 215 |
return ''; |
| 216 |
} |
| 217 |
|
| 218 |
// Handed off by token rather than as attributes: the args carry a tri-state |
| 219 |
// `enabled_actions` map and free text that a shortcode attribute string |
| 220 |
// cannot round-trip. See AIActions::push_context(). |
| 221 |
$context = AIActions::push_context( $this->ai_actions_args( $attributes ) ); |
| 222 |
|
| 223 |
return trim( |
| 224 |
(string) do_shortcode( '[betterdocs_ai_actions context="' . esc_attr( $context ) . '"]' ) |
| 225 |
); |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Per-instance overrides for Core\AIActions::render(). |
| 230 |
* |
| 231 |
* A blank text control means "inherit", so it is left out entirely rather than |
| 232 |
* passed as an empty string — render() treats a blank override as absent, but |
| 233 |
* omitting it keeps the intent legible at the call site. |
| 234 |
* |
| 235 |
* @param array $attributes |
| 236 |
* @return array |
| 237 |
*/ |
| 238 |
protected function ai_actions_args( $attributes ) { |
| 239 |
$enabled = []; |
| 240 |
foreach ( self::AI_ACTIONS as $id => list( $attribute ) ) { |
| 241 |
if ( isset( $attributes[ $attribute ] ) ) { |
| 242 |
$enabled[ $id ] = (bool) $attributes[ $attribute ]; |
| 243 |
} |
| 244 |
} |
| 245 |
|
| 246 |
$args = [ |
| 247 |
'widget_type' => 'blocks', |
| 248 |
'blockId' => isset( $attributes['blockId'] ) ? $attributes['blockId'] : '', |
| 249 |
'enabled_actions' => $enabled |
| 250 |
]; |
| 251 |
|
| 252 |
if ( ! empty( $attributes['aiButtonLabel'] ) ) { |
| 253 |
$args['button_label'] = (string) $attributes['aiButtonLabel']; |
| 254 |
} |
| 255 |
|
| 256 |
if ( ! empty( $attributes['aiPromptTemplate'] ) ) { |
| 257 |
$args['prompt_template'] = (string) $attributes['aiPromptTemplate']; |
| 258 |
} |
| 259 |
|
| 260 |
return $args; |
| 261 |
} |
| 262 |
} |
| 263 |
|