>}> */ private static $collect = array(); /** * Tab groups rendered in this request, for unique IDs. * * @var int */ private static $tab_groups = 0; /** * Tooltips rendered in this request, for unique IDs. * * @var int */ private static $tooltips = 0; /** * QR codes rendered in this request, for unique IDs. * * @var int */ private static $qr_codes = 0; /** * Table of contents requests of the current content, by placeholder number. * * @var array> */ private static $tocs = array(); /** * Marker on headings inside collapsible parts (tab panels, accordion entries, spoilers): a jump * there would land in a closed area, so the table of contents leaves them out. */ const TOC_SKIP = ' data-threece-toc="skip"'; /** * Notice icons, Tabler Icons (MIT, see credits.txt), inline so nothing loads from elsewhere. */ const NOTICE_ICONS = array( 'info' => '', 'tip' => '', 'warning' => '', 'success' => '', ); /** * Deepest supported nesting of the same element. */ const MAX_DEPTH = 4; /** * Hooks. */ public static function init(): void { add_action( 'init', array( __CLASS__, 'register' ) ); // Before wpautop (10) and do_shortcode (11), so both see the renamed inner shortcodes. add_filter( 'the_content', array( __CLASS__, 'number_nested' ), 9 ); add_filter( 'widget_text_content', array( __CLASS__, 'number_nested' ), 9 ); // After do_shortcode (11): the headings are final, the placeholders of [threece_toc] are in place. add_filter( 'the_content', array( __CLASS__, 'build_tocs' ), 12 ); // After do_shortcode (11): buttons alone in their line get a paragraph back (see wrap_buttons()). add_filter( 'the_content', array( __CLASS__, 'wrap_buttons' ), 12 ); add_filter( 'widget_text_content', array( __CLASS__, 'wrap_buttons' ), 12 ); // Automatic excerpts: WordPress strips every shortcode with its text; the containers keep theirs. add_filter( 'strip_shortcodes_tagnames', array( __CLASS__, 'keep_text_in_excerpts' ) ); } /** * Elements whose text belongs to the post (tabs, accordion, spoiler, notice, columns, tooltip, button). Without * this, wp_trim_excerpt() drops a whole FAQ written in an accordion. Elements without reading text (footnote, * table of contents, video, QR code, countdown) stay out of the excerpt. */ const EXCERPT_KEEP = array( 'threece_tabs', 'threece_tab', 'threece_accordion', 'threece_item', 'threece_spoiler', 'threece_notice', 'threece_columns', 'threece_column', 'threece_tooltip', 'threece_button' ); /** * Whether WordPress is building an automatic excerpt right now (wp_trim_excerpt() runs the_content inside * get_the_excerpt). The elements then render as plain reading order: a title once, then the text. */ public static function in_excerpt(): bool { return doing_filter( 'get_the_excerpt' ); } /** * Filter for strip_shortcodes_tagnames: takes the containers (all nesting aliases) off the list to remove. * * @param mixed $tags Shortcode names WordPress is about to strip. * @return mixed */ public static function keep_text_in_excerpts( $tags ) { if ( ! is_array( $tags ) ) { return $tags; } $keep = array(); foreach ( self::EXCERPT_KEEP as $name ) { for ( $level = 1; $level <= self::MAX_DEPTH; $level++ ) { $keep[] = self::alias( $name, $level ); } } return array_values( array_diff( $tags, $keep ) ); } /** * A button shortcode alone in its line loses its paragraph to shortcode_unautop() and would stand as a bare * link between the blocks. Block themes align only blocks with the content column, so it slipped to the edge * (Twenty Twenty-Five). Lines that hold nothing but buttons get a paragraph; buttons inside a sentence stay. * * @param mixed $content Rendered content. * @return mixed */ public static function wrap_buttons( $content ) { if ( ! is_string( $content ) || false === strpos( $content, 'threece-button' ) ) { return $content; } // One line, nothing but buttons (links, or spans without address) and spaces. Labels are escaped text, no tags. return (string) preg_replace_callback( '#^[ \t]*((?:<(a|span) class="threece-button[^"]*"[^>]*>[^<]*[ \t]*)+)$#m', function ( $m ) { return '

' . trim( $m[1] ) . '

'; }, $content ); } /** * The WordPress shortcode parser cannot nest a shortcode in itself ([threece_tabs] inside * [threece_tabs] closes at the first [/threece_tabs]). Inner levels are therefore renamed to * registered aliases (threece_tabs__2 …) before parsing, so authors can nest naturally. * * @param mixed $content Post content from the filter. * @return mixed */ public static function number_nested( $content ) { if ( ! is_string( $content ) || false === strpos( $content, '[threece_' ) ) { return $content; } $depth = array(); $names = implode( '|', array_map( 'preg_quote', array_keys( self::definitions() ) ) ); return (string) preg_replace_callback( '#\[(/?)(' . $names . ')(?=[\s\]/])([^\]]*)\]#', static function ( $m ) use ( &$depth ) { $name = $m[2]; $current = $depth[ $name ] ?? 0; if ( '/' === $m[1] ) { $depth[ $name ] = max( 0, $current - 1 ); return '[/' . self::alias( $name, $current ) . ']'; } if ( '/' === substr( rtrim( $m[3] ), -1 ) ) { return '[' . self::alias( $name, $current + 1 ) . $m[3] . ']'; } $depth[ $name ] = $current + 1; return '[' . self::alias( $name, $current + 1 ) . $m[3] . ']'; }, $content ); } /** * Shortcode name for a nesting level: level 1 keeps the name, deeper levels get __2 … __4. * * @param string $name Shortcode name. * @param int $level Nesting level, 1 for the outermost. * @return string */ private static function alias( $name, $level ) { $level = min( max( 1, $level ), self::MAX_DEPTH ); return 1 === $level ? $name : $name . '__' . $level; } /** * Every element in one place: label, callback, fields with type and default, parent and child. * Defaults, attribute cleaning and the inserter dialog are all derived from here. * * Field types: text (empty falls back to the default), bool (yes, 1, true, on), select (one of * "values", else the default), number (whole number between "min" and "max", else the default). * * @return array> */ public static function definitions() { $open = array( 'type' => 'bool', 'label' => __( 'Open at first', '3task-toolbar-for-classic-editor' ), 'default' => false, ); $definitions = array( 'threece_tabs' => array( 'label' => __( 'Tabs', '3task-toolbar-for-classic-editor' ), 'callback' => 'tabs', 'fields' => array( 'active' => array( 'type' => 'number', 'label' => __( 'Tab open at first (number)', '3task-toolbar-for-classic-editor' ), 'default' => 1, 'min' => 1, 'max' => 50, ), 'mobile' => array( 'type' => 'select', 'label' => __( 'On phones', '3task-toolbar-for-classic-editor' ), 'help' => __( 'How the tabs behave on narrow screens.', '3task-toolbar-for-classic-editor' ), 'default' => 'wrap', 'values' => array( 'wrap' => __( 'Tabs wrap to new lines', '3task-toolbar-for-classic-editor' ), 'scroll' => __( 'Tabs scroll sideways', '3task-toolbar-for-classic-editor' ), 'stack' => __( 'All contents one below the other', '3task-toolbar-for-classic-editor' ), ), ), 'anchor_in_url' => array( 'type' => 'bool', 'label' => __( 'Show the open tab in the address', '3task-toolbar-for-classic-editor' ), 'default' => false, ), ), 'child' => 'threece_tab', ), 'threece_tab' => array( 'label' => __( 'Tab', '3task-toolbar-for-classic-editor' ), 'callback' => 'tab', 'fields' => array( 'title' => self::title_field( __( 'Tab', '3task-toolbar-for-classic-editor' ) ), 'anchor' => array( 'type' => 'text', 'label' => __( 'Link name (opens this tab via #name)', '3task-toolbar-for-classic-editor' ), 'default' => '', ), ), 'content' => true, 'parent' => 'threece_tabs', ), 'threece_accordion' => array( 'label' => __( 'Accordion', '3task-toolbar-for-classic-editor' ), 'callback' => 'accordion', 'fields' => array(), 'child' => 'threece_item', ), 'threece_item' => array( 'label' => __( 'Entry', '3task-toolbar-for-classic-editor' ), 'callback' => 'item', 'fields' => array( 'title' => self::title_field( __( 'Show more', '3task-toolbar-for-classic-editor' ) ), 'open' => $open, ), 'content' => true, 'parent' => 'threece_accordion', ), 'threece_spoiler' => array( 'label' => __( 'Spoiler', '3task-toolbar-for-classic-editor' ), 'callback' => 'spoiler', 'fields' => array( 'title' => self::title_field( __( 'Show more', '3task-toolbar-for-classic-editor' ) ), 'open' => $open, ), 'content' => true, ), 'threece_notice' => array( 'label' => __( 'Notice box', '3task-toolbar-for-classic-editor' ), 'callback' => 'notice', 'fields' => array( 'type' => array( 'type' => 'select', 'label' => __( 'Kind', '3task-toolbar-for-classic-editor' ), 'default' => 'info', 'values' => array( 'info' => __( 'Info', '3task-toolbar-for-classic-editor' ), 'tip' => __( 'Tip', '3task-toolbar-for-classic-editor' ), 'warning' => __( 'Warning', '3task-toolbar-for-classic-editor' ), 'success' => __( 'Success', '3task-toolbar-for-classic-editor' ), ), ), 'title' => array( 'type' => 'text', 'label' => __( 'Title (optional)', '3task-toolbar-for-classic-editor' ), 'default' => '', ), ), 'content' => true, ), 'threece_button' => array( 'label' => __( 'Button', '3task-toolbar-for-classic-editor' ), 'callback' => 'button', 'fields' => array( 'url' => array( 'type' => 'url', 'label' => __( 'Link (https://, mailto:, tel:, /page/ or #anchor)', '3task-toolbar-for-classic-editor' ), 'default' => '', ), 'style' => array( 'type' => 'select', 'label' => __( 'Style', '3task-toolbar-for-classic-editor' ), 'default' => 'primary', 'values' => array( 'primary' => __( 'Primary', '3task-toolbar-for-classic-editor' ), 'secondary' => __( 'Secondary', '3task-toolbar-for-classic-editor' ), 'outline' => __( 'Outline', '3task-toolbar-for-classic-editor' ), ), ), 'new_tab' => array( 'type' => 'bool', 'label' => __( 'Open in a new tab', '3task-toolbar-for-classic-editor' ), 'default' => false, ), 'nofollow' => array( 'type' => 'bool', 'label' => __( 'Partner or paid link (nofollow sponsored)', '3task-toolbar-for-classic-editor' ), 'help' => __( 'For affiliate and paid links, as search engines ask for it.', '3task-toolbar-for-classic-editor' ), 'default' => false, ), 'download' => array( 'type' => 'bool', 'label' => __( 'Download file', '3task-toolbar-for-classic-editor' ), 'default' => false, ), ), 'content' => true, ), 'threece_columns' => array( 'label' => __( 'Columns', '3task-toolbar-for-classic-editor' ), 'callback' => 'columns', 'fields' => array(), 'child' => 'threece_column', ), 'threece_column' => array( 'label' => __( 'Column', '3task-toolbar-for-classic-editor' ), 'callback' => 'column', 'fields' => array( 'span' => array( 'type' => 'number', 'label' => __( 'Width (1 to 3 parts)', '3task-toolbar-for-classic-editor' ), 'default' => 1, 'min' => 1, 'max' => 3, ), ), 'content' => true, 'parent' => 'threece_columns', ), 'threece_toc' => array( 'label' => __( 'Table of contents', '3task-toolbar-for-classic-editor' ), 'callback' => 'toc', 'fields' => array( 'title' => array( 'type' => 'text', 'label' => __( 'Title', '3task-toolbar-for-classic-editor' ), 'default' => __( 'Contents', '3task-toolbar-for-classic-editor' ), ), 'from' => array( 'type' => 'number', 'label' => __( 'From heading level', '3task-toolbar-for-classic-editor' ), 'help' => __( '2 starts with the H2 headings, the usual first level inside a post.', '3task-toolbar-for-classic-editor' ), 'default' => 2, 'min' => 2, 'max' => 6, ), 'to' => array( 'type' => 'number', 'label' => __( 'To heading level', '3task-toolbar-for-classic-editor' ), 'default' => 3, 'min' => 2, 'max' => 6, ), 'open' => array( 'type' => 'bool', 'label' => __( 'Open at first', '3task-toolbar-for-classic-editor' ), 'default' => true, ), 'numbered' => array( 'type' => 'bool', 'label' => __( 'Numbered', '3task-toolbar-for-classic-editor' ), 'default' => false, ), ), ), 'threece_video' => array( 'label' => __( 'Video (YouTube, Vimeo)', '3task-toolbar-for-classic-editor' ), 'callback' => 'video', 'fields' => array( 'url' => array( 'type' => 'url', 'label' => __( 'Video address', '3task-toolbar-for-classic-editor' ), 'help' => __( 'YouTube or Vimeo. Nothing loads from there before the click.', '3task-toolbar-for-classic-editor' ), 'default' => '', ), 'title' => array( 'type' => 'text', 'label' => __( 'Title', '3task-toolbar-for-classic-editor' ), 'help' => __( 'Shown on the preview image and read out by screen readers.', '3task-toolbar-for-classic-editor' ), 'default' => '', ), 'image' => array( 'type' => 'number', 'label' => __( 'Preview image (media ID, optional)', '3task-toolbar-for-classic-editor' ), 'default' => 0, 'min' => 0, 'max' => 2147483647, // With the right to upload, the dialog picks the image in the media library instead. 'media' => array( 'label' => __( 'Preview image (optional)', '3task-toolbar-for-classic-editor' ), 'help' => __( 'Shown before the click instead of a plain surface. It comes from your own media library.', '3task-toolbar-for-classic-editor' ), ), ), 'start' => array( 'type' => 'number', 'label' => __( 'Start at second', '3task-toolbar-for-classic-editor' ), 'default' => 0, 'min' => 0, 'max' => 86400, ), ), ), 'threece_countdown' => array( 'label' => __( 'Countdown', '3task-toolbar-for-classic-editor' ), 'callback' => 'countdown', 'fields' => array( 'to' => array( 'type' => 'text', 'label' => __( 'Ends at (YYYY-MM-DD HH:MM, time zone of the site)', '3task-toolbar-for-classic-editor' ), 'help' => __( 'Example: 2026-12-24 18:00. The time zone is set under Settings, General.', '3task-toolbar-for-classic-editor' ), 'default' => '', ), 'expired' => array( 'type' => 'text', 'label' => __( 'Text after the end', '3task-toolbar-for-classic-editor' ), 'default' => __( 'The countdown has ended.', '3task-toolbar-for-classic-editor' ), ), ), ), 'threece_qr' => array( 'label' => __( 'QR code', '3task-toolbar-for-classic-editor' ), 'callback' => 'qr', 'fields' => array( 'text' => array( 'type' => 'text', 'label' => __( 'Content (link or text)', '3task-toolbar-for-classic-editor' ), 'help' => __( 'A link or any text. The code is made on your server.', '3task-toolbar-for-classic-editor' ), 'default' => '', ), 'size' => array( 'type' => 'number', 'label' => __( 'Size in pixels', '3task-toolbar-for-classic-editor' ), 'default' => 200, 'min' => 64, 'max' => 1024, ), 'ecc' => array( 'type' => 'select', 'label' => __( 'Error correction', '3task-toolbar-for-classic-editor' ), 'help' => __( 'Higher values still scan when the code is small or damaged, but the code gets denser.', '3task-toolbar-for-classic-editor' ), 'default' => 'M', 'values' => array( 'L' => __( 'Low (7 %)', '3task-toolbar-for-classic-editor' ), 'M' => __( 'Medium (15 %)', '3task-toolbar-for-classic-editor' ), 'Q' => __( 'Quartile (25 %)', '3task-toolbar-for-classic-editor' ), 'H' => __( 'High (30 %)', '3task-toolbar-for-classic-editor' ), ), ), 'title' => array( 'type' => 'text', 'label' => __( 'Description for screen readers (optional)', '3task-toolbar-for-classic-editor' ), 'default' => '', ), ), ), 'threece_tooltip' => array( 'label' => __( 'Tooltip', '3task-toolbar-for-classic-editor' ), 'callback' => 'tooltip', 'fields' => array( 'text' => array( 'type' => 'text', 'label' => __( 'Explanation', '3task-toolbar-for-classic-editor' ), 'default' => '', ), ), 'content' => true, ), 'threece_fn' => array( 'label' => __( 'Footnote', '3task-toolbar-for-classic-editor' ), 'callback' => 'footnote', 'fields' => array(), 'content' => true, ), ); /** * Elements of add-ons. Each entry needs 'label', 'callback' (a method of this class or any callable), * 'fields' and, for the dialog, a 'card' with group, description, keywords and icon; 'content' => true for * an inner content, 'child' and 'parent' for containers. Attributes are cleaned by field type like our own. * * @since 1.0.2 * @param array> $definitions Elements by shortcode name. */ $definitions = apply_filters( 'threece_elements', $definitions ); // Every element accepts extra CSS classes for themes and agencies. foreach ( $definitions as $tag => $definition ) { $definitions[ $tag ]['fields']['class'] = array( 'type' => 'class', 'label' => __( 'CSS class (optional)', '3task-toolbar-for-classic-editor' ), 'default' => '', ); } return $definitions; } /** * Most extra CSS classes per element and longest class name. */ const MAX_CLASSES = 5; const MAX_CLASS_LENGTH = 50; /** * Class attribute: own base classes plus the cleaned extra classes, without duplicates. * * @param string $base Own classes, space separated. * @param string $extra Cleaned extra classes from atts(). * @return string Escaped for an HTML attribute. */ public static function classes( $base, $extra ) { $all = array_unique( array_merge( explode( ' ', $base ), '' === $extra ? array() : explode( ' ', $extra ) ) ); return esc_attr( implode( ' ', $all ) ); } /** * Title field with its fallback text. * * @param string $fallback Shown when the title is empty. * @return array */ public static function title_field( $fallback ) { return array( 'type' => 'text', 'label' => __( 'Title', '3task-toolbar-for-classic-editor' ), 'default' => $fallback, ); } /** * Attributes of a shortcode, cleaned by its definition. Unknown attributes are dropped. * * @param string $tag Shortcode name without nesting suffix. * @param array|string $raw Attributes as WordPress passes them. * @return array */ public static function atts( $tag, $raw ) { $fields = self::definitions()[ $tag ]['fields'] ?? array(); $defaults = array(); foreach ( $fields as $name => $field ) { $defaults[ $name ] = $field['default']; } $atts = shortcode_atts( $defaults, $raw, $tag ); foreach ( $fields as $name => $field ) { $atts[ $name ] = self::clean( $field, $atts[ $name ] ); } return $atts; } /** * One attribute value by field type. * * @param array $field Field definition. * @param mixed $value Raw value. * @return mixed */ private static function clean( $field, $value ) { switch ( $field['type'] ) { case 'bool': return is_bool( $value ) ? $value : self::is_yes( $value ); case 'select': return array_key_exists( (string) $value, $field['values'] ) ? (string) $value : $field['default']; case 'number': if ( ! is_numeric( $value ) ) { return $field['default']; } return (int) min( max( (int) $value, $field['min'] ), $field['max'] ); case 'url': return esc_url_raw( trim( (string) $value ), array( 'http', 'https', 'mailto', 'tel' ) ); case 'class': $classes = array(); foreach ( preg_split( '#\s+#', (string) $value ) as $class ) { $class = substr( sanitize_html_class( $class ), 0, self::MAX_CLASS_LENGTH ); if ( '' !== $class && ! in_array( $class, $classes, true ) ) { $classes[] = $class; } } return implode( ' ', array_slice( $classes, 0, self::MAX_CLASSES ) ); default: return '' !== trim( (string) $value ) ? (string) $value : $field['default']; } } /** * Elements as the inserter dialog shows them: top-level elements with their fields and child. * * @return array> */ public static function catalog() { $definitions = self::definitions(); $catalog = array(); foreach ( $definitions as $tag => $definition ) { if ( isset( $definition['parent'] ) ) { continue; } $card = isset( $definition['card'] ) && is_array( $definition['card'] ) ? $definition['card'] : ( self::cards()[ $tag ] ?? array() ); $entry = array( 'tag' => $tag, 'label' => $definition['label'], 'group' => $card['group'] ?? '', 'description' => $card['description'] ?? '', 'keywords' => $card['keywords'] ?? array(), 'icon' => $card['icon'] ?? '', 'fields' => self::catalog_fields( $definition ), ); if ( isset( $definition['child'] ) ) { $child = $definitions[ $definition['child'] ]; $entry['child'] = array( 'tag' => $definition['child'], 'label' => $child['label'], 'fields' => self::catalog_fields( $child ), 'content' => ! empty( $child['content'] ), ); } elseif ( ! empty( $definition['content'] ) ) { $entry['content'] = true; } $catalog[] = $entry; } return $catalog; } /** * Groups of the element dialog, in the order the dialog shows them. * * @return array */ public static function groups() { $groups = array( array( 'key' => 'structure', 'label' => __( 'Structure', '3task-toolbar-for-classic-editor' ), ), array( 'key' => 'highlight', 'label' => __( 'Highlight', '3task-toolbar-for-classic-editor' ), ), array( 'key' => 'embed', 'label' => __( 'Embed', '3task-toolbar-for-classic-editor' ), ), ); /** * Groups of add-on elements, shown after our own. * * @since 1.0.2 * @param array $groups Groups in dialog order. */ return apply_filters( 'threece_element_groups', $groups ); } /** * What a card in the element dialog shows: group, one sentence, search words and icon (name in inserter-icons.js). * Search words are translated with the plugin, so each language finds its own words. * * @return array */ private static function cards() { return array( 'threece_tabs' => array( 'group' => 'structure', 'description' => __( 'Contents side by side, one tab is open.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'tabs', '3task-toolbar-for-classic-editor' ), __( 'switch', '3task-toolbar-for-classic-editor' ), __( 'register', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'layout-navbar', ), 'threece_accordion' => array( 'group' => 'structure', 'description' => __( 'Questions and answers that fold open.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'faq', '3task-toolbar-for-classic-editor' ), __( 'questions', '3task-toolbar-for-classic-editor' ), __( 'collapse', '3task-toolbar-for-classic-editor' ), __( 'toggle', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'layout-list', ), 'threece_spoiler' => array( 'group' => 'structure', 'description' => __( 'One section that appears on click.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'hide', '3task-toolbar-for-classic-editor' ), __( 'reveal', '3task-toolbar-for-classic-editor' ), __( 'read more', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'eye-off', ), 'threece_columns' => array( 'group' => 'structure', 'description' => __( 'Two or three columns, stacked on phones.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'grid', '3task-toolbar-for-classic-editor' ), __( 'layout', '3task-toolbar-for-classic-editor' ), __( 'side by side', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'columns', ), 'threece_notice' => array( 'group' => 'highlight', 'description' => __( 'Info, tip, warning or success.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'alert', '3task-toolbar-for-classic-editor' ), __( 'box', '3task-toolbar-for-classic-editor' ), __( 'callout', '3task-toolbar-for-classic-editor' ), __( 'warning', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'info-square-rounded', ), 'threece_button' => array( 'group' => 'highlight', 'description' => __( 'A link that looks like a button.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'link', '3task-toolbar-for-classic-editor' ), __( 'call to action', '3task-toolbar-for-classic-editor' ), __( 'cta', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'hand-click', ), 'threece_tooltip' => array( 'group' => 'highlight', 'description' => __( 'An explanation on hover or tap.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'hint', '3task-toolbar-for-classic-editor' ), __( 'explain', '3task-toolbar-for-classic-editor' ), __( 'glossary', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'message-circle-question', ), 'threece_fn' => array( 'group' => 'highlight', 'description' => __( 'A raised number, the text at the end of the post.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'source', '3task-toolbar-for-classic-editor' ), __( 'reference', '3task-toolbar-for-classic-editor' ), __( 'citation', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'superscript', ), 'threece_toc' => array( 'group' => 'embed', 'description' => __( 'Builds itself from the headings.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'contents', '3task-toolbar-for-classic-editor' ), __( 'toc', '3task-toolbar-for-classic-editor' ), __( 'headings', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'list-tree', ), 'threece_video' => array( 'group' => 'embed', 'description' => __( 'YouTube or Vimeo, loads only after a click.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'youtube', '3task-toolbar-for-classic-editor' ), __( 'vimeo', '3task-toolbar-for-classic-editor' ), __( 'embed', '3task-toolbar-for-classic-editor' ), __( 'privacy', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'player-play', ), 'threece_qr' => array( 'group' => 'embed', 'description' => __( 'Made on your server, without an outside service.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'barcode', '3task-toolbar-for-classic-editor' ), __( 'scan', '3task-toolbar-for-classic-editor' ), __( 'phone', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'qrcode', ), 'threece_countdown' => array( 'group' => 'embed', 'description' => __( 'Counts down to a date and time.', '3task-toolbar-for-classic-editor' ), 'keywords' => array( __( 'timer', '3task-toolbar-for-classic-editor' ), __( 'deadline', '3task-toolbar-for-classic-editor' ), __( 'date', '3task-toolbar-for-classic-editor' ) ), 'icon' => 'hourglass', ), ); } /** * Fields as the dialog needs them (bool becomes a checkbox). * * @param array $definition Element definition. * @return array> */ private static function catalog_fields( $definition ) { $fields = array(); foreach ( $definition['fields'] as $name => $field ) { $entry = array( 'name' => $name, 'type' => 'bool' === $field['type'] ? 'checkbox' : ( 'class' === $field['type'] ? 'text' : $field['type'] ), 'label' => $field['label'], 'default' => $field['default'], ); foreach ( array( 'help', 'values', 'min', 'max' ) as $key ) { if ( isset( $field[ $key ] ) ) { $entry[ $key ] = $field[ $key ]; } } // JavaScript sorts numeric object keys first; a list of pairs keeps the order of the choices (add-ons list posts by id). if ( isset( $entry['values'] ) && is_array( $entry['values'] ) && array_keys( $entry['values'] ) !== array_filter( array_keys( $entry['values'] ), 'is_string' ) ) { $pairs = array(); foreach ( $entry['values'] as $value => $label ) { $pairs[] = array( (string) $value, $label ); } $entry['values'] = $pairs; } // Only who may use the media library gets the image picker; everyone else keeps the number field. if ( isset( $field['media'] ) && current_user_can( 'upload_files' ) ) { $entry['type'] = 'image'; $entry['label'] = $field['media']['label']; $entry['help'] = $field['media']['help']; } $fields[] = $entry; } return $fields; } /** * Registers shortcodes. */ public static function register(): void { wp_register_style( 'threece-elements', THREECE_URL . 'assets/css/elements.css', array(), THREECE_VERSION ); wp_register_script_module( 'threece-tabs', THREECE_URL . 'assets/js/tabs.js', array(), THREECE_VERSION ); wp_register_script_module( 'threece-video', THREECE_URL . 'assets/js/video.js', array(), THREECE_VERSION ); wp_register_script_module( 'threece-countdown', THREECE_URL . 'assets/js/countdown.js', array(), THREECE_VERSION ); wp_register_script_module( 'threece-tooltip', THREECE_URL . 'assets/js/tooltip.js', array(), THREECE_VERSION ); foreach ( self::definitions() as $name => $definition ) { // Our own elements name a method of this class; add-ons pass any callable. $callback = is_string( $definition['callback'] ) && method_exists( __CLASS__, $definition['callback'] ) ? array( __CLASS__, $definition['callback'] ) : $definition['callback']; if ( ! is_callable( $callback ) ) { continue; } for ( $level = 1; $level <= self::MAX_DEPTH; $level++ ) { add_shortcode( self::alias( $name, $level ), $callback ); } } } /** * [threece_tabs active="1" mobile="wrap|scroll|stack" anchor_in_url] with [threece_tab title="" anchor=""] * inside. Follows the WAI-ARIA tabs pattern; without JavaScript every panel stays visible, the script * hides the inactive ones. A tab's anchor is a data attribute, never an id, so it cannot clash with * IDs of the theme; the script opens the tab when the address ends in #anchor. * * @param array|string $atts Attributes. * @param string|null $content Tabs. * @return string */ public static function tabs( $atts, $content = null ) { $atts = self::atts( 'threece_tabs', $atts ); $tabs = self::collect_children( 'threece_tab', $content ); if ( empty( $tabs ) ) { return ''; } if ( self::in_excerpt() ) { $plain = ''; foreach ( $tabs as $tab ) { $plain .= '

' . esc_html( $tab['title'] ) . '

' . $tab['content'] . ' '; } return $plain; } wp_enqueue_style( 'threece-elements' ); wp_enqueue_script_module( 'threece-tabs' ); $group = ++self::$tab_groups; $open = min( $atts['active'], count( $tabs ) ) - 1; $anchors = array(); $buttons = ''; $panels = ''; foreach ( $tabs as $i => $tab ) { $tab_id = 'threece-tab-' . $group . '-' . ( $i + 1 ); $panel_id = 'threece-panel-' . $group . '-' . ( $i + 1 ); $active = $open === $i; $anchor = self::anchor( $tab['anchor'] ); if ( '' !== $anchor && isset( $anchors[ $anchor ] ) ) { $anchor = ''; } $anchors[ $anchor ] = true; $buttons .= sprintf( '', esc_attr( $tab_id ), esc_attr( $panel_id ), $active ? 'true' : 'false', $active ? '0' : '-1', esc_html( $tab['title'] ), '' !== $anchor ? ' data-threece-anchor="' . esc_attr( $anchor ) . '"' : '' ); $panels .= sprintf( '
%3$s
%4$s
', esc_attr( $panel_id ), esc_attr( $tab_id ), esc_html( $tab['title'] ), $tab['content'], self::classes( 'threece-tabs__panel', $tab['class'] ) ); } $base = 'wrap' === $atts['mobile'] ? 'threece-tabs' : 'threece-tabs threece-tabs--mobile-' . $atts['mobile']; return '
' . $buttons . '
' . $panels . '
'; } /** * Anchor of a tab: the readable ID form of what the author typed, at most 64 characters, '' without a * letter or number. * * @param string $text Anchor as typed, with or without #. * @return string */ private static function anchor( $text ) { if ( ! preg_match( '#[\p{L}\p{N}]#u', $text ) ) { return ''; } $anchor = self::slug( $text ); $anchor = function_exists( 'mb_substr' ) ? mb_substr( $anchor, 0, 64, 'UTF-8' ) : (string) preg_replace( '#^(.{0,64}).*$#us', '$1', $anchor ); return rtrim( $anchor, '-' ); } /** * [threece_tab title=""] one tab. Outside of [threece_tabs] it shows title and content. * * @param array|string $atts Attributes. * @param string|null $content Tab content. * @return string */ public static function tab( $atts, $content = null ) { $atts = self::atts( 'threece_tab', $atts ); $inner = self::content( $content ); if ( ! self::collecting( 'threece_tab' ) ) { wp_enqueue_style( 'threece-elements' ); return '
' . esc_html( $atts['title'] ) . '
' . $inner . '
'; } self::add_child( array( 'title' => $atts['title'], 'anchor' => $atts['anchor'], 'content' => self::mark_hidden_headings( $inner ), 'class' => $atts['class'], ) ); return ''; } /** * [threece_accordion] wraps [threece_item] entries. * * @param array|string $atts Attributes (none used). * @param string|null $content Entries. * @return string */ public static function accordion( $atts, $content = null ) { wp_enqueue_style( 'threece-elements' ); $atts = self::atts( 'threece_accordion', $atts ); return '
' . self::container_content( $content ) . '
'; } /** * [threece_item title="" open="no"] one accordion entry. * * @param array|string $atts Attributes. * @param string|null $content Entry text. * @return string */ public static function item( $atts, $content = null ) { return self::details( 'threece-item', $atts, $content, 'threece_item' ); } /** * [threece_spoiler title="" open="no"] hidden text that opens on click. * * @param array|string $atts Attributes. * @param string|null $content Hidden text. * @return string */ public static function spoiler( $atts, $content = null ) { return self::details( 'threece-spoiler', $atts, $content, 'threece_spoiler' ); } /** * [threece_notice type="info|tip|warning|success" title=""] highlighted box with icon. * * @param array|string $atts Attributes. * @param string|null $content Box text. * @return string */ public static function notice( $atts, $content = null ) { wp_enqueue_style( 'threece-elements' ); $atts = self::atts( 'threece_notice', $atts ); $type = $atts['type']; if ( self::in_excerpt() ) { return ( '' !== $atts['title'] ? '

' . esc_html( $atts['title'] ) . '

' : '' ) . self::content( $content ) . ' '; } $title = '' !== $atts['title'] ? '

' . esc_html( $atts['title'] ) . '

' : ''; return sprintf( '
%3$s%4$s
', esc_attr( $type ), self::NOTICE_ICONS[ $type ], $title, self::content( $content ), self::classes( 'threece-notice threece-notice--' . $type, $atts['class'] ) ); } /** * [threece_button url="" style="primary|secondary|outline" new_tab nofollow download]Label[/threece_button]. * Only http, https, mailto, tel, relative paths and anchors become links; anything else shows the * label without a link. * * @param array|string $atts Attributes. * @param string|null $content Label, used as plain text. * @return string */ public static function button( $atts, $content = null ) { wp_enqueue_style( 'threece-elements' ); $atts = self::atts( 'threece_button', $atts ); $label = trim( wp_strip_all_tags( do_shortcode( (string) $content ) ) ); $label = '' !== $label ? $label : __( 'Button', '3task-toolbar-for-classic-editor' ); $class = self::classes( 'threece-button threece-button--' . $atts['style'], $atts['class'] ); if ( '' === $atts['url'] ) { return '' . esc_html( $label ) . ''; } $rel = array(); if ( $atts['new_tab'] ) { $rel[] = 'noopener'; } if ( $atts['nofollow'] ) { $rel[] = 'nofollow'; $rel[] = 'sponsored'; } return sprintf( '%6$s', $class, esc_url( $atts['url'], array( 'http', 'https', 'mailto', 'tel' ) ), $atts['new_tab'] ? ' target="_blank"' : '', $rel ? ' rel="' . esc_attr( implode( ' ', $rel ) ) . '"' : '', $atts['download'] ? ' download' : '', esc_html( $label ) ); } /** * Renders a container's content and returns the children it collected. * * @param string $child_tag Expected child shortcode. * @param string|null $content Container content. * @return array> */ public static function collect_children( $child_tag, $content ) { self::$collect[] = array( 'child' => $child_tag, 'items' => array(), ); do_shortcode( shortcode_unautop( (string) $content ) ); $frame = array_pop( self::$collect ); return $frame['items']; } /** * Whether the innermost open container expects this child. * * @param string $child_tag Child shortcode. */ public static function collecting( $child_tag ): bool { $top = end( self::$collect ); return false !== $top && $child_tag === $top['child']; } /** * Adds a child to the innermost open container. * * @param array $item Child data. */ public static function add_child( $item ): void { self::$collect[ count( self::$collect ) - 1 ]['items'][] = $item; } /** * [threece_columns] with [threece_column span="1-3"] inside: a grid whose widths come only from * the cleaned whole numbers; below 600 px the columns stack. * * @param array|string $atts Attributes. * @param string|null $content Columns. * @return string */ public static function columns( $atts, $content = null ) { $atts = self::atts( 'threece_columns', $atts ); $columns = self::collect_children( 'threece_column', $content ); if ( empty( $columns ) ) { return ''; } wp_enqueue_style( 'threece-elements' ); $widths = array(); $html = ''; foreach ( $columns as $column ) { $widths[] = (int) $column['span'] . 'fr'; $html .= '
' . $column['content'] . '
'; } return '
' . $html . '
'; } /** * [threece_column span="1-3"] one column. Outside of [threece_columns] it shows its content. * * @param array|string $atts Attributes. * @param string|null $content Column content. * @return string */ public static function column( $atts, $content = null ) { $atts = self::atts( 'threece_column', $atts ); $inner = self::content( $content ); if ( ! self::collecting( 'threece_column' ) ) { wp_enqueue_style( 'threece-elements' ); return '
' . $inner . '
'; } self::add_child( array( 'span' => $atts['span'], 'content' => $inner, 'class' => $atts['class'], ) ); return ''; } /** * [threece_toc title="" from="2" to="3" open numbered] leaves a placeholder; build_tocs() fills it * once the whole content is rendered. * * @param array|string $atts Attributes. * @return string */ public static function toc( $atts ) { $atts = self::atts( 'threece_toc', $atts ); if ( $atts['from'] > $atts['to'] ) { list( $atts['from'], $atts['to'] ) = array( $atts['to'], $atts['from'] ); } self::$tocs[] = $atts; return ''; } /** * Gives the headings stable IDs and replaces the placeholders with the lists. Content without a * placeholder is returned untouched. * * @param mixed $content Rendered post content. * @return mixed */ public static function build_tocs( $content ) { if ( ! is_string( $content ) || ! preg_match_all( '##', $content, $found ) ) { return $content; } $requests = array(); foreach ( array_unique( $found[1] ) as $number ) { if ( isset( self::$tocs[ (int) $number ] ) ) { $requests[ (int) $number ] = self::$tocs[ (int) $number ]; } } $from = $requests ? min( array_column( $requests, 'from' ) ) : 2; $to = $requests ? max( array_column( $requests, 'to' ) ) : 3; preg_match_all( '#\sid=["\']([^"\']+)["\']#', $content, $existing ); $used = array_flip( $existing[1] ); $headings = array(); $content = (string) preg_replace_callback( '#]*)?>(.*?)#is', static function ( $m ) use ( $from, $to, &$used, &$headings ) { $level = (int) $m[1]; $attrs = $m[2]; if ( $level < $from || $level > $to || false !== strpos( $attrs, self::TOC_SKIP ) ) { return $m[0]; } $text = trim( html_entity_decode( wp_strip_all_tags( $m[3] ), ENT_QUOTES, 'UTF-8' ) ); if ( '' === $text ) { return $m[0]; } if ( preg_match( '#\sid=["\']([^"\']+)["\']#', $attrs, $id ) ) { $headings[] = array( $level, $id[1], $text ); return $m[0]; } $slug = self::slug( $text ); $id = $slug; for ( $n = 2; isset( $used[ $id ] ); $n++ ) { $id = $slug . '-' . $n; } $used[ $id ] = true; $headings[] = array( $level, $id, $text ); return '' . $m[3] . ''; }, $content ); return (string) preg_replace_callback( '##', static function ( $m ) use ( $requests, $headings ) { return isset( $requests[ (int) $m[1] ] ) ? self::toc_html( $requests[ (int) $m[1] ], $headings ) : ''; }, $content ); } /** * Readable ID from heading text; Latin accents are transliterated, other scripts stay. * * @param string $text Heading text. * @return string */ private static function slug( $text ) { $slug = remove_accents( str_replace( array( 'ß', 'ẞ' ), array( 'ss', 'SS' ), $text ) ); $slug = function_exists( 'mb_strtolower' ) ? mb_strtolower( $slug, 'UTF-8' ) : strtolower( $slug ); $slug = trim( (string) preg_replace( '#[^\p{L}\p{N}]+#u', '-', $slug ), '-' ); return '' !== $slug ? $slug : 'section'; } /** * One table of contents as nested list. * * @param array $atts Cleaned attributes. * @param array> $headings Level, ID and text of every heading. * @return string */ private static function toc_html( $atts, $headings ) { $items = array_values( array_filter( $headings, static function ( $h ) use ( $atts ) { return $h[0] >= $atts['from'] && $h[0] <= $atts['to']; } ) ); if ( empty( $items ) ) { return ''; } wp_enqueue_style( 'threece-elements' ); $parents = array(); $stack = array(); foreach ( $items as $i => $item ) { while ( $stack && $items[ end( $stack ) ][0] >= $item[0] ) { array_pop( $stack ); } $parents[ $i ] = $stack ? end( $stack ) : -1; $stack[] = $i; } $tag = $atts['numbered'] ? 'ol' : 'ul'; return sprintf( '', self::classes( 'threece-toc', $atts['class'] ), esc_attr( $atts['title'] ), $atts['open'] ? ' open' : '', esc_html( $atts['title'] ), self::toc_list( $items, $parents, -1, $tag ) ); } /** * List of the children of one entry. * * @param array> $items Headings. * @param array $parents Parent index per heading, -1 for top level. * @param int $owner Parent index to list, -1 for the top level. * @param string $tag ul or ol. * @return string */ private static function toc_list( $items, $parents, $owner, $tag ) { $html = ''; foreach ( $parents as $i => $p ) { if ( $p === $owner ) { $html .= '
  • ' . esc_html( $items[ $i ][2] ) . '' . self::toc_list( $items, $parents, $i, $tag ) . '
  • '; } } return '' === $html ? '' : '<' . $tag . ( -1 === $owner ? ' class="threece-toc__list"' : '' ) . '>' . $html . ''; } /** * Marks headings inside a collapsible part, see TOC_SKIP. * * @param string $html Inner content. * @return string */ private static function mark_hidden_headings( $html ) { return (string) preg_replace( '#])#i', '|string $atts Attributes. * @return string */ public static function video( $atts ) { $atts = self::atts( 'threece_video', $atts ); $video = self::parse_video( $atts['url'] ); if ( null === $video ) { if ( '' === $atts['url'] ) { return ''; } return '' . esc_html( '' !== $atts['title'] ? $atts['title'] : $atts['url'] ) . ''; } wp_enqueue_style( 'threece-elements' ); wp_enqueue_script_module( 'threece-video' ); $name = 'youtube' === $video['provider'] ? 'YouTube' : 'Vimeo'; $start = $atts['start'] > 0 ? $atts['start'] : $video['start']; $poster = ''; if ( $atts['image'] > 0 && wp_attachment_is_image( $atts['image'] ) ) { $poster = wp_get_attachment_image( $atts['image'], 'large', false, array( 'class' => 'threece-video__poster', 'alt' => '', 'loading' => 'lazy', ) ); } $notice = 'youtube' === $video['provider'] ? __( 'Playing loads the video from YouTube and sends data to Google.', '3task-toolbar-for-classic-editor' ) : __( 'Playing loads the video from Vimeo and sends data to Vimeo.', '3task-toolbar-for-classic-editor' ); return sprintf( '
    %5$s%6$s
    %8$s
    ', self::classes( 'threece-video', $atts['class'] ), esc_attr( $video['provider'] ), esc_attr( $video['id'] ), (int) $start, $poster, '' !== $atts['title'] ? '' . esc_html( $atts['title'] ) . '' : '', /* translators: %s: video title or provider name */ esc_attr( sprintf( __( 'Load video: %s', '3task-toolbar-for-classic-editor' ), '' !== $atts['title'] ? $atts['title'] : $name ) ), esc_html( $notice ) ); } /** * [threece_countdown to="2099-12-24 18:00" expired=""]. The end is read in the time zone of the * site; the page shows it as