PluginProbe
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code / trunk
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code vtrunk
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 4.1.1 All 78 releases
copy-the-code / includes / class-shortcode.php

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

1,167 lines 35.3 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.
88 'preset' => 'inline', // 'button', 'icon', 'inline', 'cover'.
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.
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
271 // Button text.
272 if ( empty( $atts['button-text'] ) ) {
273 $atts['button-text'] = __( 'Copy', 'ctc' );
274 }
275
276 // Redirect URL: prefer redirect attribute; fall back to link for backward compatibility.
277 if ( ! empty( $atts['redirect'] ) ) {
278 $atts['link'] = $atts['redirect'];
279 }
280
281 // Analytics enabled flag.
282 $raw_analytics = isset( $atts['analytics'] ) ? strtolower( trim( (string) $atts['analytics'] ) ) : '';
283 $enabled = true;
284
285 if ( '' !== $raw_analytics ) {
286 if ( in_array( $raw_analytics, [ '0', 'false', 'off', 'no', 'disabled' ], true ) ) {
287 $enabled = false;
288 } elseif ( in_array( $raw_analytics, [ '1', 'true', 'on', 'yes', 'enabled' ], true ) ) {
289 $enabled = true;
290 }
291 }
292
293 /**
294 * Filter whether analytics should be enabled for this shortcode instance.
295 *
296 * @since 5.4.0
297 *
298 * @param bool $enabled Whether analytics is enabled.
299 * @param array $atts Normalized shortcode attributes.
300 * @param string $content Shortcode content.
301 */
302 $enabled = (bool) apply_filters( 'ctc/shortcode/analytics_enabled', $enabled, $atts, $content );
303
304 $atts['analytics_enabled'] = $enabled;
305
306 // Set display when shortcode content is present.
307 if ( $content && empty( $atts['display'] ) ) {
308 $atts['display'] = $content;
309 }
310
311 // Set display when content attribute exists and content exists.
312 if ( $atts['content'] && $content ) {
313 $atts['display'] = $content;
314 }
315
316 return $atts;
317 }
318
319 /**
320 * Get sanitized display HTML for shortcode content.
321 *
322 * Uses wp_kses with filterable allowed HTML so formatted content (e.g. bold) renders
323 * while keeping output safe. Themes/plugins can extend via ctc/shortcode/display_allowed_html.
324 *
325 * @since 5.3.1
326 *
327 * @param array $atts Shortcode attributes (must include 'display').
328 * @return string Sanitized HTML for display.
329 */
330 private function get_display_html( $value ) {
331 $allowed = wp_kses_allowed_html( 'post' );
332
333 /**
334 * Filter allowed HTML for [copy] shortcode display content.
335 *
336 * Use this to add or restrict tags/attributes for the content between [copy][/copy]
337 * when it is rendered on the front end (e.g. add span with class for highlights).
338 *
339 * @since 5.3.1
340 *
341 * @param array $allowed Allowed HTML (wp_kses format).
342 * @param array $atts Shortcode attributes.
343 */
344 $allowed = apply_filters( 'ctc/shortcode/display_allowed_html', $allowed, $value );
345
346 return wp_kses( $value, $allowed );
347 }
348
349 /**
350 * Fix WordPress wptexturize() inside CSS var() functions.
351 *
352 * WordPress converts -- to en-dash (–) and --- to em-dash (—).
353 * This breaks CSS custom properties like var(--wp--preset--color--bg).
354 *
355 * This method only fixes content inside var() to avoid affecting
356 * legitimate typography like "2020–2025" or "He said—yes".
357 *
358 * @since 5.0.0
359 *
360 * @param string $text Text to process.
361 * @return string Processed text with var() contents fixed.
362 */
363 private function fix_var_texturize( $text ) {
364 if ( empty( $text ) || strpos( $text, 'var(' ) === false ) {
365 return $text;
366 }
367
368 // Replace en-dash/em-dash with hyphens only inside var().
369 return preg_replace_callback(
370 '/var\s*\(([^)]+)\)/',
371 function ( $matches ) {
372 $inside = str_replace(
373 [ '', '' ], // En-dash, Em-dash (Unicode).
374 [ '--', '---' ], // Double, Triple hyphen.
375 $matches[1]
376 );
377 return 'var(' . $inside . ')';
378 },
379 $text
380 );
381 }
382
383 /**
384 * Render inline preset.
385 *
386 * Displays text with a copy icon. Best for coupon codes, promo codes, etc.
387 *
388 * Supports:
389 * - tag="a" : Uses native anchor tag with theme styling (no custom CSS overrides).
390 * - show-icon="no" : Hides the copy icon.
391 *
392 * @param array $atts Shortcode attributes.
393 * @return string HTML output.
394 */
395 private function render_inline_preset( $atts ) {
396 $id = ! empty( $atts['id'] ) ? $atts['id'] : 'ctc-inline-' . wp_generate_password( 8, false, false );
397 $hidden_class = 'yes' === $atts['hidden'] ? 'ctc-inline-hidden' : '';
398 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
399
400 // Determine if using native anchor tag (inherits theme styling).
401 $use_native_tag = 'a' === strtolower( $atts['tag'] );
402
403 // Determine if icon should be shown (default: yes, unless show-icon="no").
404 $show_icon = ! in_array( strtolower( $atts['show-icon'] ), [ 'no', 'false', '0' ], true );
405
406 // Build CSS class based on tag type.
407 if ( $use_native_tag ) {
408 // Native mode: Use theme's anchor styling, minimal CTC styling.
409 $this->used_presets['native'] = true;
410 $css_class = 'ctc-shortcode ctc-shortcode--native' . $custom_class;
411 } else {
412 // Modern mode: Full CTC styling.
413 $css_class = 'ctc-shortcode ctc-shortcode--inline' . $custom_class;
414 }
415
416 // Build inline styles (only for non-native mode).
417 $inline_style = '';
418 if ( ! $use_native_tag && ! empty( $atts['color'] ) ) {
419 $inline_style .= '--ctc-inline-color: ' . esc_attr( $atts['color'] ) . ';';
420 }
421
422 // Determine HTML tag.
423 $tag = $use_native_tag ? 'a' : 'span';
424
425 // Copy as: prefer copy_as.
426 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
427
428 // Build icon HTML.
429 $icon_html = '';
430 if ( $show_icon ) {
431 $icon_key = ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard';
432 $icon_html = '<span class="ctc-shortcode__icon" aria-hidden="true">' .
433 wp_kses( $this->get_icon_svg( $icon_key ), $this->get_allowed_svg_tags() ) .
434 '</span>';
435 }
436
437 ob_start();
438 ?>
439 <<?php echo esc_attr( $tag ); ?>
440 id="<?php echo esc_attr( $id ); ?>"
441 class="<?php echo esc_attr( $css_class ); ?>"
442 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
443 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
444 data-ctc-success="<?php echo esc_attr( $atts['success-text'] ); ?>"
445 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
446 <?php if ( ! empty( $copy_as_value ) ) : ?>
447 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
448 <?php endif; ?>
449 <?php if ( ! empty( $atts['target'] ) ) : ?>
450 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
451 <?php endif; ?>
452 <?php if ( $inline_style ) : ?>
453 style="<?php echo esc_attr( $inline_style ); ?>"
454 <?php endif; ?>
455 <?php if ( $use_native_tag ) : ?>
456 href="javascript:void(0);"
457 title="<?php echo esc_attr( $atts['tooltip'] ); ?>"
458 <?php else : ?>
459 role="button"
460 tabindex="0"
461 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
462 <?php endif; ?>
463 >
464 <span class="ctc-shortcode__text <?php echo esc_attr( $hidden_class ); ?>"><?php echo $this->get_display_html( $atts['display'] ); ?></span>
465 <?php echo $icon_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
466 <span class="ctc-shortcode__success" aria-live="polite"></span>
467 </<?php echo esc_attr( $tag ); ?>>
468 <?php
469 return ob_get_clean();
470 }
471
472 /**
473 * Render button preset.
474 *
475 * Uses Button style class for consistent styling with Global Injector.
476 *
477 * @param array $atts Shortcode attributes.
478 * @return string HTML output.
479 */
480 private function render_button_preset( $atts ) {
481 $id = ! empty( $atts['id'] ) ? $atts['id'] : Button::generate_button_id();
482 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
483
484 // Build config for Button style.
485 $config = [
486 'text' => [
487 'button_text' => $atts['button-text'],
488 'success_text' => $atts['success-text'],
489 ],
490 'icon' => [
491 'enabled' => 'no' !== $atts['show-icon'],
492 'position' => ! empty( $atts['icon-position'] ) ? $atts['icon-position'] : 'left',
493 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
494 ],
495 'style' => [],
496 ];
497
498 // Add custom colors if provided.
499 if ( ! empty( $atts['color'] ) ) {
500 $config['style']['text_color'] = $atts['color'];
501 }
502 if ( ! empty( $atts['bg'] ) ) {
503 $config['style']['background_color'] = $atts['bg'];
504 }
505 if ( ! empty( $atts['hover-bg'] ) ) {
506 $config['style']['hover_background_color'] = $atts['hover-bg'];
507 }
508
509 // Merge with defaults.
510 $config = Button::merge_config( $config );
511
512 // Build inline styles.
513 $inline_styles = Button::build_inline_styles( $config['style'] );
514
515 // Get icon SVG.
516 $icon_svg = '';
517 if ( $config['icon']['enabled'] ) {
518 $icon_svg = '<span class="ctc-shortcode__icon">' . Button::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
519 }
520
521 // Build content based on icon position.
522 $button_content = '';
523 if ( 'left' === $config['icon']['position'] && $icon_svg ) {
524 $button_content .= $icon_svg;
525 }
526 $button_content .= '<span class="ctc-shortcode__text">' . esc_html( $config['text']['button_text'] ) . '</span>';
527 if ( 'right' === $config['icon']['position'] && $icon_svg ) {
528 $button_content .= $icon_svg;
529 }
530
531 // Copy as: prefer copy_as.
532 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
533
534 ob_start();
535 ?>
536 <button
537 type="button"
538 id="<?php echo esc_attr( $id ); ?>"
539 class="ctc-shortcode ctc-shortcode--button<?php echo esc_attr( $custom_class ); ?>"
540 style="<?php echo esc_attr( $inline_styles ); ?>"
541 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
542 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
543 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
544 data-ctc-original="<?php echo esc_attr( $config['text']['button_text'] ); ?>"
545 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
546 <?php if ( ! empty( $copy_as_value ) ) : ?>
547 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
548 <?php endif; ?>
549 <?php if ( ! empty( $atts['target'] ) ) : ?>
550 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
551 <?php endif; ?>
552 <?php if ( ! empty( $atts['link'] ) ) : ?>
553 data-ctc-link="<?php echo esc_attr( $atts['link'] ); ?>"
554 <?php endif; ?>
555 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
556 >
557 <?php echo $button_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
558 </button>
559 <?php
560 return ob_get_clean();
561 }
562
563 /**
564 * Render icon preset.
565 *
566 * Uses Icon style class for consistent styling with Global Injector.
567 *
568 * @param array $atts Shortcode attributes.
569 * @return string HTML output.
570 */
571 private function render_icon_preset( $atts ) {
572 $id = ! empty( $atts['id'] ) ? $atts['id'] : Icon::generate_icon_id();
573 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
574
575 // Build config for Icon style.
576 $config = [
577 'text' => [
578 'tooltip_text' => $atts['tooltip'],
579 'success_text' => $atts['success-text'],
580 ],
581 'icon' => [
582 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
583 ],
584 'style' => [],
585 ];
586
587 // Add custom colors if provided.
588 if ( ! empty( $atts['icon-color'] ) ) {
589 $config['style']['icon_color'] = $atts['icon-color'];
590 }
591 if ( ! empty( $atts['bg'] ) ) {
592 $config['style']['bg_color'] = $atts['bg'];
593 }
594
595 // Merge with defaults.
596 $config = Icon::merge_config( $config );
597
598 // Build inline styles.
599 $inline_styles = Icon::build_inline_styles( $config['style'] );
600
601 // Get icon SVG.
602 $icon_svg = '<span class="ctc-shortcode__icon">' . Icon::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
603
604 // Copy as: prefer copy_as.
605 $copy_as_value = ! empty( $atts['copy_as'] ) ? $atts['copy_as'] : '';
606
607 ob_start();
608 ?>
609 <button
610 type="button"
611 id="<?php echo esc_attr( $id ); ?>"
612 class="ctc-shortcode ctc-shortcode--icon<?php echo esc_attr( $custom_class ); ?>"
613 style="<?php echo esc_attr( $inline_styles ); ?>"
614 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
615 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
616 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
617 data-ctc-tooltip="<?php echo esc_attr( $config['text']['tooltip_text'] ); ?>"
618 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
619 <?php if ( ! empty( $copy_as_value ) ) : ?>
620 data-ctc-copy-as="<?php echo esc_attr( $copy_as_value ); ?>"
621 <?php endif; ?>
622 <?php if ( ! empty( $atts['target'] ) ) : ?>
623 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
624 <?php endif; ?>
625 aria-label="<?php echo esc_attr( $config['text']['tooltip_text'] ); ?>"
626 >
627 <?php echo $icon_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
628 </button>
629 <?php
630 return ob_get_clean();
631 }
632
633 /**
634 * Render cover preset.
635 *
636 * Wraps content with hover overlay and copy button.
637 *
638 * @param array $atts Shortcode attributes.
639 * @return string HTML output.
640 */
641 private function render_cover_preset( $atts ) {
642 $id = ! empty( $atts['id'] ) ? $atts['id'] : Cover::generate_cover_id();
643 $custom_class = ! empty( $atts['class'] ) ? ' ' . esc_attr( $atts['class'] ) : '';
644
645 // Build config for Cover style.
646 $config = [
647 'text' => [
648 'button_text' => $atts['button-text'],
649 'success_text' => $atts['success-text'],
650 ],
651 'icon' => [
652 'enabled' => 'no' !== $atts['show-icon'],
653 'position' => ! empty( $atts['icon-position'] ) ? $atts['icon-position'] : 'left',
654 'icon_key' => ! empty( $atts['icon'] ) ? $atts['icon'] : 'clipboard',
655 ],
656 'button' => [],
657 ];
658
659 // Add custom colors if provided.
660 if ( ! empty( $atts['color'] ) ) {
661 $config['button']['text_color'] = $atts['color'];
662 }
663 if ( ! empty( $atts['bg'] ) ) {
664 $config['button']['bg_color'] = $atts['bg'];
665 }
666 if ( ! empty( $atts['hover-bg'] ) ) {
667 $config['button']['hover_bg_color'] = $atts['hover-bg'];
668 }
669
670 // Merge with defaults.
671 $config = Cover::merge_config( $config );
672
673 // Get icon SVG.
674 $icon_svg = '';
675 if ( $config['icon']['enabled'] ) {
676 $icon_svg = '<span class="ctc-cover-icon">' . Cover::get_icon_svg( $config['icon']['icon_key'] ) . '</span>';
677 }
678
679 // Build button content.
680 $button_content = '';
681 if ( 'left' === $config['icon']['position'] && $icon_svg ) {
682 $button_content .= $icon_svg;
683 }
684 $button_content .= '<span class="ctc-cover-text">' . esc_html( $config['text']['button_text'] ) . '</span>';
685 if ( 'right' === $config['icon']['position'] && $icon_svg ) {
686 $button_content .= $icon_svg;
687 }
688
689 ob_start();
690 ?>
691 <div
692 id="<?php echo esc_attr( $id ); ?>"
693 class="ctc-shortcode ctc-shortcode--cover<?php echo esc_attr( $custom_class ); ?>"
694 data-ctc-format="<?php echo esc_attr( $atts['copy-as'] ); ?>"
695 >
696 <div class="ctc-shortcode__content">
697 <?php echo wp_kses_post( $atts['display'] ); ?>
698 </div>
699 <div
700 class="ctc-cover-overlay"
701 data-ctc-analytics="<?php echo $atts['analytics_enabled'] ? '1' : '0'; ?>"
702 data-ctc-copy="<?php echo esc_attr( $atts['text'] ); ?>"
703 data-ctc-success="<?php echo esc_attr( $config['text']['success_text'] ); ?>"
704 <?php if ( ! empty( $atts['target'] ) ) : ?>
705 data-ctc-target="<?php echo esc_attr( $atts['target'] ); ?>"
706 <?php endif; ?>
707 role="button"
708 tabindex="0"
709 aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>"
710 >
711 <button type="button" class="ctc-cover-button" aria-label="<?php echo esc_attr( $atts['tooltip'] ); ?>">
712 <?php echo $button_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
713 </button>
714 </div>
715 </div>
716 <?php
717 return ob_get_clean();
718 }
719
720 /**
721 * Get icon SVG by key.
722 *
723 * @param string $icon_key Icon key.
724 * @return string SVG markup.
725 */
726 private function get_icon_svg( $icon_key ) {
727 $icons = [
728 '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>',
729 '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>',
730 '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>',
731 '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>',
732 ];
733
734 return isset( $icons[ $icon_key ] ) ? $icons[ $icon_key ] : $icons['clipboard'];
735 }
736
737 /**
738 * Get success icon SVG.
739 *
740 * @return string SVG markup.
741 */
742 private function get_success_icon_svg() {
743 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>';
744 }
745
746 /**
747 * Get allowed SVG tags for wp_kses.
748 *
749 * @return array Allowed HTML tags and attributes.
750 */
751 private function get_allowed_svg_tags() {
752 return [
753 'svg' => [
754 'xmlns' => true,
755 'fill' => true,
756 'viewbox' => true,
757 'stroke' => true,
758 'width' => true,
759 'height' => true,
760 'class' => true,
761 'aria-hidden' => true,
762 ],
763 'path' => [
764 'd' => true,
765 'stroke-linecap' => true,
766 'stroke-linejoin' => true,
767 'stroke-width' => true,
768 'fill' => true,
769 ],
770 ];
771 }
772
773 /**
774 * Whether the current page may contain [copy] output (direct shortcode or via container shortcodes like tables).
775 * Used so we enqueue assets when [copy] appears inside Supsystic/wpDataTables etc., including when table HTML is cached.
776 *
777 * @return bool
778 */
779 private function page_may_contain_copy_output() {
780 if ( $this->styles_enqueued ) {
781 return true;
782 }
783 $post = get_post();
784 if ( ! $post || ! is_singular() || empty( $post->post_content ) ) {
785 return false;
786 }
787 // Built-in list is empty; third-party integrations add their shortcodes via filter when plugins are active.
788 $container_shortcodes = [];
789 /**
790 * Shortcodes that may output `[copy]` in their rendered content (e.g. table cell data).
791 * When present in post content, we enqueue copy assets so buttons work even if that content is cached.
792 * Third-party classes (e.g. CTC\ThirdParty\Supsystic_Tables) add to this when their plugin is active.
793 *
794 * @param string[] $container_shortcodes List of shortcode names.
795 */
796 $container_shortcodes = apply_filters( 'ctc/shortcode/container_shortcodes', $container_shortcodes );
797 foreach ( $container_shortcodes as $tag ) {
798 if ( has_shortcode( $post->post_content, $tag ) ) {
799 return true;
800 }
801 }
802 return false;
803 }
804
805 /**
806 * Maybe enqueue assets if shortcode was used or page may contain copy output (e.g. inside table plugins).
807 *
808 * @return void
809 */
810 public function maybe_enqueue_assets() {
811 if ( ! $this->page_may_contain_copy_output() ) {
812 return;
813 }
814
815 wp_enqueue_script(
816 'ctc-lib-core',
817 CTC_URI . 'assets/frontend/js/lib/ctc.js',
818 [],
819 CTC_VER,
820 true
821 );
822
823 wp_enqueue_script(
824 'ctc-shortcode',
825 CTC_URI . 'assets/frontend/js/shortcode.js',
826 [ 'ctc-lib-core' ],
827 CTC_VER,
828 true
829 );
830
831 // Localize shortcode frontend context for analytics tracking.
832 $localize_data = [
833 'eventsUrl' => rest_url( 'ctc/v1/analytics/events' ),
834 'postId' => get_the_ID() ? get_the_ID() : null,
835 'postType' => get_post_type() ? get_post_type() : null,
836 'pageUrl' => $this->get_current_page_url(),
837 ];
838 /**
839 * Filter shortcode frontend localize data.
840 *
841 * Pro can use this to customize eventsUrl and add extra telemetry context.
842 *
843 * @since 5.4.0
844 * @param array $data Localized data array.
845 */
846 $localize_data = apply_filters( 'ctc/shortcode/localize_data', $localize_data );
847
848 wp_localize_script(
849 'ctc-shortcode',
850 'ctcShortcode',
851 $localize_data
852 );
853
854 // When enqueueing for container shortcodes only (e.g. table cached), include all preset CSS.
855 if ( ! $this->styles_enqueued ) {
856 $this->used_presets = array_fill_keys( [ 'inline', 'button', 'icon', 'cover', 'native' ], true );
857 }
858
859 // Enqueue inline CSS (only for presets used on the page, minified).
860 wp_register_style( 'ctc-shortcode', false, [], CTC_VER );
861 wp_enqueue_style( 'ctc-shortcode' );
862 wp_add_inline_style( 'ctc-shortcode', $this->get_inline_css() );
863 }
864
865 /**
866 * Get the current page URL.
867 *
868 * @since 5.4.0
869 * @return string Current page URL.
870 */
871 private function get_current_page_url() {
872 if ( ! empty( $_SERVER['HTTP_HOST'] ) && ! empty( $_SERVER['REQUEST_URI'] ) ) {
873 $scheme = is_ssl() ? 'https://' : 'http://';
874 return esc_url_raw(
875 $scheme .
876 sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) .
877 sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
878 );
879 }
880
881 // Fallback to home URL if server vars are unavailable.
882 return esc_url_raw( home_url( '/' ) );
883 }
884
885 /**
886 * Minify CSS: strip comments and collapse whitespace.
887 *
888 * @param string $css Raw CSS.
889 * @return string Minified CSS.
890 */
891 private function minify_css( $css ) {
892 $css = (string) preg_replace( '/\/\*[\s\S]*?\*\//', '', $css );
893 $css = (string) preg_replace( '/\s+/', ' ', $css );
894 return trim( $css );
895 }
896
897 /**
898 * Get inline CSS for shortcode styles.
899 * Builds from base + only the preset chunks that were used, then minifies.
900 *
901 * @return string CSS styles.
902 */
903 private function get_inline_css() {
904 $parts = [ $this->get_css_base() ];
905 $presets = array_keys( $this->used_presets );
906 $chunk_map = [
907 'inline' => 'get_css_inline',
908 'native' => 'get_css_native',
909 'button' => 'get_css_button',
910 'icon' => 'get_css_icon',
911 'cover' => 'get_css_cover',
912 ];
913 foreach ( $presets as $preset ) {
914 if ( isset( $chunk_map[ $preset ] ) ) {
915 $parts[] = $this->{$chunk_map[ $preset ]}();
916 }
917 }
918 return $this->minify_css( implode( "\n", $parts ) );
919 }
920
921 /**
922 * Base + shared CSS (vars, icon, success, table, keyframes). Always included when shortcode is used.
923 *
924 * @return string
925 */
926 private function get_css_base() {
927 return '
928 .ctc-shortcode {
929 --ctc-inline-color: currentColor;
930 --ctc-inline-hover-color: currentColor;
931 }
932 .ctc-shortcode__icon {
933 display: inline-flex;
934 align-items: center;
935 justify-content: center;
936 }
937 .ctc-shortcode__icon svg {
938 width: 14px;
939 height: 14px;
940 }
941 table .ctc-shortcode__icon,
942 table .ctc-shortcode__icon svg,
943 table .ctc-cover-icon,
944 table .ctc-cover-icon svg {
945 width: 14px !important;
946 height: 14px !important;
947 min-width: 14px;
948 min-height: 14px;
949 max-width: 14px !important;
950 max-height: 14px !important;
951 flex-shrink: 0;
952 }
953 table .ctc-cover-icon svg {
954 width: 12px !important;
955 height: 12px !important;
956 max-width: 12px !important;
957 max-height: 12px !important;
958 }
959 .ctc-shortcode__success {
960 position: absolute;
961 pointer-events: none;
962 }
963 .ctc-shortcode--copied {
964 animation: ctc-shortcode-pulse 0.3s ease;
965 }
966 @keyframes ctc-shortcode-pulse {
967 0%, 100% { transform: scale(1); }
968 50% { transform: scale(1.05); }
969 }
970 ';
971 }
972
973 /**
974 * Inline preset CSS.
975 *
976 * @return string
977 */
978 private function get_css_inline() {
979 return '
980 .ctc-shortcode--inline {
981 display: inline-flex;
982 align-items: center;
983 gap: 4px;
984 cursor: pointer;
985 color: var(--ctc-inline-color);
986 transition: opacity 0.15s ease;
987 }
988 .ctc-shortcode--inline:hover {
989 opacity: 0.8;
990 }
991 .ctc-shortcode--inline:focus {
992 outline: 2px solid currentColor;
993 outline-offset: 2px;
994 }
995 .ctc-shortcode--inline .ctc-inline-hidden {
996 display: none;
997 }
998 ';
999 }
1000
1001 /**
1002 * Native preset CSS.
1003 *
1004 * @return string
1005 */
1006 private function get_css_native() {
1007 return '
1008 .ctc-shortcode--native {
1009 display: inline-flex;
1010 align-items: center;
1011 gap: 4px;
1012 cursor: pointer;
1013 }
1014 .ctc-shortcode--native .ctc-shortcode__icon {
1015 opacity: 0.7;
1016 transition: opacity 0.15s ease;
1017 }
1018 .ctc-shortcode--native:hover .ctc-shortcode__icon {
1019 opacity: 1;
1020 }
1021 ';
1022 }
1023
1024 /**
1025 * Button preset CSS.
1026 *
1027 * @return string
1028 */
1029 private function get_css_button() {
1030 return '
1031 .ctc-shortcode--button {
1032 display: inline-flex;
1033 align-items: center;
1034 gap: 6px;
1035 border: none;
1036 cursor: pointer;
1037 font-family: inherit;
1038 line-height: 1.4;
1039 transition: all 0.15s ease;
1040 color: var(--ctc-text-color, #ffffff);
1041 background: var(--ctc-bg, #4f46e5);
1042 font-size: var(--ctc-font-size, 13px);
1043 font-weight: var(--ctc-font-weight, 600);
1044 padding: var(--ctc-padding-y, 8px) var(--ctc-padding-x, 16px);
1045 border-radius: var(--ctc-border-radius, 6px);
1046 }
1047 .ctc-shortcode--button:hover {
1048 background: var(--ctc-hover-bg, #4338ca);
1049 }
1050 .ctc-shortcode--button:focus {
1051 outline: 2px solid var(--ctc-bg, #4f46e5);
1052 outline-offset: 2px;
1053 }
1054 .ctc-shortcode--button:active {
1055 transform: scale(0.97);
1056 }
1057 .ctc-shortcode--button .ctc-shortcode__icon svg {
1058 width: 14px;
1059 height: 14px;
1060 }
1061 ';
1062 }
1063
1064 /**
1065 * Icon preset CSS.
1066 *
1067 * @return string
1068 */
1069 private function get_css_icon() {
1070 return '
1071 .ctc-shortcode--icon {
1072 display: inline-flex;
1073 align-items: center;
1074 justify-content: center;
1075 border-style: solid;
1076 cursor: pointer;
1077 transition: all 0.2s ease;
1078 color: var(--ctc-icon-color, #6b7280);
1079 background: var(--ctc-icon-bg, transparent);
1080 border-color: var(--ctc-icon-border, #d1d5db);
1081 border-width: var(--ctc-icon-border-width, 1px);
1082 padding: var(--ctc-icon-padding, 6px);
1083 border-radius: var(--ctc-icon-radius, 6px);
1084 }
1085 .ctc-shortcode--icon:hover {
1086 color: var(--ctc-icon-hover-color, #374151);
1087 border-color: var(--ctc-icon-hover-border, #9ca3af);
1088 background: var(--ctc-icon-hover-bg, #f3f4f6);
1089 }
1090 .ctc-shortcode--icon:focus {
1091 outline: 2px solid var(--ctc-icon-border, #d1d5db);
1092 outline-offset: 2px;
1093 }
1094 .ctc-shortcode--icon .ctc-shortcode__icon svg {
1095 width: var(--ctc-icon-size, 16px);
1096 height: var(--ctc-icon-size, 16px);
1097 }
1098 ';
1099 }
1100
1101 /**
1102 * Cover preset CSS.
1103 *
1104 * @return string
1105 */
1106 private function get_css_cover() {
1107 return '
1108 .ctc-shortcode--cover {
1109 position: relative;
1110 display: block;
1111 }
1112 .ctc-shortcode--cover .ctc-shortcode__content {
1113 display: block;
1114 }
1115 .ctc-shortcode--cover .ctc-cover-overlay {
1116 position: absolute;
1117 inset: 0;
1118 z-index: 10;
1119 border-radius: inherit;
1120 transition: all 0.3s ease;
1121 cursor: pointer;
1122 opacity: 0;
1123 background: rgba(15, 23, 42, 0.2);
1124 display: flex;
1125 align-items: center;
1126 justify-content: center;
1127 }
1128 .ctc-shortcode--cover:hover .ctc-cover-overlay {
1129 opacity: 1;
1130 background: rgba(79, 70, 229, 0.3);
1131 backdrop-filter: blur(2px);
1132 -webkit-backdrop-filter: blur(2px);
1133 }
1134 .ctc-shortcode--cover .ctc-cover-button {
1135 transform: scale(0.9);
1136 opacity: 0;
1137 transition: all 0.2s ease;
1138 color: #0f172a;
1139 background: rgba(255, 255, 255, 0.95);
1140 font-size: 10px;
1141 font-weight: 700;
1142 padding: 6px 12px;
1143 border-radius: 9999px;
1144 display: inline-flex;
1145 align-items: center;
1146 gap: 6px;
1147 border: 1px solid rgba(255, 255, 255, 0.5);
1148 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1149 white-space: nowrap;
1150 cursor: pointer;
1151 }
1152 .ctc-shortcode--cover:hover .ctc-cover-button {
1153 opacity: 1;
1154 transform: scale(1);
1155 }
1156 .ctc-shortcode--cover .ctc-cover-button:hover {
1157 background: #f1f5f9;
1158 transform: scale(1.05);
1159 }
1160 .ctc-shortcode--cover .ctc-cover-icon svg {
1161 width: 12px;
1162 height: 12px;
1163 }
1164 ';
1165 }
1166 }
1167