PluginProbe
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code / 5.5.4
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code v5.5.4
5.5.4 5.5.3 3.1.0 3.2.0 3.2.1 3.3.0 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.6.0 3.7.0 3.8.0 3.8.1 3.8.2 3.8.3 4.0.0 4.0.2 4.0.3 4.0.4 4.0.5 4.1.0 All 79 releases
copy-the-code / includes / class-shortcode.php

class-shortcode.php in Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code 5.5.4, at includes/class-shortcode.php

1,170 lines 35.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Unified Shortcode Handler
4 *
5 * Provides `[copy]` (and `[ctc]` alias) with design presets: button, icon, inline, cover.
6 * Default preset is **inline**. Legacy `[copy_inline]` still registers for old content but is not recommended for new sites—use `[copy]` instead.
7 *
8 * Reuses the same styles and JS from Global Injector for consistency.
9 *
10 * @package CTC
11 * @since 5.0.0
12 */
13
14 namespace CTC;
15
16 use CTC\Global_Injector\Styles\Button;
17 use CTC\Global_Injector\Styles\Icon;
18 use CTC\Global_Injector\Styles\Cover;
19
20 /**
21 * Shortcode Class
22 *
23 * Single source of truth for all copy shortcodes.
24 */
25 class Shortcode {
26
27 /**
28 * Instance
29 *
30 * @var Shortcode|null
31 */
32 private static $instance = null;
33
34 /**
35 * Track if styles have been enqueued.
36 *
37 * @var bool
38 */
39 private $styles_enqueued = false;
40
41 /**
42 * Track which presets were used on the page (for conditional CSS).
43 *
44 * @var array<string, true>
45 */
46 private $used_presets = [];
47
48 /**
49 * Get instance.
50 *
51 * @return Shortcode
52 */
53 public static function get() {
54 if ( null === self::$instance ) {
55 self::$instance = new self();
56 }
57 return self::$instance;
58 }
59
60 /**
61 * Constructor.
62 */
63 private function __construct() {
64 // Register shortcodes.
65 add_shortcode( 'copy', [ $this, 'render_copy_shortcode' ] );
66 add_shortcode( 'copy_inline', [ $this, 'render_copy_inline_shortcode' ] );
67 add_shortcode( 'ctc', [ $this, 'render_copy_shortcode' ] );
68
69 // Enqueue scripts/styles when shortcode is used.
70 add_action( 'wp_footer', [ $this, 'maybe_enqueue_assets' ], 5 );
71 }
72
73 /**
74 * Get default attributes for [copy] shortcode.
75 *
76 * @return array Default attributes.
77 */
78 public static function get_default_atts() {
79 return [
80 // Content.
81 'text' => '', // Content to copy (or use content between tags).
82 'display' => '', // What to display (default: same as text).
83 'target' => '', // CSS selector to copy from (advanced).
84 'copy-as' => 'text', // 'text' or 'html' (legacy).
85 'copy_as' => '', // Copy as: text, html, text_and_html, image, json, svg.
86
87 // Preset/Style. Empty default so legacy style="button" can map in normalize_legacy_atts().
88 'preset' => '', // 'button', 'icon', 'inline', 'cover' (default inline after normalize).
89
90 // Text.
91 'button-text' => '', // Button text (overrides preset default).
92 'success-text' => '', // Success message.
93 'tooltip' => '', // Tooltip text.
94
95 // Icon.
96 'icon' => '', // Icon key: clipboard, copy, link, etc.
97 'icon-position' => '', // 'left' or 'right'.
98 'show-icon' => '', // 'yes' or 'no'.
99
100 // Colors (overrides preset).
101 'color' => '', // Text color.
102 'bg' => '', // Background color.
103 'hover-bg' => '', // Hover background color.
104 'icon-color' => '', // Icon color (for icon preset).
105
106 // Layout.
107 'class' => '', // Additional CSS class.
108 'id' => '', // Custom ID.
109
110 // Analytics.
111 'analytics' => '', // Enable/disable analytics per shortcode: on|off, 1|0, true|false (default: on).
112
113 // Redirect: URL to open after copy (e.g. store page).
114 'redirect' => '', // Preferred: URL to open after copying.
115 'link' => '', // Backward compatibility: same as redirect.
116
117 // Legacy attributes (backward compatibility).
118 'copied-text' => '', // Maps to success-text.
119 'style' => '', // Maps to preset.
120 'tag' => '', // Legacy: HTML tag.
121 'title' => '', // Legacy: Tooltip.
122 'content' => '', // Legacy: Content to copy.
123 'hidden' => '', // Legacy: Hide display text.
124 ];
125 }
126
127 /**
128 * Render [copy] shortcode.
129 *
130 * @param array $atts Shortcode attributes.
131 * @param string $content Shortcode content.
132 * @return string HTML output.
133 */
134 public function render_copy_shortcode( $atts = [], $content = '' ) {
135 $atts = shortcode_atts( self::get_default_atts(), $atts, 'copy' );
136
137 // Normalize legacy attributes.
138 $atts = $this->normalize_legacy_atts( $atts, $content );
139
140 /**
141 * Filter shortcode attributes before rendering.
142 *
143 * @since 5.0.0
144 *
145 * @param array $atts Normalized shortcode attributes.
146 * @param string $content Shortcode content.
147 */
148 $atts = apply_filters( 'ctc/shortcode/atts', $atts, $content );
149
150 // Mark that we need to enqueue assets.
151 $this->styles_enqueued = true;
152
153 // Track preset for conditional CSS (only load styles for presets used).
154 $preset = $atts['preset'] ?? 'inline';
155 $this->used_presets[ $preset ] = true;
156
157 // Render based on preset.
158 switch ( $preset ) {
159 case 'button':
160 return $this->render_button_preset( $atts );
161
162 case 'icon':
163 return $this->render_icon_preset( $atts );
164
165 case 'cover':
166 return $this->render_cover_preset( $atts );
167
168 case 'inline':
169 default:
170 return $this->render_inline_preset( $atts );
171 }
172 }
173
174 /**
175 * Render legacy `[copy_inline]` shortcode (backward compatible).
176 *
177 * Maps to `[copy]` with the inline preset. Prefer `[copy]` in new content.
178 *
179 * @param array $atts Shortcode attributes.
180 * @param string $content Shortcode content.
181 * @return string HTML output.
182 */
183 public function render_copy_inline_shortcode( $atts = [], $content = '' ) {
184 // Map copy_inline attributes to copy attributes.
185 $mapped_atts = [
186 'preset' => 'inline',
187 'text' => isset( $atts['text'] ) ? $atts['text'] : $content,
188 'display' => isset( $atts['display'] ) ? $atts['display'] : '',
189 'success-text' => isset( $atts['tooltip'] ) ? $atts['tooltip'] : __( 'Copied', 'ctc' ),
190 'class' => isset( $atts['style'] ) ? 'ctc-inline-style-' . $atts['style'] : '',
191 'hidden' => isset( $atts['hidden'] ) ? $atts['hidden'] : '',
192 ];
193
194 return $this->render_copy_shortcode( $mapped_atts, $content );
195 }
196
197 /**
198 * Normalize legacy attributes to new format.
199 *
200 * @param array $atts Shortcode attributes.
201 * @param string $content Shortcode content.
202 * @return array Normalized attributes.
203 */
204 private function normalize_legacy_atts( $atts, $content ) {
205 // Decode HTML entities in content so [ ] work (e.g. &#91; &#93;).
206 if ( ! empty( $atts['content'] ) ) {
207 $atts['content'] = html_entity_decode( $atts['content'], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
208 }
209
210 // Legacy support: 'content' attribute contains what to copy.
211 // 'text' attribute is the display text in legacy usage.
212 // Example: [copy text="Location" content="/wp [...]"] - displays "Location", copies "/wp [...]"
213 if ( ! empty( $atts['content'] ) ) {
214 // Legacy mode: content = what to copy, text = what to display.
215 $copy_text = $atts['content'];
216 $display_text = ! empty( $atts['text'] ) ? $atts['text'] : $atts['content'];
217
218 $atts['text'] = $copy_text;
219 $atts['display'] = $display_text;
220 } elseif ( empty( $atts['text'] ) && ! empty( $content ) ) {
221 // Modern mode: text = both copy and display (unless display is set).
222 $atts['text'] = $content;
223 }
224
225 // Decode HTML entities in text (supports &#91; for [ and &#93; for ] etc.).
226 // This allows users to use HTML entities for special characters in shortcode attributes.
227 if ( ! empty( $atts['text'] ) ) {
228 $atts['text'] = html_entity_decode( $atts['text'], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
229 // Fix WordPress wptexturize() converting -- to en-dash inside CSS var().
230 $atts['text'] = $this->fix_var_texturize( $atts['text'] );
231 }
232
233 // Display text: prefer inner content when present, else default to copy text.
234 $content_trimmed = isset( $content ) ? trim( (string) $content ) : '';
235 if ( ! empty( $content_trimmed ) && empty( $atts['display'] ) ) {
236 $atts['display'] = html_entity_decode( $content_trimmed, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
237 } elseif ( empty( $atts['display'] ) ) {
238 $atts['display'] = $atts['text'];
239 } else {
240 $atts['display'] = html_entity_decode( $atts['display'], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
241 }
242
243 // Success text.
244 if ( empty( $atts['success-text'] ) && ! empty( $atts['copied-text'] ) ) {
245 $atts['success-text'] = $atts['copied-text'];
246 }
247 if ( empty( $atts['success-text'] ) ) {
248 $atts['success-text'] = __( 'Copied!', 'ctc' );
249 }
250
251 // Tooltip.
252 if ( empty( $atts['tooltip'] ) && ! empty( $atts['title'] ) ) {
253 $atts['tooltip'] = $atts['title'];
254 }
255 if ( empty( $atts['tooltip'] ) ) {
256 $atts['tooltip'] = __( 'Copy to clipboard', 'ctc' );
257 }
258
259 // Preset from legacy style (style="button|icon|cover").
260 if ( empty( $atts['preset'] ) && ! empty( $atts['style'] ) ) {
261 $style_map = [
262 'icon' => 'icon',
263 'button' => 'button',
264 'cover' => 'cover',
265 ];
266 if ( isset( $style_map[ $atts['style'] ] ) ) {
267 $atts['preset'] = $style_map[ $atts['style'] ];
268 }
269 }
270 if ( empty( $atts['preset'] ) ) {
271 $atts['preset'] = 'inline';
272 }
273
274 // Button text.
275 if ( empty( $atts['button-text'] ) ) {
276 $atts['button-text'] = __( 'Copy', 'ctc' );
277 }
278
279 // Redirect URL: prefer redirect attribute; fall back to link for backward compatibility.
280 if ( ! empty( $atts['redirect'] ) ) {
281 $atts['link'] = $atts['redirect'];
282 }
283
284 // Analytics enabled flag.
285 $raw_analytics = isset( $atts['analytics'] ) ? strtolower( trim( (string) $atts['analytics'] ) ) : '';
286 $enabled = true;
287
288 if ( '' !== $raw_analytics ) {
289 if ( in_array( $raw_analytics, [ '0', 'false', 'off', 'no', 'disabled' ], true ) ) {
290 $enabled = false;
291 } elseif ( in_array( $raw_analytics, [ '1', 'true', 'on', 'yes', 'enabled' ], true ) ) {
292 $enabled = true;
293 }
294 }
295
296 /**
297 * Filter whether analytics should be enabled for this shortcode instance.
298 *
299 * @since 5.4.0
300 *
301 * @param bool $enabled Whether analytics is enabled.
302 * @param array $atts Normalized shortcode attributes.
303 * @param string $content Shortcode content.
304 */
305 $enabled = (bool) apply_filters( 'ctc/shortcode/analytics_enabled', $enabled, $atts, $content );
306
307 $atts['analytics_enabled'] = $enabled;
308
309 // Set display when shortcode content is present.
310 if ( $content && empty( $atts['display'] ) ) {
311 $atts['display'] = $content;
312 }
313
314 // Set display when content attribute exists and content exists.
315 if ( $atts['content'] && $content ) {
316 $atts['display'] = $content;
317 }
318
319 return $atts;
320 }
321
322 /**
323 * Get sanitized display HTML for shortcode content.
324 *
325 * Uses wp_kses with filterable allowed HTML so formatted content (e.g. bold) renders
326 * while keeping output safe. Themes/plugins can extend via ctc/shortcode/display_allowed_html.
327 *
328 * @since 5.3.1
329 *
330 * @param array $atts Shortcode attributes (must include 'display').
331 * @return string Sanitized HTML for display.
332 */
333 private function get_display_html( $value ) {
334 $allowed = wp_kses_allowed_html( 'post' );
335
336 /**
337 * Filter allowed HTML for [copy] shortcode display content.
338 *
339 * Use this to add or restrict tags/attributes for the content between [copy][/copy]
340 * when it is rendered on the front end (e.g. add span with class for highlights).
341 *
342 * @since 5.3.1
343 *
344 * @param array $allowed Allowed HTML (wp_kses format).
345 * @param array $atts Shortcode attributes.
346 */
347 $allowed = apply_filters( 'ctc/shortcode/display_allowed_html', $allowed, $value );
348
349 return wp_kses( $value, $allowed );
350 }
351
352 /**
353 * Fix WordPress wptexturize() inside CSS var() functions.
354 *
355 * WordPress converts -- to en-dash (–) and --- to em-dash (—).
356 * This breaks CSS custom properties like var(--wp--preset--color--bg).
357 *
358 * This method only fixes content inside var() to avoid affecting
359 * legitimate typography like "2020–2025" or "He said—yes".
360 *
361 * @since 5.0.0
362 *
363 * @param string $text Text to process.
364 * @return string Processed text with var() contents fixed.
365 */
366 private function fix_var_texturize( $text ) {
367 if ( empty( $text ) || strpos( $text, 'var(' ) === false ) {
368 return $text;
369 }
370
371 // Replace en-dash/em-dash with hyphens only inside var().
372 return preg_replace_callback(
373 '/var\s*\(([^)]+)\)/',
374 function ( $matches ) {
375 $inside = str_replace(
376 [ '–', '—' ], // En-dash, Em-dash (Unicode).
377 [ '--', '---' ], // Double, Triple hyphen.
378 $matches[1]
379 );
380 return 'var(' . $inside . ')';
381 },
382 $text
383 );
384 }
385
386 /**
387 * Render inline preset.
388 *
389 * Displays text with a copy icon. Best for coupon codes, promo codes, etc.
390 *
391 * Supports:
392 * - tag="a" : Uses native anchor tag with theme styling (no custom CSS overrides).
393 * - show-icon="no" : Hides the copy icon.
394 *
395 * @param array $atts Shortcode attributes.
396 * @return string HTML output.
397 */
398 private function render_inline_preset( $atts ) {
399 $id = ! empty( $atts['id'] ) ? $atts['id'] : 'ctc-inline-' . wp_generate_password( 8, false, false );
400 $hidden_class = 'yes' === $atts['hidden'] ? 'ctc-inline-hidden' : '';
401 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
402
403 // Determine if using native anchor tag (inherits theme styling).
404 $use_native_tag = 'a' === strtolower( $atts['tag'] );
405
406 // Determine if icon should be shown (default: yes, unless show-icon="no").
407 $show_icon = ! in_array( strtolower( $atts['show-icon'] ), [ 'no', 'false', '0' ], true );
408
409 // Build CSS class based on tag type.
410 if ( $use_native_tag ) {
411 // Native mode: Use theme's anchor styling, minimal CTC styling.
412 $this->used_presets['native'] = true;
413 $css_class = 'ctc-shortcode ctc-shortcode--native' . $custom_class;
414 } else {
415 // Modern mode: Full CTC styling.
416 $css_class = 'ctc-shortcode ctc-shortcode--inline' . $custom_class;
417 }
418
419 // Build inline styles (only for non-native mode).
420 $inline_style = '';
421 if ( ! $use_native_tag && ! empty( $atts['color'] ) ) {
422 $inline_style .= '--ctc-inline-color: ' . esc_attr( $atts['color'] ) . ';';
423 }
424
425 // Determine HTML tag.
426 $tag = $use_native_tag ? 'a' : 'span';
427
428 // Copy as: prefer copy_as.
429 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
430
431 // Build icon HTML.
432 $icon_html = '';
433 if ( $show_icon ) {
434 $icon_key = ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard';
435 $icon_html = '<span class="ctc-shortcode__icon" aria-hidden="true">' .
436 wp_kses( $this->get_icon_svg( $icon_key ), $this->get_allowed_svg_tags() ) .
437 '</span>';
438 }
439
440 ob_start();
441 ?>
442 <<?php echo esc_attr( $tag ); ?>
443 id="<?php echo esc_attr( $id ); ?>"
444 class="<?php echo esc_attr( $css_class ); ?>"
445 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
446 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
447 data-ctc-success="<?php echo esc_attr( $atts['success-text'] ); ?>"
448 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
449 <?php if ( ! empty( $copy_as_value ) ) : ?>
450 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
451 <?php endif; ?>
452 <?php if ( ! empty( $atts['target'] ) ) : ?>
453 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
454 <?php endif; ?>
455 <?php if ( $inline_style ) : ?>
456 style="<?php echo esc_attr( $inline_style ); ?>"
457 <?php endif; ?>
458 <?php if ( $use_native_tag ) : ?>
459 href="javascript:void(0);"
460 title="<?php echo esc_attr( $atts['tooltip'] ); ?>"
461 <?php else : ?>
462 role="button"
463 tabindex="0"
464 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
465 <?php endif; ?>
466 >
467 <span class="ctc-shortcode__text <?php echo esc_attr( $hidden_class ); ?>"><?php echo $this->get_display_html( $atts['display'] ); ?></span>
468 <?php echo $icon_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
469 <span class="ctc-shortcode__success" aria-live="polite"></span>
470 </<?php echo esc_attr( $tag ); ?>>
471 <?php
472 return ob_get_clean();
473 }
474
475 /**
476 * Render button preset.
477 *
478 * Uses Button style class for consistent styling with Global Injector.
479 *
480 * @param array $atts Shortcode attributes.
481 * @return string HTML output.
482 */
483 private function render_button_preset( $atts ) {
484 $id = ! empty( $atts['id'] ) ? $atts['id'] : Button::generate_button_id();
485 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
486
487 // Build config for Button style.
488 $config = [
489 'text' => [
490 'button_text' => $atts['button-text'],
491 'success_text' => $atts['success-text'],
492 ],
493 'icon' => [
494 'enabled' => 'no' !== $atts['show-icon'],
495 'position' => ! empty( $atts['icon-position'] ) ? $atts['icon-position'] : 'left',
496 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
497 ],
498 'style' => [],
499 ];
500
501 // Add custom colors if provided.
502 if ( ! empty( $atts['color'] ) ) {
503 $config['style']['text_color'] = $atts['color'];
504 }
505 if ( ! empty( $atts['bg'] ) ) {
506 $config['style']['background_color'] = $atts['bg'];
507 }
508 if ( ! empty( $atts['hover-bg'] ) ) {
509 $config['style']['hover_background_color'] = $atts['hover-bg'];
510 }
511
512 // Merge with defaults.
513 $config = Button::merge_config( $config );
514
515 // Build inline styles.
516 $inline_styles = Button::build_inline_styles( $config['style'] );
517
518 // Get icon SVG.
519 $icon_svg = '';
520 if ( $config['icon']['enabled'] ) {
521 $icon_svg = '<span class="ctc-shortcode__icon">' . Button::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
522 }
523
524 // Build content based on icon position.
525 $button_content = '';
526 if ( 'left' === $config['icon']['position'] && $icon_svg ) {
527 $button_content .= $icon_svg;
528 }
529 $button_content .= '<span class="ctc-shortcode__text">' . esc_html( $config['text']['button_text'] ) . '</span>';
530 if ( 'right' === $config['icon']['position'] && $icon_svg ) {
531 $button_content .= $icon_svg;
532 }
533
534 // Copy as: prefer copy_as.
535 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
536
537 ob_start();
538 ?>
539 <button
540 type="button"
541 id="<?php echo esc_attr( $id ); ?>"
542 class="ctc-shortcode ctc-shortcode--button<?php echo esc_attr( $custom_class ); ?>"
543 style="<?php echo esc_attr( $inline_styles ); ?>"
544 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
545 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
546 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
547 data-ctc-original="<?php echo esc_attr( $config['text']['button_text'] ); ?>"
548 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
549 <?php if ( ! empty( $copy_as_value ) ) : ?>
550 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
551 <?php endif; ?>
552 <?php if ( ! empty( $atts['target'] ) ) : ?>
553 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
554 <?php endif; ?>
555 <?php if ( ! empty( $atts['link'] ) ) : ?>
556 data-ctc-link="<?php echo esc_attr( $atts['link'] ); ?>"
557 <?php endif; ?>
558 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
559 >
560 <?php echo $button_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
561 </button>
562 <?php
563 return ob_get_clean();
564 }
565
566 /**
567 * Render icon preset.
568 *
569 * Uses Icon style class for consistent styling with Global Injector.
570 *
571 * @param array $atts Shortcode attributes.
572 * @return string HTML output.
573 */
574 private function render_icon_preset( $atts ) {
575 $id = ! empty( $atts['id'] ) ? $atts['id'] : Icon::generate_icon_id();
576 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
577
578 // Build config for Icon style.
579 $config = [
580 'text' => [
581 'tooltip_text' => $atts['tooltip'],
582 'success_text' => $atts['success-text'],
583 ],
584 'icon' => [
585 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
586 ],
587 'style' => [],
588 ];
589
590 // Add custom colors if provided.
591 if ( ! empty( $atts['icon-color'] ) ) {
592 $config['style']['icon_color'] = $atts['icon-color'];
593 }
594 if ( ! empty( $atts['bg'] ) ) {
595 $config['style']['bg_color'] = $atts['bg'];
596 }
597
598 // Merge with defaults.
599 $config = Icon::merge_config( $config );
600
601 // Build inline styles.
602 $inline_styles = Icon::build_inline_styles( $config['style'] );
603
604 // Get icon SVG.
605 $icon_svg = '<span class="ctc-shortcode__icon">' . Icon::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
606
607 // Copy as: prefer copy_as.
608 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
609
610 ob_start();
611 ?>
612 <button
613 type="button"
614 id="<?php echo esc_attr( $id ); ?>"
615 class="ctc-shortcode ctc-shortcode--icon<?php echo esc_attr( $custom_class ); ?>"
616 style="<?php echo esc_attr( $inline_styles ); ?>"
617 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
618 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
619 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
620 data-ctc-tooltip="<?php echo esc_attr( $config['text']['tooltip_text'] ); ?>"
621 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
622 <?php if ( ! empty( $copy_as_value ) ) : ?>
623 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
624 <?php endif; ?>
625 <?php if ( ! empty( $atts['target'] ) ) : ?>
626 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
627 <?php endif; ?>
628 aria-label="<?php echo esc_attr( $config['text']['tooltip_text'] ); ?>"
629 >
630 <?php echo $icon_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
631 </button>
632 <?php
633 return ob_get_clean();
634 }
635
636 /**
637 * Render cover preset.
638 *
639 * Wraps content with hover overlay and copy button.
640 *
641 * @param array $atts Shortcode attributes.
642 * @return string HTML output.
643 */
644 private function render_cover_preset( $atts ) {
645 $id = ! empty( $atts['id'] ) ? $atts['id'] : Cover::generate_cover_id();
646 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
647
648 // Build config for Cover style.
649 $config = [
650 'text' => [
651 'button_text' => $atts['button-text'],
652 'success_text' => $atts['success-text'],
653 ],
654 'icon' => [
655 'enabled' => 'no' !== $atts['show-icon'],
656 'position' => ! empty( $atts['icon-position'] ) ? $atts['icon-position'] : 'left',
657 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
658 ],
659 'button' => [],
660 ];
661
662 // Add custom colors if provided.
663 if ( ! empty( $atts['color'] ) ) {
664 $config['button']['text_color'] = $atts['color'];
665 }
666 if ( ! empty( $atts['bg'] ) ) {
667 $config['button']['bg_color'] = $atts['bg'];
668 }
669 if ( ! empty( $atts['hover-bg'] ) ) {
670 $config['button']['hover_bg_color'] = $atts['hover-bg'];
671 }
672
673 // Merge with defaults.
674 $config = Cover::merge_config( $config );
675
676 // Get icon SVG.
677 $icon_svg = '';
678 if ( $config['icon']['enabled'] ) {
679 $icon_svg = '<span class="ctc-cover-icon">' . Cover::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
680 }
681
682 // Build button content.
683 $button_content = '';
684 if ( 'left' === $config['icon']['position'] && $icon_svg ) {
685 $button_content .= $icon_svg;
686 }
687 $button_content .= '<span class="ctc-cover-text">' . esc_html( $config['text']['button_text'] ) . '</span>';
688 if ( 'right' === $config['icon']['position'] && $icon_svg ) {
689 $button_content .= $icon_svg;
690 }
691
692 ob_start();
693 ?>
694 <div
695 id="<?php echo esc_attr( $id ); ?>"
696 class="ctc-shortcode ctc-shortcode--cover<?php echo esc_attr( $custom_class ); ?>"
697 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
698 >
699 <div class="ctc-shortcode__content">
700 <?php echo wp_kses_post( $atts['display'] ); ?>
701 </div>
702 <div
703 class="ctc-cover-overlay"
704 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
705 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
706 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
707 <?php if ( ! empty( $atts['target'] ) ) : ?>
708 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
709 <?php endif; ?>
710 role="button"
711 tabindex="0"
712 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
713 >
714 <button type="button" class="ctc-cover-button" aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>">
715 <?php echo $button_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
716 </button>
717 </div>
718 </div>
719 <?php
720 return ob_get_clean();
721 }
722
723 /**
724 * Get icon SVG by key.
725 *
726 * @param string $icon_key Icon key.
727 * @return string SVG markup.
728 */
729 private function get_icon_svg( $icon_key ) {
730 $icons = [
731 'clipboard' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>',
732 'copy' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>',
733 'link' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>',
734 'check' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
735 ];
736
737 return isset( $icons[ $icon_key ] ) ? $icons[ $icon_key ] : $icons['clipboard'];
738 }
739
740 /**
741 * Get success icon SVG.
742 *
743 * @return string SVG markup.
744 */
745 private function get_success_icon_svg() {
746 return '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
747 }
748
749 /**
750 * Get allowed SVG tags for wp_kses.
751 *
752 * @return array Allowed HTML tags and attributes.
753 */
754 private function get_allowed_svg_tags() {
755 return [
756 'svg' => [
757 'xmlns' => true,
758 'fill' => true,
759 'viewbox' => true,
760 'stroke' => true,
761 'width' => true,
762 'height' => true,
763 'class' => true,
764 'aria-hidden' => true,
765 ],
766 'path' => [
767 'd' => true,
768 'stroke-linecap' => true,
769 'stroke-linejoin' => true,
770 'stroke-width' => true,
771 'fill' => true,
772 ],
773 ];
774 }
775
776 /**
777 * Whether the current page may contain [copy] output (direct shortcode or via container shortcodes like tables).
778 * Used so we enqueue assets when [copy] appears inside Supsystic/wpDataTables etc., including when table HTML is cached.
779 *
780 * @return bool
781 */
782 private function page_may_contain_copy_output() {
783 if ( $this->styles_enqueued ) {
784 return true;
785 }
786 $post = get_post();
787 if ( ! $post || ! is_singular() || empty( $post->post_content ) ) {
788 return false;
789 }
790 // Built-in list is empty; third-party integrations add their shortcodes via filter when plugins are active.
791 $container_shortcodes = [];
792 /**
793 * Shortcodes that may output `[copy]` in their rendered content (e.g. table cell data).
794 * When present in post content, we enqueue copy assets so buttons work even if that content is cached.
795 * Third-party classes (e.g. CTC\ThirdParty\Supsystic_Tables) add to this when their plugin is active.
796 *
797 * @param string[] $container_shortcodes List of shortcode names.
798 */
799 $container_shortcodes = apply_filters( 'ctc/shortcode/container_shortcodes', $container_shortcodes );
800 foreach ( $container_shortcodes as $tag ) {
801 if ( has_shortcode( $post->post_content, $tag ) ) {
802 return true;
803 }
804 }
805 return false;
806 }
807
808 /**
809 * Maybe enqueue assets if shortcode was used or page may contain copy output (e.g. inside table plugins).
810 *
811 * @return void
812 */
813 public function maybe_enqueue_assets() {
814 if ( ! $this->page_may_contain_copy_output() ) {
815 return;
816 }
817
818 wp_enqueue_script(
819 'ctc-lib-core',
820 CTC_URI . 'assets/frontend/js/lib/ctc.js',
821 [],
822 CTC_VER,
823 true
824 );
825
826 wp_enqueue_script(
827 'ctc-shortcode',
828 CTC_URI . 'assets/frontend/js/shortcode.js',
829 [ 'ctc-lib-core' ],
830 CTC_VER,
831 true
832 );
833
834 // Localize shortcode frontend context for analytics tracking.
835 $localize_data = [
836 'eventsUrl' => rest_url( 'ctc/v1/analytics/events' ),
837 'postId' => get_the_ID() ? get_the_ID() : null,
838 'postType' => get_post_type() ? get_post_type() : null,
839 'pageUrl' => $this->get_current_page_url(),
840 ];
841 /**
842 * Filter shortcode frontend localize data.
843 *
844 * Pro can use this to customize eventsUrl and add extra telemetry context.
845 *
846 * @since 5.4.0
847 * @param array $data Localized data array.
848 */
849 $localize_data = apply_filters( 'ctc/shortcode/localize_data', $localize_data );
850
851 wp_localize_script(
852 'ctc-shortcode',
853 'ctcShortcode',
854 $localize_data
855 );
856
857 // When enqueueing for container shortcodes only (e.g. table cached), include all preset CSS.
858 if ( ! $this->styles_enqueued ) {
859 $this->used_presets = array_fill_keys( [ 'inline', 'button', 'icon', 'cover', 'native' ], true );
860 }
861
862 // Enqueue inline CSS (only for presets used on the page, minified).
863 wp_register_style( 'ctc-shortcode', false, [], CTC_VER );
864 wp_enqueue_style( 'ctc-shortcode' );
865 wp_add_inline_style( 'ctc-shortcode', $this->get_inline_css() );
866 }
867
868 /**
869 * Get the current page URL.
870 *
871 * @since 5.4.0
872 * @return string Current page URL.
873 */
874 private function get_current_page_url() {
875 if ( ! empty( $_SERVER['HTTP_HOST'] ) && ! empty( $_SERVER['REQUEST_URI'] ) ) {
876 $scheme = is_ssl() ? 'https://' : 'http://';
877 return esc_url_raw(
878 $scheme .
879 sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) .
880 sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
881 );
882 }
883
884 // Fallback to home URL if server vars are unavailable.
885 return esc_url_raw( home_url( '/' ) );
886 }
887
888 /**
889 * Minify CSS: strip comments and collapse whitespace.
890 *
891 * @param string $css Raw CSS.
892 * @return string Minified CSS.
893 */
894 private function minify_css( $css ) {
895 $css = (string) preg_replace( '/\/\*[\s\S]*?\*\//', '', $css );
896 $css = (string) preg_replace( '/\s+/', ' ', $css );
897 return trim( $css );
898 }
899
900 /**
901 * Get inline CSS for shortcode styles.
902 * Builds from base + only the preset chunks that were used, then minifies.
903 *
904 * @return string CSS styles.
905 */
906 private function get_inline_css() {
907 $parts = [ $this->get_css_base() ];
908 $presets = array_keys( $this->used_presets );
909 $chunk_map = [
910 'inline' => 'get_css_inline',
911 'native' => 'get_css_native',
912 'button' => 'get_css_button',
913 'icon' => 'get_css_icon',
914 'cover' => 'get_css_cover',
915 ];
916 foreach ( $presets as $preset ) {
917 if ( isset( $chunk_map[ $preset ] ) ) {
918 $parts[] = $this->{$chunk_map[ $preset ]}();
919 }
920 }
921 return $this->minify_css( implode( "\n", $parts ) );
922 }
923
924 /**
925 * Base + shared CSS (vars, icon, success, table, keyframes). Always included when shortcode is used.
926 *
927 * @return string
928 */
929 private function get_css_base() {
930 return '
931 .ctc-shortcode {
932 --ctc-inline-color: currentColor;
933 --ctc-inline-hover-color: currentColor;
934 }
935 .ctc-shortcode__icon {
936 display: inline-flex;
937 align-items: center;
938 justify-content: center;
939 }
940 .ctc-shortcode__icon svg {
941 width: 14px;
942 height: 14px;
943 }
944 table .ctc-shortcode__icon,
945 table .ctc-shortcode__icon svg,
946 table .ctc-cover-icon,
947 table .ctc-cover-icon svg {
948 width: 14px !important;
949 height: 14px !important;
950 min-width: 14px;
951 min-height: 14px;
952 max-width: 14px !important;
953 max-height: 14px !important;
954 flex-shrink: 0;
955 }
956 table .ctc-cover-icon svg {
957 width: 12px !important;
958 height: 12px !important;
959 max-width: 12px !important;
960 max-height: 12px !important;
961 }
962 .ctc-shortcode__success {
963 position: absolute;
964 pointer-events: none;
965 }
966 .ctc-shortcode--copied {
967 animation: ctc-shortcode-pulse 0.3s ease;
968 }
969 @keyframes ctc-shortcode-pulse {
970 0%, 100% { transform: scale(1); }
971 50% { transform: scale(1.05); }
972 }
973 ';
974 }
975
976 /**
977 * Inline preset CSS.
978 *
979 * @return string
980 */
981 private function get_css_inline() {
982 return '
983 .ctc-shortcode--inline {
984 display: inline-flex;
985 align-items: center;
986 gap: 4px;
987 cursor: pointer;
988 color: var(--ctc-inline-color);
989 transition: opacity 0.15s ease;
990 }
991 .ctc-shortcode--inline:hover {
992 opacity: 0.8;
993 }
994 .ctc-shortcode--inline:focus {
995 outline: 2px solid currentColor;
996 outline-offset: 2px;
997 }
998 .ctc-shortcode--inline .ctc-inline-hidden {
999 display: none;
1000 }
1001 ';
1002 }
1003
1004 /**
1005 * Native preset CSS.
1006 *
1007 * @return string
1008 */
1009 private function get_css_native() {
1010 return '
1011 .ctc-shortcode--native {
1012 display: inline-flex;
1013 align-items: center;
1014 gap: 4px;
1015 cursor: pointer;
1016 }
1017 .ctc-shortcode--native .ctc-shortcode__icon {
1018 opacity: 0.7;
1019 transition: opacity 0.15s ease;
1020 }
1021 .ctc-shortcode--native:hover .ctc-shortcode__icon {
1022 opacity: 1;
1023 }
1024 ';
1025 }
1026
1027 /**
1028 * Button preset CSS.
1029 *
1030 * @return string
1031 */
1032 private function get_css_button() {
1033 return '
1034 .ctc-shortcode--button {
1035 display: inline-flex;
1036 align-items: center;
1037 gap: 6px;
1038 border: none;
1039 cursor: pointer;
1040 font-family: inherit;
1041 line-height: 1.4;
1042 transition: all 0.15s ease;
1043 color: var(--ctc-text-color, #ffffff);
1044 background: var(--ctc-bg, #4f46e5);
1045 font-size: var(--ctc-font-size, 13px);
1046 font-weight: var(--ctc-font-weight, 600);
1047 padding: var(--ctc-padding-y, 8px) var(--ctc-padding-x, 16px);
1048 border-radius: var(--ctc-border-radius, 6px);
1049 }
1050 .ctc-shortcode--button:hover {
1051 background: var(--ctc-hover-bg, #4338ca);
1052 }
1053 .ctc-shortcode--button:focus {
1054 outline: 2px solid var(--ctc-bg, #4f46e5);
1055 outline-offset: 2px;
1056 }
1057 .ctc-shortcode--button:active {
1058 transform: scale(0.97);
1059 }
1060 .ctc-shortcode--button .ctc-shortcode__icon svg {
1061 width: 14px;
1062 height: 14px;
1063 }
1064 ';
1065 }
1066
1067 /**
1068 * Icon preset CSS.
1069 *
1070 * @return string
1071 */
1072 private function get_css_icon() {
1073 return '
1074 .ctc-shortcode--icon {
1075 display: inline-flex;
1076 align-items: center;
1077 justify-content: center;
1078 border-style: solid;
1079 cursor: pointer;
1080 transition: all 0.2s ease;
1081 color: var(--ctc-icon-color, #6b7280);
1082 background: var(--ctc-icon-bg, transparent);
1083 border-color: var(--ctc-icon-border, #d1d5db);
1084 border-width: var(--ctc-icon-border-width, 1px);
1085 padding: var(--ctc-icon-padding, 6px);
1086 border-radius: var(--ctc-icon-radius, 6px);
1087 }
1088 .ctc-shortcode--icon:hover {
1089 color: var(--ctc-icon-hover-color, #374151);
1090 border-color: var(--ctc-icon-hover-border, #9ca3af);
1091 background: var(--ctc-icon-hover-bg, #f3f4f6);
1092 }
1093 .ctc-shortcode--icon:focus {
1094 outline: 2px solid var(--ctc-icon-border, #d1d5db);
1095 outline-offset: 2px;
1096 }
1097 .ctc-shortcode--icon .ctc-shortcode__icon svg {
1098 width: var(--ctc-icon-size, 16px);
1099 height: var(--ctc-icon-size, 16px);
1100 }
1101 ';
1102 }
1103
1104 /**
1105 * Cover preset CSS.
1106 *
1107 * @return string
1108 */
1109 private function get_css_cover() {
1110 return '
1111 .ctc-shortcode--cover {
1112 position: relative;
1113 display: block;
1114 }
1115 .ctc-shortcode--cover .ctc-shortcode__content {
1116 display: block;
1117 }
1118 .ctc-shortcode--cover .ctc-cover-overlay {
1119 position: absolute;
1120 inset: 0;
1121 z-index: 10;
1122 border-radius: inherit;
1123 transition: all 0.3s ease;
1124 cursor: pointer;
1125 opacity: 0;
1126 background: rgba(15, 23, 42, 0.2);
1127 display: flex;
1128 align-items: center;
1129 justify-content: center;
1130 }
1131 .ctc-shortcode--cover:hover .ctc-cover-overlay {
1132 opacity: 1;
1133 background: rgba(79, 70, 229, 0.3);
1134 backdrop-filter: blur(2px);
1135 -webkit-backdrop-filter: blur(2px);
1136 }
1137 .ctc-shortcode--cover .ctc-cover-button {
1138 transform: scale(0.9);
1139 opacity: 0;
1140 transition: all 0.2s ease;
1141 color: #0f172a;
1142 background: rgba(255, 255, 255, 0.95);
1143 font-size: 10px;
1144 font-weight: 700;
1145 padding: 6px 12px;
1146 border-radius: 9999px;
1147 display: inline-flex;
1148 align-items: center;
1149 gap: 6px;
1150 border: 1px solid rgba(255, 255, 255, 0.5);
1151 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1152 white-space: nowrap;
1153 cursor: pointer;
1154 }
1155 .ctc-shortcode--cover:hover .ctc-cover-button {
1156 opacity: 1;
1157 transform: scale(1);
1158 }
1159 .ctc-shortcode--cover .ctc-cover-button:hover {
1160 background: #f1f5f9;
1161 transform: scale(1.05);
1162 }
1163 .ctc-shortcode--cover .ctc-cover-icon svg {
1164 width: 12px;
1165 height: 12px;
1166 }
1167 ';
1168 }
1169 }
1170