PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
betterdocs / includes / Editors / Elementor / Widget / ReadingTime.php

ReadingTime.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/Editors/Elementor/Widget/ReadingTime.php

877 lines 26.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Editors\Elementor\Widget;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly
7 }
8
9 use Elementor\Controls_Manager;
10 use Elementor\Group_Control_Typography;
11 use WPDeveloper\BetterDocs\Editors\Elementor\BaseWidget;
12 use WPDeveloper\BetterDocs\Core\AIActions as CoreAIActions;
13 use Elementor\Group_Control_Border;
14
15 /**
16 * The single-doc meta row: estimated reading time, Listen and the AI Actions
17 * button.
18 *
19 * Three independent parts, each with its own switch, in one widget. `get_name()`
20 * stays `betterdocs-reading-time` because Elementor persists the widget type in
21 * `_elementor_data` and drops any element whose type it cannot resolve — renaming
22 * it would silently delete the widget, and its settings, from every template that
23 * already uses it. Only the title changed.
24 *
25 * Every control defaults to the matching Settings-panel value, so a widget nobody
26 * has touched renders what the site-wide configuration says.
27 */
28 class ReadingTime extends BaseWidget {
29
30 /**
31 * Action id => the control that overrides its Settings key.
32 *
33 * Ids come from Core\AIActions::registry(). Control names deliberately match
34 * the Settings keys, so the panel and the widget read the same at a glance.
35 *
36 * @var array<string, string>
37 */
38 const AI_ACTION_CONTROLS = [
39 'copy-page' => 'ai_actions_copy_page',
40 'view-markdown' => 'ai_actions_view_markdown',
41 'open-chatgpt' => 'ai_actions_chatgpt',
42 'open-claude' => 'ai_actions_claude',
43 'open-aistudio' => 'ai_actions_gemini',
44 'open-perplexity' => 'ai_actions_perplexity',
45 'open-grok' => 'ai_actions_grok'
46 ];
47
48 public function get_name() {
49 return 'betterdocs-reading-time';
50 }
51
52 public function get_style_depends() {
53 // `betterdocs-ai-actions` and `betterdocs-listen` each carry the meta row
54 // itself, not just their own control, so both are needed whichever parts are
55 // switched off. They are separate handles precisely so either can lay the row
56 // out alone — see scss/template-parts/_doc-meta.scss.
57 return [ 'reading-time', 'betterdocs-ai-actions', 'betterdocs-listen' ];
58 }
59
60 public function get_script_depends() {
61 return [ 'betterdocs' ];
62 }
63
64 public function get_title() {
65 return __( 'Reading Time, Listen and AI Action', 'betterdocs' );
66 }
67
68 public function get_icon() {
69 return 'betterdocs-icon-Reading-Time';
70 }
71
72 public function get_categories() {
73 return [ 'betterdocs-elements', 'betterdocs-elements-single' ];
74 }
75
76 public function get_keywords() {
77 return [ 'betterdocs-elements', 'betterdocs', 'docs', 'single-doc', 'reading time', 'ai', 'markdown', 'copy', 'listen', 'audio', 'text to speech', 'accessibility' ];
78 }
79
80 public function get_custom_help_url() {
81 return 'https://betterdocs.co/docs/docs-archive-in-elementor/';
82 }
83
84 /**
85 * A boolean setting as a SWITCHER default.
86 *
87 * @param string $key
88 * @return string `yes` or ''
89 */
90 protected function switcher_default( $key ) {
91 return betterdocs()->settings->get( $key ) ? 'yes' : '';
92 }
93
94 protected function register_controls() {
95 $settings = betterdocs()->settings;
96
97 $this->start_controls_section(
98 'section_content',
99 [
100 'label' => __( 'Reading Time', 'betterdocs' ),
101 'tab' => Controls_Manager::TAB_CONTENT
102 ]
103 );
104
105 $this->add_control(
106 'enable_reading_time',
107 [
108 'label' => __( 'Enable Reading Time', 'betterdocs' ),
109 'type' => Controls_Manager::SWITCHER,
110 'label_on' => __( 'Yes', 'betterdocs' ),
111 'label_off' => __( 'No', 'betterdocs' ),
112 'return_value' => 'yes',
113 'default' => $this->switcher_default( 'enable_estimated_reading_time' )
114 ]
115 );
116
117 $this->add_control(
118 'ert_reading_title',
119 [
120 'label' => __( 'Reading Time Title', 'betterdocs' ),
121 'type' => Controls_Manager::TEXT,
122 'default' => (string) $settings->get( 'estimated_reading_time_title' ),
123 'placeholder' => __( 'Type Here', 'betterdocs' ),
124 'condition' => [ 'enable_reading_time' => 'yes' ]
125 ]
126 );
127
128 $this->add_control(
129 'ert_reading_text',
130 [
131 'label' => __( 'Reading Time Text', 'betterdocs' ),
132 'type' => Controls_Manager::TEXT,
133 'default' => (string) $settings->get( 'estimated_reading_time_text' ),
134 'placeholder' => __( 'Type Here', 'betterdocs' ),
135 'condition' => [ 'enable_reading_time' => 'yes' ]
136 ]
137 );
138
139 $this->add_control(
140 'singular_ert_reading_text',
141 [
142 'label' => __( 'Singular Reading Time Text', 'betterdocs' ),
143 'type' => Controls_Manager::TEXT,
144 'default' => (string) $settings->get( 'singular_estimated_reading_time_text' ),
145 'placeholder' => __( 'Type Here', 'betterdocs' ),
146 'condition' => [ 'enable_reading_time' => 'yes' ]
147 ]
148 );
149
150 $this->end_controls_section();
151
152 $this->start_controls_section(
153 'section_listen',
154 [
155 'label' => __( 'Listen', 'betterdocs' ),
156 'tab' => Controls_Manager::TAB_CONTENT
157 ]
158 );
159
160 $this->add_control(
161 'enable_listen',
162 [
163 'label' => __( 'Enable Listen', 'betterdocs' ),
164 'description' => __( 'Adds a button that turns into a small player and reads the doc aloud using the visitor\'s own browser — no audio files and no third-party service.', 'betterdocs' ),
165 'type' => Controls_Manager::SWITCHER,
166 'label_on' => __( 'Yes', 'betterdocs' ),
167 'label_off' => __( 'No', 'betterdocs' ),
168 'return_value' => 'yes',
169 'default' => $this->switcher_default( 'enable_listen' )
170 ]
171 );
172
173 $this->add_control(
174 'listen_button_label',
175 [
176 'label' => __( 'Button Label', 'betterdocs' ),
177 'type' => Controls_Manager::TEXT,
178 'default' => (string) $settings->get( 'listen_button_label' ),
179 'placeholder' => __( 'Listen', 'betterdocs' ),
180 'condition' => [ 'enable_listen' => 'yes' ]
181 ]
182 );
183
184 $this->add_control(
185 'listen_show_speed',
186 [
187 'label' => __( 'Speed Control', 'betterdocs' ),
188 'description' => __( 'Let readers cycle the playback speed between 0.75× and 2×.', 'betterdocs' ),
189 'type' => Controls_Manager::SWITCHER,
190 'label_on' => __( 'Yes', 'betterdocs' ),
191 'label_off' => __( 'No', 'betterdocs' ),
192 'return_value' => 'yes',
193 'default' => $this->switcher_default( 'listen_show_speed' ),
194 'condition' => [ 'enable_listen' => 'yes' ]
195 ]
196 );
197
198 $this->add_control(
199 'listen_words_per_minute',
200 [
201 'label' => __( 'Words Per Minute', 'betterdocs' ),
202 'description' => __( 'Used to estimate the total playing time the player shows. Browser voices read at roughly 180 words a minute at normal speed.', 'betterdocs' ),
203 'type' => Controls_Manager::NUMBER,
204 'min' => 60,
205 'max' => 400,
206 'step' => 10,
207 'default' => (int) $settings->get( 'listen_words_per_minute' ),
208 'condition' => [ 'enable_listen' => 'yes' ]
209 ]
210 );
211
212 $this->end_controls_section();
213
214 $this->start_controls_section(
215 'section_ai_actions',
216 [
217 'label' => __( 'AI Actions', 'betterdocs' ),
218 'tab' => Controls_Manager::TAB_CONTENT
219 ]
220 );
221
222 $this->add_control(
223 'enable_ai_actions',
224 [
225 'label' => __( 'Enable AI Actions', 'betterdocs' ),
226 'description' => __( 'Adds a "Copy page" button that copies the doc as Markdown, or hands it to ChatGPT, Claude or another assistant as context.', 'betterdocs' ),
227 'type' => Controls_Manager::SWITCHER,
228 'label_on' => __( 'Yes', 'betterdocs' ),
229 'label_off' => __( 'No', 'betterdocs' ),
230 'return_value' => 'yes',
231 'default' => $this->switcher_default( 'enable_ai_actions' )
232 ]
233 );
234
235 $this->add_control(
236 'ai_actions_button_label',
237 [
238 'label' => __( 'Button Label', 'betterdocs' ),
239 'type' => Controls_Manager::TEXT,
240 'default' => (string) $settings->get( 'ai_actions_button_label' ),
241 'placeholder' => __( 'Copy page', 'betterdocs' ),
242 'condition' => [ 'enable_ai_actions' => 'yes' ]
243 ]
244 );
245
246 $this->add_control(
247 'ai_actions_prompt_template',
248 [
249 'label' => __( 'AI Prompt Template', 'betterdocs' ),
250 'description' => __( 'Sent to the AI assistant when a reader opens this doc there. Use {URL} for the doc address.', 'betterdocs' ),
251 'type' => Controls_Manager::TEXTAREA,
252 'rows' => 3,
253 'default' => (string) $settings->get( 'ai_actions_prompt_template' ),
254 'condition' => [ 'enable_ai_actions' => 'yes' ]
255 ]
256 );
257
258 $action_labels = [
259 'ai_actions_copy_page' => __( 'Copy Page', 'betterdocs' ),
260 'ai_actions_view_markdown' => __( 'View as Markdown', 'betterdocs' ),
261 'ai_actions_chatgpt' => __( 'Open in ChatGPT', 'betterdocs' ),
262 'ai_actions_claude' => __( 'Open in Claude', 'betterdocs' ),
263 'ai_actions_gemini' => __( 'Open in Google AI Studio', 'betterdocs' ),
264 'ai_actions_perplexity' => __( 'Open in Perplexity', 'betterdocs' ),
265 'ai_actions_grok' => __( 'Open in Grok', 'betterdocs' )
266 ];
267
268 // Registered in registry() priority order, which is the order they appear in
269 // the dropdown, so the panel reads the same way the button does.
270 foreach ( self::AI_ACTION_CONTROLS as $control ) {
271 $this->add_control(
272 $control,
273 [
274 'label' => $action_labels[ $control ],
275 'type' => Controls_Manager::SWITCHER,
276 'label_on' => __( 'Yes', 'betterdocs' ),
277 'label_off' => __( 'No', 'betterdocs' ),
278 'return_value' => 'yes',
279 'default' => $this->switcher_default( $control ),
280 'condition' => [ 'enable_ai_actions' => 'yes' ]
281 ]
282 );
283 }
284
285 $this->end_controls_section();
286
287 $this->start_controls_section(
288 'section_reading_style',
289 [
290 'label' => __( 'Reading Time', 'betterdocs' ),
291 'tab' => Controls_Manager::TAB_STYLE,
292 'condition' => [ 'enable_reading_time' => 'yes' ]
293 ]
294 );
295
296 $this->add_responsive_control(
297 'reading_background_color',
298 [
299 'label' => esc_html__( 'Background Color', 'betterdocs' ),
300 'type' => Controls_Manager::COLOR,
301 'selectors' => [
302 '{{WRAPPER}} .reading-time' => 'background-color: {{VALUE}};'
303 ]
304 ]
305 );
306
307 $this->add_control(
308 'reading_text_color',
309 [
310 'label' => __( 'Text Color', 'betterdocs' ),
311 'type' => Controls_Manager::COLOR,
312 'selectors' => [
313 '{{WRAPPER}} .reading-time p' => 'color: {{VALUE}}'
314 ]
315 ]
316 );
317
318 $this->add_group_control(
319 Group_Control_Typography::get_type(),
320 [
321 'name' => 'reading_text_typo',
322 'selector' => '{{WRAPPER}} .reading-time p'
323 ]
324 );
325
326 $this->add_control(
327 'reading_box_width',
328 [
329 'label' => __( 'Width', 'betterdocs' ),
330 'type' => Controls_Manager::SLIDER,
331 'size_units' => [ 'px', '%' ],
332 'range' => [
333 'px' => [
334 'max' => 500,
335 'step' => 1
336 ],
337 '%' => [
338 'max' => 100,
339 'step' => 1
340 ]
341 ],
342 'selectors' => [
343 '{{WRAPPER}} .reading-time' => 'width: {{SIZE}}px;'
344 ]
345 ]
346 );
347
348 $this->add_group_control(
349 Group_Control_Border::get_type(),
350 [
351 'name' => 'reading_box_border',
352 'label' => esc_html__( 'Border', 'betterdocs' ),
353 'selector' => '{{WRAPPER}} .reading-time'
354 ]
355 );
356
357 $this->add_control(
358 'reading_box_border_radius',
359 [
360 'label' => __( 'Border Radius', 'betterdocs' ),
361 'type' => Controls_Manager::SLIDER,
362 'size_units' => [ 'px', '%' ],
363 'range' => [
364 'px' => [
365 'max' => 500,
366 'step' => 1
367 ],
368 '%' => [
369 'max' => 100,
370 'step' => 1
371 ]
372 ],
373 'selectors' => [
374 '{{WRAPPER}} .reading-time' => 'border-radius: {{SIZE}}px;'
375 ]
376 ]
377 );
378
379 $this->add_responsive_control(
380 'reading_padding',
381 [
382 'label' => __( 'Padding', 'betterdocs' ),
383 'type' => Controls_Manager::DIMENSIONS,
384 'size_units' => [ 'px', 'em', '%' ],
385 'selectors' => [
386 '{{WRAPPER}} .reading-time' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};'
387 ]
388 ]
389 );
390
391 $this->add_responsive_control(
392 'reading_margin',
393 [
394 'label' => __( 'Margin', 'betterdocs' ),
395 // The meta row zeroes its children's block margins with !important —
396 // it has to, or a pill with a bottom margin inflates the flex line and
397 // knocks the button off centre — so this control reaches the row.
398 'description' => __( 'Applied to the meta row, so both halves move together.', 'betterdocs' ),
399 'type' => Controls_Manager::DIMENSIONS,
400 'size_units' => [ 'px', 'em', '%' ],
401 'selectors' => [
402 '{{WRAPPER}} .betterdocs-doc-meta' => 'margin: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};'
403 ]
404 ]
405 );
406
407 $this->add_control(
408 'clock_icon_width',
409 [
410 'label' => __( 'Clock Icon Width', 'betterdocs' ),
411 'type' => Controls_Manager::SLIDER,
412 'size_units' => [ 'px', '%' ],
413 'range' => [
414 'px' => [
415 'max' => 500,
416 'step' => 1
417 ],
418 '%' => [
419 'max' => 100,
420 'step' => 1
421 ]
422 ],
423 'selectors' => [
424 '{{WRAPPER}} .reading-time p svg' => 'width: {{SIZE}}px;'
425 ]
426 ]
427 );
428
429 $this->add_responsive_control(
430 'clock_icon_color',
431 [
432 'label' => esc_html__( 'Clock Icon Color', 'betterdocs' ),
433 'type' => Controls_Manager::COLOR,
434 'selectors' => [
435 '{{WRAPPER}} .reading-time p svg path' => 'fill: {{VALUE}};'
436 ]
437 ]
438 );
439
440 $this->end_controls_section();
441
442 $this->start_controls_section(
443 'section_listen_style',
444 [
445 'label' => __( 'Listen', 'betterdocs' ),
446 'tab' => Controls_Manager::TAB_STYLE,
447 'condition' => [ 'enable_listen' => 'yes' ]
448 ]
449 );
450
451 $this->add_responsive_control(
452 'listen_icon_size',
453 [
454 'label' => __( 'Icon Size', 'betterdocs' ),
455 'type' => Controls_Manager::SLIDER,
456 'size_units' => [ 'px', 'em' ],
457 'range' => [
458 'px' => [
459 'min' => 10,
460 'max' => 40,
461 'step' => 1
462 ]
463 ],
464 // Every default in this section restates what the stylesheet already
465 // draws, so the controls open on the real pill rather than on blanks
466 // — and match the two sections above, which is the point of the trio.
467 'default' => [
468 'unit' => 'px',
469 'size' => 14
470 ],
471 'selectors' => [
472 // The headset glyph on the collapsed pill only. The player's own
473 // glyphs are 9px and 11px by design and stay that way — they have
474 // to fit inside 20px circles.
475 '{{WRAPPER}} .betterdocs-listen-trigger svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};'
476 ]
477 ]
478 );
479
480 $this->add_control(
481 'listen_icon_color',
482 [
483 'label' => esc_html__( 'Icon Color', 'betterdocs' ),
484 'type' => Controls_Manager::COLOR,
485 'default' => '#667085',
486 'selectors' => [
487 // Stroked with `currentColor`, so a `color` on the svg is the
488 // whole job.
489 '{{WRAPPER}} .betterdocs-listen-trigger svg' => 'color: {{VALUE}};'
490 ]
491 ]
492 );
493
494 $this->add_control(
495 'listen_background_color',
496 [
497 'label' => esc_html__( 'Background Color', 'betterdocs' ),
498 'type' => Controls_Manager::COLOR,
499 'default' => '#F9FAFB',
500 'selectors' => [
501 // On the pill itself, so the surface is one colour whichever state
502 // it is in — the player is transparent and sits on top of it.
503 '{{WRAPPER}} .betterdocs-listen' => 'background-color: {{VALUE}};'
504 ]
505 ]
506 );
507
508 $this->add_control(
509 'listen_text_color',
510 [
511 'label' => esc_html__( 'Text Color', 'betterdocs' ),
512 'type' => Controls_Manager::COLOR,
513 'default' => '#667085',
514 'selectors' => [
515 '{{WRAPPER}} .betterdocs-listen-label' => 'color: {{VALUE}};'
516 ]
517 ]
518 );
519
520 $this->add_control(
521 'listen_accent_color',
522 [
523 'label' => esc_html__( 'Player Accent Color', 'betterdocs' ),
524 'description' => esc_html__( 'Used for the play button, the progress bar and its handle.', 'betterdocs' ),
525 'type' => Controls_Manager::COLOR,
526 'default' => '#1D2939',
527 'selectors' => [
528 // One custom property for all three, which is how listen.scss
529 // publishes them. Three separate controls would be three chances
530 // for a play button that does not match its own progress bar.
531 '{{WRAPPER}} .betterdocs-listen-player' => '--bd-listen-accent: {{VALUE}};'
532 ]
533 ]
534 );
535
536 $this->add_group_control(
537 Group_Control_Typography::get_type(),
538 [
539 'name' => 'listen_typo',
540 // On the pill, not the label: the player's readout is sized in `em`,
541 // so this scales the two together. A font-size on the label alone
542 // would leave the clock behind at 12.6px.
543 'selector' => '{{WRAPPER}} .betterdocs-listen',
544 'fields_options' => [
545 'typography' => [ 'default' => 'custom' ],
546 'font_size' => [
547 'default' => [
548 'unit' => 'px',
549 'size' => 14
550 ]
551 ],
552 'font_weight' => [ 'default' => '400' ]
553 ]
554 ]
555 );
556
557 $this->add_responsive_control(
558 'listen_padding',
559 [
560 'label' => __( 'Padding', 'betterdocs' ),
561 'type' => Controls_Manager::DIMENSIONS,
562 'size_units' => [ 'px', 'em', '%' ],
563 // Strings, not ints: Elementor's dimensions control compares the
564 // incoming value against the field's string state, and an int default
565 // lands in the panel as an empty box.
566 'default' => [
567 'unit' => 'px',
568 'top' => '5',
569 'right' => '10',
570 'bottom' => '5',
571 'left' => '10',
572 'isLinked' => false
573 ],
574 'selectors' => [
575 // On the trigger, which is the only element in flow and therefore
576 // the one that gives the pill its size. The player is absolutely
577 // positioned with `inset-block: 0` and inherits that height, which
578 // is what keeps the promise that nothing below the pill moves when
579 // it opens.
580 '{{WRAPPER}} .betterdocs-listen-trigger' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};'
581 ]
582 ]
583 );
584
585 $this->add_group_control(
586 Group_Control_Border::get_type(),
587 [
588 'name' => 'listen_border',
589 'label' => esc_html__( 'Border', 'betterdocs' ),
590 'selector' => '{{WRAPPER}} .betterdocs-listen'
591 ]
592 );
593
594 $this->add_control(
595 'listen_border_radius',
596 [
597 'label' => __( 'Border Radius', 'betterdocs' ),
598 'type' => Controls_Manager::SLIDER,
599 'size_units' => [ 'px', '%' ],
600 'range' => [
601 'px' => [
602 'max' => 100,
603 'step' => 1
604 ]
605 ],
606 'default' => [
607 'unit' => 'px',
608 'size' => 16
609 ],
610 'selectors' => [
611 // Both: the pill clips its contents, so the wrapper's corner is
612 // what shows — but the trigger states its own corner so a theme's
613 // `button { border-radius }` cannot square it off, and a radius
614 // that stopped at the wrapper would let that square edge through.
615 '{{WRAPPER}} .betterdocs-listen' => 'border-radius: {{SIZE}}{{UNIT}};',
616 '{{WRAPPER}} .betterdocs-listen-trigger' => 'border-radius: {{SIZE}}{{UNIT}};'
617 ]
618 ]
619 );
620
621 $this->end_controls_section();
622
623 $this->start_controls_section(
624 'section_ai_actions_style',
625 [
626 'label' => __( 'AI Actions', 'betterdocs' ),
627 'tab' => Controls_Manager::TAB_STYLE,
628 'condition' => [ 'enable_ai_actions' => 'yes' ]
629 ]
630 );
631
632 $this->add_responsive_control(
633 'ai_actions_icon_size',
634 [
635 'label' => __( 'Icon Size', 'betterdocs' ),
636 'type' => Controls_Manager::SLIDER,
637 'size_units' => [ 'px', 'em' ],
638 'range' => [
639 'px' => [
640 'min' => 10,
641 'max' => 40,
642 'step' => 1
643 ]
644 ],
645 // Every default in this section restates what the stylesheet already
646 // draws, so the controls open on the real button rather than on
647 // blanks — and match the reading-time half above, which is the point
648 // of the pair.
649 'default' => [
650 'unit' => 'px',
651 'size' => 14
652 ],
653 'selectors' => [
654 '{{WRAPPER}} .betterdocs-ai-actions-primary svg, {{WRAPPER}} .betterdocs-ai-actions-toggle svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};'
655 ]
656 ]
657 );
658
659 $this->add_control(
660 'ai_actions_icon_color',
661 [
662 'label' => esc_html__( 'Icon Color', 'betterdocs' ),
663 'type' => Controls_Manager::COLOR,
664 'default' => '#667085',
665 'selectors' => [
666 // Every icon AIActions::icon() draws is stroked with
667 // `currentColor`, so this one declaration covers the copy glyph,
668 // the copied tick, the failure mark and the caret.
669 '{{WRAPPER}} .betterdocs-ai-actions-primary svg, {{WRAPPER}} .betterdocs-ai-actions-toggle svg' => 'color: {{VALUE}};'
670 ]
671 ]
672 );
673
674 $this->add_control(
675 'ai_actions_background_color',
676 [
677 'label' => esc_html__( 'Background Color', 'betterdocs' ),
678 'type' => Controls_Manager::COLOR,
679 'default' => '#F9FAFB',
680 'selectors' => [
681 '{{WRAPPER}} .betterdocs-ai-actions-primary, {{WRAPPER}} .betterdocs-ai-actions-toggle' => 'background-color: {{VALUE}};'
682 ]
683 ]
684 );
685
686 $this->add_control(
687 'ai_actions_text_color',
688 [
689 'label' => esc_html__( 'Text Color', 'betterdocs' ),
690 'type' => Controls_Manager::COLOR,
691 'default' => '#667085',
692 'selectors' => [
693 '{{WRAPPER}} .betterdocs-ai-actions-label' => 'color: {{VALUE}};'
694 ]
695 ]
696 );
697
698 $this->add_group_control(
699 Group_Control_Typography::get_type(),
700 [
701 'name' => 'ai_actions_typo',
702 'selector' => '{{WRAPPER}} .betterdocs-ai-actions-label',
703 'fields_options' => [
704 'typography' => [ 'default' => 'custom' ],
705 'font_size' => [
706 'default' => [
707 'unit' => 'px',
708 'size' => 14
709 ]
710 ],
711 'font_weight' => [ 'default' => '400' ]
712 ]
713 ]
714 );
715
716 $this->add_responsive_control(
717 'ai_actions_padding',
718 [
719 'label' => __( 'Padding', 'betterdocs' ),
720 'type' => Controls_Manager::DIMENSIONS,
721 'size_units' => [ 'px', 'em', '%' ],
722 // Strings, not ints: Elementor's dimensions control compares the
723 // incoming value against the field's string state, and an int
724 // default lands in the panel as an empty box.
725 'default' => [
726 'unit' => 'px',
727 'top' => '5',
728 'right' => '10',
729 'bottom' => '5',
730 'left' => '10',
731 'isLinked' => false
732 ],
733 'selectors' => [
734 '{{WRAPPER}} .betterdocs-ai-actions-primary, {{WRAPPER}} .betterdocs-ai-actions-toggle' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};'
735 ]
736 ]
737 );
738
739 $this->add_group_control(
740 Group_Control_Border::get_type(),
741 [
742 'name' => 'ai_actions_border',
743 'label' => esc_html__( 'Border', 'betterdocs' ),
744 'selector' => '{{WRAPPER}} .betterdocs-ai-actions'
745 ]
746 );
747
748 $this->add_control(
749 'ai_actions_border_radius',
750 [
751 'label' => __( 'Border Radius', 'betterdocs' ),
752 'type' => Controls_Manager::SLIDER,
753 'size_units' => [ 'px', '%' ],
754 'range' => [
755 'px' => [
756 'max' => 100,
757 'step' => 1
758 ]
759 ],
760 'default' => [
761 'unit' => 'px',
762 'size' => 16
763 ],
764 'selectors' => [
765 // All three, for the same reason the Customizer writes all three:
766 // the wrapper cannot clip its halves — the dropdown is absolutely
767 // positioned — so each half owns its own outer corners and a
768 // radius set only on the wrapper leaves the pill's ends behind.
769 '{{WRAPPER}} .betterdocs-ai-actions' => 'border-radius: {{SIZE}}{{UNIT}};',
770 '{{WRAPPER}} .betterdocs-ai-actions-primary' => 'border-start-start-radius: {{SIZE}}{{UNIT}}; border-end-start-radius: {{SIZE}}{{UNIT}};',
771 '{{WRAPPER}} .betterdocs-ai-actions-toggle' => 'border-start-end-radius: {{SIZE}}{{UNIT}}; border-end-end-radius: {{SIZE}}{{UNIT}};'
772 ]
773 ]
774 );
775
776 $this->end_controls_section();
777 }
778
779 public function view_params() {
780 $attributes = $this->attributes;
781
782 return [
783 'attributes' => $attributes,
784 'enable_reading_time' => 'yes' === ( isset( $attributes['enable_reading_time'] ) ? $attributes['enable_reading_time'] : '' ),
785 'enable_listen' => 'yes' === ( isset( $attributes['enable_listen'] ) ? $attributes['enable_listen'] : '' ),
786 'listen_args' => $this->listen_args( $attributes ),
787 'enable_ai_actions' => 'yes' === ( isset( $attributes['enable_ai_actions'] ) ? $attributes['enable_ai_actions'] : '' ),
788 'ai_actions_args' => $this->ai_actions_args( $attributes ),
789 'is_editing' => self::is_editing()
790 ];
791 }
792
793 /**
794 * Per-instance overrides for Core\Listen::render().
795 *
796 * A blank label means "inherit", so it is omitted rather than passed as an empty
797 * string. `show_speed` is different: Elementor stores a switcher's off state as
798 * '', which is indistinguishable from "never set" — so it is only forwarded when
799 * the key exists, and an explicit '' then means off rather than inherit. That is
800 * the right reading here, because Elementor writes every registered control into
801 * `_elementor_data` on save.
802 *
803 * @param array $attributes
804 * @return array
805 */
806 protected function listen_args( $attributes ) {
807 $args = [ 'widget_type' => 'elementor' ];
808
809 if ( ! empty( $attributes['listen_button_label'] ) ) {
810 $args['button_label'] = (string) $attributes['listen_button_label'];
811 }
812
813 if ( isset( $attributes['listen_show_speed'] ) ) {
814 $args['show_speed'] = 'yes' === $attributes['listen_show_speed'];
815 }
816
817 if ( ! empty( $attributes['listen_words_per_minute'] ) ) {
818 $args['words_per_minute'] = (int) $attributes['listen_words_per_minute'];
819 }
820
821 return $args;
822 }
823
824 /**
825 * Per-instance overrides for Core\AIActions::render().
826 *
827 * A blank text control means "inherit", so it is omitted rather than passed as
828 * an empty string.
829 *
830 * @param array $attributes
831 * @return array
832 */
833 protected function ai_actions_args( $attributes ) {
834 $enabled = [];
835 foreach ( self::AI_ACTION_CONTROLS as $id => $control ) {
836 if ( isset( $attributes[ $control ] ) ) {
837 $enabled[ $id ] = 'yes' === $attributes[ $control ];
838 }
839 }
840
841 $args = [
842 'widget_type' => 'elementor',
843 'enabled_actions' => $enabled
844 ];
845
846 if ( ! empty( $attributes['ai_actions_button_label'] ) ) {
847 $args['button_label'] = (string) $attributes['ai_actions_button_label'];
848 }
849
850 if ( ! empty( $attributes['ai_actions_prompt_template'] ) ) {
851 $args['prompt_template'] = (string) $attributes['ai_actions_prompt_template'];
852 }
853
854 return $args;
855 }
856
857 /**
858 * Is Elementor drawing its own canvas rather than a real page?
859 *
860 * @return bool
861 */
862 public static function is_editing() {
863 if ( ! class_exists( '\Elementor\Plugin' ) ) {
864 return false;
865 }
866
867 $elementor = \Elementor\Plugin::$instance;
868
869 return ( ! empty( $elementor->editor ) && $elementor->editor->is_edit_mode() )
870 || ( ! empty( $elementor->preview ) && $elementor->preview->is_preview_mode() );
871 }
872
873 protected function render_callback() {
874 $this->views( 'widgets/reading-time' );
875 }
876 }
877