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
← All changes | includes/Editors/Elementor/Widget/ReadingTime.php +652 -21 4.9.0 → 4.9.4 View file →
@@ -8,22 +8,62 @@
8 8
9 9 use Elementor\Controls_Manager;
10 10 use Elementor\Group_Control_Typography;
11 11 use WPDeveloper\BetterDocs\Editors\Elementor\BaseWidget;
12 +use WPDeveloper\BetterDocs\Core\AIActions as CoreAIActions;
12 13 use Elementor\Group_Control_Border;
13 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 + */
14 28 class ReadingTime extends BaseWidget {
15 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 +
16 48 public function get_name() {
17 49 return 'betterdocs-reading-time';
18 50 }
19 51
20 52 public function get_style_depends() {
21 - return [ 'reading-time' ];
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' ];
22 58 }
23 59
60 + public function get_script_depends() {
61 + return [ 'betterdocs' ];
62 + }
63 +
24 64 public function get_title() {
25 - return __( 'Reading Time', 'betterdocs' );
65 + return __( 'Reading Time, Listen and AI Action', 'betterdocs' );
26 66 }
27 67
28 68 public function get_icon() {
29 69 return 'betterdocs-icon-Reading-Time';
@@ -29,13 +69,13 @@
29 69 return 'betterdocs-icon-Reading-Time';
30 70 }
31 71
32 72 public function get_categories() {
33 - return [ 'betterdocs-elements', 'single-doc' ];
73 + return [ 'betterdocs-elements', 'betterdocs-elements-single' ];
34 74 }
35 75
36 76 public function get_keywords() {
37 - return [ 'betterdocs-elements', 'betterdocs', 'docs', 'single-doc' ];
77 + return [ 'betterdocs-elements', 'betterdocs', 'docs', 'single-doc', 'reading time', 'ai', 'markdown', 'copy', 'listen', 'audio', 'text to speech', 'accessibility' ];
38 78 }
39 79
40 80 public function get_custom_help_url() {
41 81 return 'https://betterdocs.co/docs/docs-archive-in-elementor/';
@@ -40,24 +80,49 @@
40 80 public function get_custom_help_url() {
41 81 return 'https://betterdocs.co/docs/docs-archive-in-elementor/';
42 82 }
43 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 +
44 94 protected function register_controls() {
95 + $settings = betterdocs()->settings;
96 +
45 97 $this->start_controls_section(
46 98 'section_content',
47 99 [
48 - 'label' => __( 'Content', 'betterdocs' ),
49 - 'tab' => Controls_Manager::TAB_STYLE
100 + 'label' => __( 'Reading Time', 'betterdocs' ),
101 + 'tab' => Controls_Manager::TAB_CONTENT
50 102 ]
51 103 );
52 104
53 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(
54 118 'ert_reading_title',
55 119 [
56 120 'label' => __( 'Reading Time Title', 'betterdocs' ),
57 121 'type' => Controls_Manager::TEXT,
58 - 'default' => '',
59 - 'placeholder' => __( 'Type Here', 'betterdocs' )
122 + 'default' => (string) $settings->get( 'estimated_reading_time_title' ),
123 + 'placeholder' => __( 'Type Here', 'betterdocs' ),
124 + 'condition' => [ 'enable_reading_time' => 'yes' ]
60 125 ]
61 126 );
62 127
63 128 $this->add_control(
@@ -64,10 +129,11 @@
64 129 'ert_reading_text',
65 130 [
66 131 'label' => __( 'Reading Time Text', 'betterdocs' ),
67 132 'type' => Controls_Manager::TEXT,
68 - 'default' => __( 'min read', 'betterdocs' ),
69 - 'placeholder' => __( 'Type Here', 'betterdocs' )
133 + 'default' => (string) $settings->get( 'estimated_reading_time_text' ),
134 + 'placeholder' => __( 'Type Here', 'betterdocs' ),
135 + 'condition' => [ 'enable_reading_time' => 'yes' ]
70 136 ]
71 137 );
72 138
73 139 $this->add_control(
@@ -72,12 +138,13 @@
72 138
73 139 $this->add_control(
74 140 'singular_ert_reading_text',
75 141 [
76 - 'label' => __( 'Singluar Reading Time Text', 'betterdocs' ),
142 + 'label' => __( 'Singular Reading Time Text', 'betterdocs' ),
77 143 'type' => Controls_Manager::TEXT,
78 - 'default' => __( 'min read', 'betterdocs' ),
79 - 'placeholder' => __( 'Type Here', 'betterdocs' )
144 + 'default' => (string) $settings->get( 'singular_estimated_reading_time_text' ),
145 + 'placeholder' => __( 'Type Here', 'betterdocs' ),
146 + 'condition' => [ 'enable_reading_time' => 'yes' ]
80 147 ]
81 148 );
82 149
83 150 $this->end_controls_section();
@@ -82,12 +149,148 @@
82 149
83 150 $this->end_controls_section();
84 151
85 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(
86 288 'section_reading_style',
87 289 [
88 - 'label' => __( 'Style', 'betterdocs' ),
89 - 'tab' => Controls_Manager::TAB_STYLE
290 + 'label' => __( 'Reading Time', 'betterdocs' ),
291 + 'tab' => Controls_Manager::TAB_STYLE,
292 + 'condition' => [ 'enable_reading_time' => 'yes' ]
90 293 ]
91 294 );
92 295
93 296 $this->add_responsive_control(
@@ -187,13 +390,17 @@
187 390
188 391 $this->add_responsive_control(
189 392 'reading_margin',
190 393 [
191 - 'label' => __( 'Margin', 'betterdocs' ),
192 - 'type' => Controls_Manager::DIMENSIONS,
193 - 'size_units' => [ 'px', 'em', '%' ],
194 - 'selectors' => [
195 - '{{WRAPPER}} .reading-time' => 'margin: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};'
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}};'
196 403 ]
197 404 ]
198 405 );
199 406
@@ -230,14 +437,438 @@
230 437 ]
231 438 );
232 439
233 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();
234 777 }
235 778
236 779 public function view_params() {
780 + $attributes = $this->attributes;
781 +
237 782 return [
238 - 'attributes' => $this->attributes
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()
239 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() );
240 871 }
241 872
242 873 protected function render_callback() {
243 874 $this->views( 'widgets/reading-time' );